diff --git a/README.md b/README.md index a75ae1046d..250f71abb3 100644 --- a/README.md +++ b/README.md @@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![97%](https://geps.dev/progress/97) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![36%](https://geps.dev/progress/36) | -| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) | +| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) | +| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) | | Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) | -| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) | -| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) | -| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) | +| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) | +| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) | +| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) | | Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) | | Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![95%](https://geps.dev/progress/95) | -| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) | -| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) | -| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) | -| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![35%](https://geps.dev/progress/35) | -| Irish (Gaeilge) (ga_IE) | ![39%](https://geps.dev/progress/39) | -| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) | -| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) | -| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) | +| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) | +| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) | +| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) | +| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) | +| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) | +| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) | +| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) | +| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) | +| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) | | Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) | | Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) | -| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) | -| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) | -| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) | -| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) | -| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) | +| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) | +| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) | +| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) | +| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) | +| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) | | Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) | -| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) | -| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) | +| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) | +| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) | | Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) | -| Spanish (Español) (es_ES) | ![97%](https://geps.dev/progress/97) | -| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) | -| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) | +| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) | +| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) | +| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | -| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) | -| Turkish (Türkçe) (tr_TR) | ![42%](https://geps.dev/progress/42) | -| Ukrainian (Українська) (uk_UA) | ![41%](https://geps.dev/progress/41) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) | +| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) | +| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) | +| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) | | Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | ## Stirling PDF Enterprise diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 405643877d..99fb34b0db 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1946,6 +1946,25 @@ viewer.zoomIn=Zoom in # Tool Picker toolPicker.searchPlaceholder=Search tools... toolPicker.noToolsFound=No tools found +toolPanel.toggle.legacy=Switch to legacy mode +toolPanel.toggle.sidebar=Switch to sidebar mode +toolPanel.placeholder=Choose a tool to get started +toolPanel.legacy.heading=All tools (legacy view) +toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery. +toolPanel.legacy.descriptionsOn=Showing descriptions +toolPanel.legacy.descriptionsOff=Descriptions hidden +toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need. +toolPanel.legacy.matchedSynonym=Matches "{{text}}" +toolPanel.modePrompt.title=Choose how you browse tools +toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools. +toolPanel.modePrompt.sidebarTitle=Advanced sidebar +toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching. +toolPanel.modePrompt.recommended=Recommended +toolPanel.modePrompt.chooseSidebar=Use advanced sidebar +toolPanel.modePrompt.legacyTitle=Legacy fullscreen +toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one. +toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen +toolPanel.modePrompt.dismiss=Maybe later pageEditor.reset=Reset Changes pageEditor.zoomIn=Zoom In pageEditor.zoomOut=Zoom Out @@ -1958,3 +1977,39 @@ viewer.nextPage=Next Page viewer.pageNavigation=Page Navigation viewer.currentPage=Current Page viewer.totalPages=Total Pages +toolPanel.legacy.favorites=Favourites +toolPanel.legacy.recent=Recently used +toolPanel.legacy.favorite=Add to favourites +toolPanel.legacy.unfavorite=Remove from favourites +toolPanel.legacy.settings.title=Customise appearance +toolPanel.legacy.settings.iconBackground.label=Tool icon background +toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons +toolPanel.legacy.settings.iconBackground.none=None +toolPanel.legacy.settings.iconBackground.hover=On hover +toolPanel.legacy.settings.iconBackground.always=Always +toolPanel.legacy.settings.iconColor.label=Tool icon colour +toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons +toolPanel.legacy.settings.iconColor.colored=Coloured +toolPanel.legacy.settings.iconColor.vibrant=Vibrant +toolPanel.legacy.settings.iconColor.monochrome=Monochrome +toolPanel.legacy.settings.sectionTitle.label=Section titles +toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles +toolPanel.legacy.settings.sectionTitle.colored=Coloured +toolPanel.legacy.settings.sectionTitle.neutral=Neutral +toolPanel.legacy.settings.headerIcon.label=Section header icons +toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons +toolPanel.legacy.settings.headerIcon.colored=Coloured +toolPanel.legacy.settings.headerIcon.monochrome=Monochrome +toolPanel.legacy.settings.headerBadge.label=Section header badges +toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers +toolPanel.legacy.settings.headerBadge.colored=Coloured +toolPanel.legacy.settings.headerBadge.neutral=Neutral +toolPanel.legacy.settings.border.label=Tool item borders +toolPanel.legacy.settings.border.description=Show borders around tool items +toolPanel.legacy.settings.border.visible=Visible +toolPanel.legacy.settings.border.hidden=Hidden +toolPanel.legacy.settings.hover.label=Hover effect intensity +toolPanel.legacy.settings.hover.description=How prominent the hover effect should be +toolPanel.legacy.settings.hover.subtle=Subtle +toolPanel.legacy.settings.hover.moderate=Moderate +toolPanel.legacy.settings.hover.prominent=Prominent diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index d633c5163d..8294d34f53 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1,4 +1,21 @@ { + "toolPanel": { + "modePrompt": { + "title": "Choose how you browse tools", + "description": "Preview both layouts and decide how you want to explore Stirling PDF tools.", + "sidebarTitle": "Sidebar mode", + "sidebarDescription": "Keep tools alongside your workspace for quick switching.", + "recommended": "Recommended", + "chooseSidebar": "Use sidebar mode", + "fullscreenTitle": "Fullscreen mode - (legacy)", + "fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.", + "chooseFullscreen": "Use fullscreen mode", + "dismiss": "Maybe later" + }, + "fullscreen": { + "showDetails": "Show Details" + } + }, "unsavedChanges": "You have unsaved changes to your PDF.", "areYouSure": "Are you sure you want to leave?", "unsavedChangesTitle": "Unsaved Changes", @@ -598,11 +615,6 @@ "title": "Redact", "desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)" }, - "overlayPdfs": { - "tags": "overlay,combine,stack", - "title": "Overlay PDFs", - "desc": "Overlays PDFs on-top of another PDF" - }, "splitBySections": { "tags": "split,sections,divide", "title": "Split PDF by Sections", @@ -2550,11 +2562,15 @@ "overlay-pdfs": { "tags": "Overlay", "header": "Overlay PDF Files", + "title": "Overlay PDFs", + "desc": "Overlay one PDF on top of another", "baseFile": { "label": "Select Base PDF File" }, "overlayFiles": { - "label": "Select Overlay PDF Files" + "label": "Select Overlay PDF Files", + "placeholder": "Choose PDF(s)...", + "addMore": "Add more PDFs..." }, "mode": { "label": "Select Overlay Mode", @@ -2564,14 +2580,49 @@ }, "counts": { "label": "Overlay Counts (for Fixed Repeat Mode)", - "placeholder": "Enter comma-separated counts (e.g., 2,3,1)" + "placeholder": "Enter comma-separated counts (e.g., 2,3,1)", + "item": "Count for file" }, "position": { "label": "Select Overlay Position", "foreground": "Foreground", "background": "Background" }, - "submit": "Submit" + "submit": "Submit", + "settings": { + "title": "Settings" + }, + "results": { + "title": "Overlay Results" + }, + "tooltip": { + "header": { + "title": "Overlay PDFs Overview" + }, + "description": { + "title": "Description", + "text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background." + }, + "mode": { + "title": "Overlay Mode", + "text": "Choose how to distribute overlay pages across the base PDF pages.", + "sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.", + "interleaved": "Interleaved Overlay: Take one page from each overlay in turn.", + "fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers." + }, + "position": { + "title": "Overlay Position", + "text": "Foreground places the overlay on top of the page. Background places it behind." + }, + "overlayFiles": { + "title": "Overlay Files", + "text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes." + }, + "counts": { + "title": "Counts (Fixed Repeat only)", + "text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat." + } + } }, "split-by-sections": { "tags": "Section Split, Divide, Customize,Customise", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index b4ac8302dd..c92c4c56e4 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -2,6 +2,23 @@ "language": { "direction": "ltr" }, + "toolPanel": { + "modePrompt": { + "title": "Choose how you browse tools", + "description": "Preview both layouts and decide how you want to explore Stirling PDF tools.", + "sidebarTitle": "Sidebar mode", + "sidebarDescription": "Keep tools alongside your workspace for quick switching.", + "recommended": "Recommended", + "chooseSidebar": "Use sidebar mode", + "fullscreenTitle": "Fullscreen mode - (legacy)", + "fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.", + "chooseFullscreen": "Use fullscreen mode", + "dismiss": "Maybe later" + }, + "fullscreen": { + "showDetails": "Show Details" + } + }, "addPageNumbers": { "fontSize": "Font Size", "fontName": "Font Name", @@ -1521,11 +1538,15 @@ "overlay-pdfs": { "tags": "Overlay", "header": "Overlay PDF Files", + "title": "Overlay PDFs", + "desc": "Overlay one PDF on top of another", "baseFile": { "label": "Select Base PDF File" }, "overlayFiles": { - "label": "Select Overlay PDF Files" + "label": "Select Overlay PDF Files", + "placeholder": "Choose PDF(s)...", + "addMore": "Add more PDFs..." }, "mode": { "label": "Select Overlay Mode", @@ -1535,14 +1556,49 @@ }, "counts": { "label": "Overlay Counts (for Fixed Repeat Mode)", - "placeholder": "Enter comma-separated counts (e.g., 2,3,1)" + "placeholder": "Enter comma-separated counts (e.g., 2,3,1)", + "item": "Count for file" }, "position": { "label": "Select Overlay Position", "foreground": "Foreground", "background": "Background" }, - "submit": "Submit" + "submit": "Submit", + "settings": { + "title": "Settings" + }, + "results": { + "title": "Overlay Results" + }, + "tooltip": { + "header": { + "title": "Overlay PDFs Overview" + }, + "description": { + "title": "Description", + "text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background." + }, + "mode": { + "title": "Overlay Mode", + "text": "Choose how to distribute overlay pages across the base PDF pages.", + "sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.", + "interleaved": "Interleaved Overlay: Take one page from each overlay in turn.", + "fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers." + }, + "position": { + "title": "Overlay Position", + "text": "Foreground places the overlay on top of the page. Background places it behind." + }, + "overlayFiles": { + "title": "Overlay Files", + "text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes." + }, + "counts": { + "title": "Counts (Fixed Repeat only)", + "text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat." + } + } }, "split-by-sections": { "tags": "Section Split, Divide, Customize", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 96185902b6..4f8c203caf 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -9,6 +9,7 @@ import MobileLayout from './fileManager/MobileLayout'; import DesktopLayout from './fileManager/DesktopLayout'; import DragOverlay from './fileManager/DragOverlay'; import { FileManagerProvider } from '../contexts/FileManagerContext'; +import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex'; import { isGoogleDriveConfigured } from '../services/googleDrivePickerService'; import { loadScript } from '../utils/scriptLoader'; @@ -87,6 +88,7 @@ const FileManager: React.FC = ({ selectedTool }) => { }, []); // Preload Google Drive scripts if configured + useEffect(() => { if (isGoogleDriveConfigured()) { // Load scripts in parallel without blocking @@ -125,6 +127,7 @@ const FileManager: React.FC = ({ selectedTool }) => { radius="md" className="overflow-hidden p-0" withCloseButton={false} + zIndex={Z_INDEX_FILE_MANAGER_MODAL} styles={{ content: { position: 'relative', diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 90ee6eebe6..5c76d2248c 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, Center, Box, LoadingOverlay, Stack, Group } from '@mantine/core'; @@ -62,7 +62,7 @@ const FileEditor = ({ const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode - React.useEffect(() => { + useEffect(() => { if (toolMode) { setSelectionMode(true); } diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx index b1e7e93e32..d4ef7bd07a 100644 --- a/frontend/src/components/fileManager/FileDetails.tsx +++ b/frontend/src/components/fileManager/FileDetails.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Stack, Button, Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; @@ -50,7 +50,7 @@ const FileDetails: React.FC = ({ }; // Reset index when selection changes - React.useEffect(() => { + useEffect(() => { if (currentFileIndex >= selectedFiles.length) { setCurrentFileIndex(0); } diff --git a/frontend/src/components/fileManager/FileHistoryGroup.tsx b/frontend/src/components/fileManager/FileHistoryGroup.tsx index 16bc02aa13..771499584a 100644 --- a/frontend/src/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/components/fileManager/FileHistoryGroup.tsx @@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC = ({ - {sortedHistory.map((historyFile, _index) => ( + {sortedHistory.map((historyFile) => ( = ({ onDoubleClick={() => onFileDoubleClick(historyFile)} isHistoryFile={true} // This enables "Add to Recents" in menu isLatestVersion={false} // History files are never latest - // onAddToRecents is accessed from context by FileListItem /> ))} diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index a3353df194..8436d2e29a 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -40,7 +40,7 @@ const FileListItem: React.FC = ({ const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); - const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext(); + const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext(); // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); @@ -188,7 +188,6 @@ const FileListItem: React.FC = ({ leftSection={} onClick={(e) => { e.stopPropagation(); - onAddToRecents(file); }} > {t('fileManager.restore', 'Restore')} diff --git a/frontend/src/components/shared/AppConfigModal.tsx b/frontend/src/components/shared/AppConfigModal.tsx index 2ed7f71b71..1605471800 100644 --- a/frontend/src/components/shared/AppConfigModal.tsx +++ b/frontend/src/components/shared/AppConfigModal.tsx @@ -6,6 +6,7 @@ import Overview from './config/configSections/Overview'; import { createConfigNavSections } from './config/configNavSections'; import { NavKey } from './config/types'; import './AppConfigModal.css'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; interface AppConfigModalProps { opened: boolean; @@ -77,7 +78,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { centered radius="lg" withCloseButton={false} - style={{ zIndex: 1000 }} + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} overlayProps={{ opacity: 0.35, blur: 2 }} padding={0} fullScreen={isMobile} diff --git a/frontend/src/components/shared/Badge.tsx b/frontend/src/components/shared/Badge.tsx new file mode 100644 index 0000000000..7bf3283e4f --- /dev/null +++ b/frontend/src/components/shared/Badge.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Box } from '@mantine/core'; + +interface BadgeProps { + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg'; + variant?: 'default' | 'colored'; + color?: string; + textColor?: string; + backgroundColor?: string; + className?: string; + style?: React.CSSProperties; +} + +const Badge: React.FC = ({ + children, + size = 'sm', + variant = 'default', + color, + textColor, + backgroundColor, + className, + style +}) => { + const getSizeStyles = () => { + switch (size) { + case 'sm': + return { + padding: '0.125rem 0.5rem', + fontSize: '0.75rem', + fontWeight: 700, + borderRadius: '0.5rem', + }; + case 'md': + return { + padding: '0.25rem 0.75rem', + fontSize: '0.875rem', + fontWeight: 700, + borderRadius: '0.625rem', + }; + case 'lg': + return { + padding: '0.375rem 1rem', + fontSize: '1rem', + fontWeight: 700, + borderRadius: '0.75rem', + }; + default: + return {}; + } + }; + + const getVariantStyles = () => { + // If explicit colors are provided, use them + if (textColor && backgroundColor) { + return { + backgroundColor, + color: textColor, + }; + } + + // If a single color is provided, use it for text and 20% opacity for background + if (color) { + return { + backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`, + color: color, + }; + } + + // If variant is colored but no color provided, use default colored styling + if (variant === 'colored') { + return { + backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`, + color: 'var(--category-color-default)', + borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`, + border: '1px solid', + }; + } + + // Default styling + return { + background: 'var(--tool-header-badge-bg)', + color: 'var(--tool-header-badge-text)', + }; + }; + + return ( + + {children} + + ); +}; + +export default Badge; diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx index 9bb2694a1e..bb571b5897 100644 --- a/frontend/src/components/shared/FilePickerModal.tsx +++ b/frontend/src/components/shared/FilePickerModal.tsx @@ -125,6 +125,7 @@ const FilePickerModal = ({ title={t("fileUpload.selectFromStorage", "Select Files from Storage")} size="lg" scrollAreaComponent={ScrollArea.Autosize} + zIndex={1100} > {storedFiles.length === 0 ? ( diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index 0ea80a79fa..507dab16b0 100644 --- a/frontend/src/components/shared/LanguageSelector.tsx +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '../../i18n'; import LocalIcon from './LocalIcon'; import styles from './LanguageSelector.module.css'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; // Types interface LanguageSelectorProps { @@ -209,6 +210,7 @@ const LanguageSelector: React.FC = ({ position = 'bottom- width={600} position={position} offset={offset} + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} transitionProps={{ transition: 'scale-y', duration: 200, @@ -264,6 +266,7 @@ const LanguageSelector: React.FC = ({ position = 'bottom- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))', + zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, }} > diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index af9c8acff6..7a8dd7fdd7 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -24,7 +24,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); - const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); + const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -68,7 +68,8 @@ const QuickAccessBar = forwardRef((_, ref) => { onClick: (e: React.MouseEvent) => handleClick(e), 'aria-label': config.name } : { - onClick: () => handleClick() + onClick: () => handleClick(), + 'aria-label': config.name })} size={isActive ? (config.size || 'lg') : 'lg'} variant="subtle" @@ -98,12 +99,10 @@ const QuickAccessBar = forwardRef((_, ref) => { type: 'navigation', onClick: () => { setActiveButton('read'); - handleBackToTools(); handleReaderToggle(); } }, - // TODO: Add sign - //{ + // { // id: 'sign', // name: t("quickAccess.sign", "Sign"), // icon: , @@ -114,7 +113,7 @@ const QuickAccessBar = forwardRef((_, ref) => { // setActiveButton('sign'); // handleToolSelect('sign'); // } - //}, + // }, { id: 'automate', name: t("quickAccess.automate", "Automate"), @@ -230,6 +229,7 @@ const QuickAccessBar = forwardRef((_, ref) => { onClick={config.onClick} style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} + aria-label={config.name} data-testid={`${config.id}-button`} > diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 2b4569dc51..44af1e215b 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -20,18 +20,23 @@ import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls'; import { parseSelection } from '../../utils/bulkselection/parseSelection'; +import { useSidebarContext } from '../../contexts/SidebarContext'; + export default function RightRail() { + const { sidebarRefs } = useSidebarContext(); const { t } = useTranslation(); const [isPanning, setIsPanning] = useState(false); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); const { toggleTheme } = useRainbowThemeContext(); - const { buttons, actions } = useRightRail(); + const { buttons, actions, allButtonsDisabled } = useRightRail(); + const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]); // Access PageEditor functions for page-editor-specific actions - const { pageEditorFunctions } = useToolWorkflow(); + const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); + const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; // CSV input state for page selection const [csvInput, setCsvInput] = useState(""); @@ -176,19 +181,19 @@ export default function RightRail() { }, [currentView]); return ( -
+
{topButtons.length > 0 && ( <>
{topButtons.map(btn => ( - + actions[btn.id]?.()} - disabled={btn.disabled} + disabled={btn.disabled || allButtonsDisabled || disableForFullscreen} > {btn.icon} @@ -206,7 +211,7 @@ export default function RightRail() { >
{/* Search */} - +
@@ -214,7 +219,7 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - disabled={currentView !== 'viewer'} + disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'} > @@ -234,7 +239,7 @@ export default function RightRail() { {/* Pan Mode */} - + {/* Rotate Left */} - + { viewerContext?.rotationActions.rotateBackward(); }} - disabled={currentView !== 'viewer'} + disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} > {/* Rotate Right */} - + { viewerContext?.rotationActions.rotateForward(); }} - disabled={currentView !== 'viewer'} + disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} > {/* Sidebar Toggle */} - + { viewerContext?.toggleThumbnailSidebar(); }} - disabled={currentView !== 'viewer'} + disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} > {/* Annotation Controls */} - +
@@ -308,14 +316,14 @@ export default function RightRail() { >
{/* Select All Button */} - +
@@ -323,14 +331,14 @@ export default function RightRail() { {/* Deselect All Button */} - +
@@ -339,7 +347,7 @@ export default function RightRail() { {/* Select by Numbers - page editor only, with animated presence */} {pageControlsMounted && ( - +
@@ -349,7 +357,7 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - disabled={!pageControlsVisible || totalItems === 0} + disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen} aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'} > @@ -376,7 +384,7 @@ export default function RightRail() { {/* Delete Selected Pages - page editor only, with animated presence */} {pageControlsMounted && ( - +
@@ -385,7 +393,7 @@ export default function RightRail() { radius="md" className="right-rail-icon" onClick={() => { pageEditorFunctions?.handleDelete?.(); }} - disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen} aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} > @@ -398,7 +406,7 @@ export default function RightRail() { {/* Export Selected Pages - page editor only */} {pageControlsMounted && ( - +
{ pageEditorFunctions?.onExportSelected?.(); }} - disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen} aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'} > @@ -417,7 +425,7 @@ export default function RightRail() { )} {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} - +
@@ -441,7 +450,8 @@ export default function RightRail() { {/* Theme toggle and Language dropdown */}
- + - + +
+ +
+
0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) - } position="left" offset={12} arrow> + } position="left" offset={12} arrow portalTarget={document.body}>
@@ -481,4 +495,3 @@ export default function RightRail() { ); } - diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index 543422609b..775cbbee13 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -8,6 +8,7 @@ import { TooltipContent } from './tooltip/TooltipContent'; import { useSidebarContext } from '../../contexts/SidebarContext'; import { BASE_PATH } from '../../constants/app'; import styles from './tooltip/Tooltip.module.css'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; export interface TooltipProps { sidebarTooltip?: boolean; @@ -28,6 +29,10 @@ export interface TooltipProps { pinOnClick?: boolean; /** If true, clicking outside also closes when not pinned (default true) */ closeOnOutside?: boolean; + /** If true, tooltip interaction is disabled entirely */ + disabled?: boolean; + /** If false, tooltip will not open on focus (hover only) */ + openOnFocus?: boolean; } export const Tooltip: React.FC = ({ @@ -48,6 +53,8 @@ export const Tooltip: React.FC = ({ containerStyle = {}, pinOnClick = false, closeOnOutside = true, + disabled = false, + openOnFocus = true, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); @@ -68,7 +75,7 @@ export const Tooltip: React.FC = ({ const sidebarContext = sidebarTooltip ? useSidebarContext() : null; const isControlled = controlledOpen !== undefined; - const open = isControlled ? !!controlledOpen : internalOpen; + const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; const setOpen = useCallback( (newOpen: boolean) => { @@ -148,15 +155,16 @@ export const Tooltip: React.FC = ({ // === Trigger handlers === const openWithDelay = useCallback(() => { clearTimers(); + if (disabled) return; openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0)); - }, [clearTimers, setOpen, delay]); + }, [clearTimers, setOpen, delay, disabled]); const handlePointerEnter = useCallback( (e: React.PointerEvent) => { - if (!isPinned) openWithDelay(); + if (!isPinned && !disabled) openWithDelay(); (children.props as any)?.onPointerEnter?.(e); }, - [isPinned, openWithDelay, children.props] + [isPinned, openWithDelay, children.props, disabled] ); const handlePointerLeave = useCallback( @@ -219,10 +227,10 @@ export const Tooltip: React.FC = ({ // Keyboard / focus accessibility const handleFocus = useCallback( (e: React.FocusEvent) => { - if (!isPinned) openWithDelay(); + if (!isPinned && !disabled && openOnFocus) openWithDelay(); (children.props as any)?.onFocus?.(e); }, - [isPinned, openWithDelay, children.props] + [isPinned, openWithDelay, children.props, disabled, openOnFocus] ); const handleBlur = useCallback( @@ -291,7 +299,7 @@ export const Tooltip: React.FC = ({ left: coords.left, width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined), minWidth, - zIndex: 9999, + zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, visibility: positionReady ? 'visible' : 'hidden', opacity: positionReady ? 1 : 0, color: 'var(--text-primary)', @@ -345,9 +353,13 @@ export const Tooltip: React.FC = ({ return ( <> {childWithHandlers} - {portalTarget && document.body.contains(portalTarget) - ? tooltipElement && createPortal(tooltipElement, portalTarget) - : tooltipElement} + {(() => { + const defaultTarget = typeof document !== 'undefined' ? document.body : null; + const target = portalTarget ?? defaultTarget; + return tooltipElement && target + ? createPortal(tooltipElement, target) + : tooltipElement; + })()} ); }; diff --git a/frontend/src/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/components/shared/config/configSections/GeneralSection.tsx index 07f71c484c..a79c15d72f 100644 --- a/frontend/src/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/components/shared/config/configSections/GeneralSection.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core'; +import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePreferences } from '../../../../contexts/PreferencesContext'; +import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; @@ -26,6 +27,24 @@ const GeneralSection: React.FC = () => { +
+
+ + {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} + + + {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} + +
+ updatePreference('defaultToolPanelMode', val as ToolPanelMode)} + data={[ + { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, + { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, + ]} + /> +
{ const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys(); const [editingTool, setEditingTool] = useState(null); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]); + const filteredTools = useMemo(() => { + if (!searchQuery.trim()) return tools; + + const query = searchQuery.toLowerCase(); + return tools.filter(([toolId, tool]) => + tool.name.toLowerCase().includes(query) || + tool.description.toLowerCase().includes(query) || + toolId.toLowerCase().includes(query) + ); + }, [tools, searchQuery]); + useEffect(() => { if (!editingTool) { return; @@ -47,15 +59,17 @@ const HotkeysSection: React.FC = () => { } const handleKeyDown = (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); setEditingTool(null); setError(null); return; } + event.preventDefault(); + event.stopPropagation(); + const binding = eventToBinding(event as KeyboardEvent); if (!binding) { const osKey = isMac ? 'mac' : 'windows'; @@ -71,7 +85,10 @@ const HotkeysSection: React.FC = () => { )); if (conflictEntry) { - const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0]; + const conflictKey = conflictEntry[0]; + const conflictTool = (conflictKey in toolRegistry) + ? toolRegistry[conflictKey as ToolId]?.name + : conflictKey; setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool })); return; } @@ -101,9 +118,22 @@ const HotkeysSection: React.FC = () => {
+ setSearchQuery(event.currentTarget.value)} + size="md" + radius="md" + /> + - {tools.map(([toolId, tool], index) => { + {filteredTools.length === 0 ? ( + + {t('toolPicker.noToolsFound', 'No tools found')} + + ) : ( + filteredTools.map(([toolId, tool], index) => { const currentBinding = hotkeys[toolId]; const defaultBinding = defaults[toolId]; const isEditing = editingTool === toolId; @@ -160,10 +190,11 @@ const HotkeysSection: React.FC = () => { )} - {index < tools.length - 1 && } + {index < filteredTools.length - 1 && } ); - })} + }) + )} diff --git a/frontend/src/components/shared/rightRail/RightRail.css b/frontend/src/components/shared/rightRail/RightRail.css index d310edc408..94b1f551c0 100644 --- a/frontend/src/components/shared/rightRail/RightRail.css +++ b/frontend/src/components/shared/rightRail/RightRail.css @@ -46,6 +46,13 @@ background-color: transparent !important; } +/* When all buttons are disabled via context */ +.right-rail--all-disabled .right-rail-icon { + color: var(--right-rail-icon-disabled) !important; + background-color: transparent !important; + pointer-events: none !important; +} + .right-rail-spacer { flex: 1; } diff --git a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx index 0b9d9a7545..0ffa3102e8 100644 --- a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -14,9 +14,10 @@ import { useNavigationState } from '../../../contexts/NavigationContext'; interface ViewerAnnotationControlsProps { currentView: string; + disabled?: boolean; } -export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) { +export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) { const { t } = useTranslation(); const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); @@ -52,7 +53,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati return ( <> {/* Annotation Visibility Toggle */} - + { viewerContext?.toggleAnnotationsVisibility(); }} - disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} + disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} > @@ -129,7 +130,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
) : ( // When inactive: Show "Draw" tooltip - + @@ -155,7 +156,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati )} {/* Save PDF with Annotations */} - + diff --git a/frontend/src/components/tools/FullscreenToolList.tsx b/frontend/src/components/tools/FullscreenToolList.tsx new file mode 100644 index 0000000000..ce76b73751 --- /dev/null +++ b/frontend/src/components/tools/FullscreenToolList.tsx @@ -0,0 +1,253 @@ +import React, { useMemo } from 'react'; +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy'; +import { ToolId } from '../../types/toolId'; +import { useToolSections } from '../../hooks/useToolSections'; +import NoToolsFound from './shared/NoToolsFound'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import StarRoundedIcon from '@mui/icons-material/StarRounded'; +import ThumbUpRoundedIcon from '@mui/icons-material/ThumbUpRounded'; +import Badge from '../shared/Badge'; +import './ToolPanel.css'; +import DetailedToolItem from './fullscreen/DetailedToolItem'; +import CompactToolItem from './fullscreen/CompactToolItem'; +import { useFavoriteToolItems } from '../../hooks/tools/useFavoriteToolItems'; + +interface FullscreenToolListProps { + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; + searchQuery: string; + showDescriptions: boolean; + selectedToolKey: string | null; + matchedTextMap: Map; + onSelect: (id: ToolId) => void; +} + +const FullscreenToolList = ({ + filteredTools, + searchQuery, + showDescriptions, + selectedToolKey, + matchedTextMap: _matchedTextMap, + onSelect, +}: FullscreenToolListProps) => { + const { t } = useTranslation(); + const { toolRegistry, favoriteTools } = useToolWorkflow(); + + const { sections, searchGroups } = useToolSections(filteredTools, searchQuery); + + const tooltipPortalTarget = typeof document !== 'undefined' ? document.body : undefined; + + + const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); + + const quickSection = useMemo(() => sections.find(section => section.key === 'quick'), [sections]); + const recommendedItems = useMemo(() => { + if (!quickSection) return [] as Array<{ id: string, tool: ToolRegistryEntry }>; + const items: Array<{ id: string, tool: ToolRegistryEntry }> = []; + quickSection.subcategories.forEach(sc => sc.tools.forEach(t => items.push(t))); + return items; + }, [quickSection]); + + // Show recommended/favorites section only when not searching + const showRecentFavorites = searchQuery.trim().length === 0 && ((recommendedItems.length > 0) || favoriteToolItems.length > 0); + + const subcategoryGroups = useMemo(() => { + if (searchQuery.trim().length > 0) { + return searchGroups; + } + const allSection = sections.find(section => section.key === 'all'); + return allSection ? allSection.subcategories : []; + }, [searchGroups, sections, searchQuery]); + + if (subcategoryGroups.length === 0 && !showRecentFavorites) { + return ( +
+ + + {t('toolPanel.fullscreen.noResults', 'Try adjusting your search or toggle descriptions to find what you need.')} + +
+ ); + } + + const containerClass = showDescriptions + ? 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--detailed' + : 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--compact'; + + // Helper function to render a tool item + const renderToolItem = (id: ToolId, tool: ToolRegistryEntry) => { + const isSelected = selectedToolKey === id; + + const handleClick = () => { + if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') return; + if (tool.link) { + window.open(tool.link, '_blank', 'noopener,noreferrer'); + return; + } + onSelect(id as ToolId); + }; + + if (showDescriptions) { + return ( + + ); + } + + return ( + + ); + }; + + return ( +
+ {showRecentFavorites && ( + <> + {favoriteToolItems.length > 0 && ( +
+
+
+ + + + + {t('toolPanel.fullscreen.favorites', 'Favourites')} + +
+ + {favoriteToolItems.length} + +
+ {showDescriptions ? ( +
+ {favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))} +
+ ) : ( +
+ {favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))} +
+ )} +
+ )} + + {recommendedItems.length > 0 && ( +
+
+
+ + + + + {t('toolPanel.fullscreen.recommended', 'Recommended')} + +
+ + {recommendedItems.length} + +
+ {showDescriptions ? ( +
+ {recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))} +
+ ) : ( +
+ {recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))} +
+ )} +
+ )} + + )} + + {subcategoryGroups.map(({ subcategoryId, tools }) => { + const categoryColor = getSubcategoryColor(subcategoryId); + + return ( +
+
+
+ + {getSubcategoryIcon(subcategoryId)} + + + {getSubcategoryLabel(t, subcategoryId)} + +
+ + {tools.length} + +
+ + {showDescriptions ? ( +
+ {tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))} +
+ ) : ( +
+ {tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))} +
+ )} +
+ ); + })} +
+ ); +}; + +export default FullscreenToolList; + + diff --git a/frontend/src/components/tools/FullscreenToolSurface.tsx b/frontend/src/components/tools/FullscreenToolSurface.tsx new file mode 100644 index 0000000000..69b3c205de --- /dev/null +++ b/frontend/src/components/tools/FullscreenToolSurface.tsx @@ -0,0 +1,159 @@ +import { useState, useRef } from 'react'; +import { ActionIcon, ScrollArea, Switch, useMantineColorScheme } from '@mantine/core'; +import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; +import { useTranslation } from 'react-i18next'; +import ToolSearch from './toolPicker/ToolSearch'; +import FullscreenToolList from './FullscreenToolList'; +import { ToolRegistryEntry } from '../../data/toolsTaxonomy'; +import { ToolId } from '../../types/toolId'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { BASE_PATH } from '../../constants/app'; +import { Tooltip } from '../shared/Tooltip'; +import './ToolPanel.css'; +import { ToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; + +interface FullscreenToolSurfaceProps { + searchQuery: string; + toolRegistry: Partial>; + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; + selectedToolKey: string | null; + showDescriptions: boolean; + matchedTextMap: Map; + onSearchChange: (value: string) => void; + onSelect: (id: ToolId) => void; + onToggleDescriptions: () => void; + onExitFullscreenMode: () => void; + toggleLabel: string; + geometry: ToolPanelGeometry | null; +} + +const FullscreenToolSurface = ({ + searchQuery, + toolRegistry, + filteredTools, + selectedToolKey, + showDescriptions, + matchedTextMap, + onSearchChange, + onSelect, + onToggleDescriptions, + onExitFullscreenMode, + toggleLabel, + geometry, +}: FullscreenToolSurfaceProps) => { + const { t } = useTranslation(); + const { colorScheme } = useMantineColorScheme(); + const [isExiting, setIsExiting] = useState(false); + const surfaceRef = useRef(null); + + // Enable focus trap when surface is active + useFocusTrap(surfaceRef, !isExiting); + + const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${ + colorScheme === "dark" ? "Dark" : "Light" + }.svg`; + const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${ + colorScheme === "dark" ? "White" : "Black" + }Text.svg`; + + const handleExit = () => { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (prefersReducedMotion) { + onExitFullscreenMode(); + return; + } + + setIsExiting(true); + const el = surfaceRef.current; + if (!el) { + onExitFullscreenMode(); + return; + } + // Rely on CSS animation end rather than duplicating timing in JS + el.addEventListener('animationend', () => { + onExitFullscreenMode(); + }, { once: true }); +}; + + + const style = geometry + ? { + left: `${geometry.left}px`, + top: `${geometry.top}px`, + width: `${geometry.width}px`, + height: `${geometry.height}px`, + } + : undefined; + + return ( +
+
+
+
+ + {brandAltText} +
+
+ + + + + +
+
+ +
+ + onToggleDescriptions()} + size="md" + labelPosition="left" + label={t('toolPanel.fullscreen.showDetails', 'Show Details')} + /> +
+ +
+ + + +
+
+
+ ); +}; + +export default FullscreenToolSurface; + + diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx index aca9f88bc1..5fef3d8584 100644 --- a/frontend/src/components/tools/SearchResults.tsx +++ b/frontend/src/components/tools/SearchResults.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { Box, Stack } from '@mantine/core'; import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy'; +import { ToolId } from '../../types/toolId'; import ToolButton from './toolPicker/ToolButton'; import { useTranslation } from 'react-i18next'; import { useToolSections } from '../../hooks/useToolSections'; import SubcategoryHeader from './shared/SubcategoryHeader'; import NoToolsFound from './shared/NoToolsFound'; import "./toolPicker/ToolPicker.css"; -import { ToolId } from 'src/types/toolId'; interface SearchResultsProps { filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; diff --git a/frontend/src/components/tools/ToolPanel.css b/frontend/src/components/tools/ToolPanel.css new file mode 100644 index 0000000000..b8b50ae3db --- /dev/null +++ b/frontend/src/components/tools/ToolPanel.css @@ -0,0 +1,548 @@ +/* CSS Custom Properties for Fullscreen Mode */ +.tool-panel__fullscreen-surface-inner { + --fullscreen-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent); + --fullscreen-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent); + --fullscreen-bg-header: var(--bg-toolbar); + --fullscreen-bg-controls-1: var(--bg-toolbar); + --fullscreen-bg-controls-2: color-mix(in srgb, var(--bg-toolbar) 95%, var(--bg-background)); + --fullscreen-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent); + --fullscreen-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent); + --fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent); + --fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent); + --fullscreen-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); + --fullscreen-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent); + --fullscreen-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent); + --fullscreen-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent); + --fullscreen-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent); + --fullscreen-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent); + --fullscreen-border-favorites: color-mix(in srgb, var(--special-color-favorites) 25%, var(--border-subtle)); + --fullscreen-border-recommended: color-mix(in srgb, var(--special-color-recommended) 25%, var(--border-subtle)); + --fullscreen-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent); + --fullscreen-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent); + --fullscreen-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent); + --fullscreen-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); + --fullscreen-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + --fullscreen-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent); + --fullscreen-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); + --fullscreen-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); + --fullscreen-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted)); + --fullscreen-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted)); +} + +.tool-panel { + position: relative; + transition: width 0.3s ease, max-width 0.3s ease; +} + +.tool-panel--fullscreen-active { + overflow: visible !important; +} + +.tool-panel__search-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; +} + +.tool-panel__search-row .search-input-container { + flex: 1 1 auto; +} + +.tool-panel__mode-toggle { + transition: transform 0.2s ease; +} + +.tool-panel__mode-toggle:hover { + transform: scale(1.04); +} + +.tool-panel--fullscreen { + background: var(--bg-toolbar); +} + +.tool-panel__placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.9rem; + padding: 1.5rem; + text-align: center; +} + +.tool-panel__fullscreen-surface { + position: fixed; + display: flex; + pointer-events: none; + z-index: var(--z-fullscreen-surface); + top: 0; + left: 0; + width: 0; + height: 0; +} + +.tool-panel__fullscreen-surface-inner { + pointer-events: auto; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border-radius: 0; + background: + linear-gradient( + 140deg, + var(--fullscreen-bg-surface-1), + var(--fullscreen-bg-surface-2) + ) + padding-box; + border: 1px solid var(--fullscreen-border-subtle-75); + box-shadow: none; + backdrop-filter: blur(18px); + overflow: hidden; + /* Shared animation durations for JS + CSS (sourced from theme.css) */ + --fullscreen-anim-in-duration: var(--fullscreen-anim-duration-in); + --fullscreen-anim-out-duration: var(--fullscreen-anim-duration-out); + animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) ease forwards; +} + +.tool-panel__fullscreen-surface-inner--exiting { + animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards; +} + +.tool-panel__fullscreen-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.75rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-toolbar); +} + +.tool-panel__fullscreen-brand { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.tool-panel__fullscreen-brand-icon { + height: 1.75rem; + width: auto; +} + +.tool-panel__fullscreen-brand-text { + height: 1.25rem; + width: auto; +} + +.tool-panel__fullscreen-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.75rem; + border-bottom: 1px solid var(--tool-panel-search-border-bottom); + background: var(--tool-panel-search-bg); +} + +.tool-panel__fullscreen-controls .search-input-container { + flex: 1 1 auto; +} + +.tool-panel__fullscreen-body { + flex: 1; + min-height: 0; + background: transparent; +} + +.tool-panel__fullscreen-scroll { + height: 100%; +} + +/* fullscreen group layout */ +.tool-panel__fullscreen-groups { + padding: 1.5rem 1.75rem; + column-width: 18rem; + column-gap: 1.5rem; +} + +.tool-panel__fullscreen-groups--compact { + column-width: 17rem; +} + +.tool-panel__fullscreen-groups--detailed { + column-width: auto; +} + +.tool-panel__fullscreen-group { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + margin: 0 0 1.5rem; + padding: 0.65rem 0.75rem 1rem; + border-radius: 1rem; + background: var(--fullscreen-bg-group); + border: 1px solid var(--fullscreen-border-subtle-65); + box-shadow: 0 14px 32px var(--fullscreen-shadow-group); + break-inside: avoid; + backdrop-filter: blur(10px); +} + +.tool-panel__fullscreen-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.1rem 0.15rem 0.35rem; +} + +.tool-panel__fullscreen-section-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tool-panel__fullscreen-section-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.tool-panel__fullscreen-grid { + display: grid; + gap: 0.75rem; +} + +.tool-panel__fullscreen-grid--detailed { + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); +} + +.tool-panel__fullscreen-item { + all: unset; + display: flex; + flex-direction: row; + gap: 0.75rem; + align-items: flex-start; + padding: 0.85rem 0.95rem; + border: 1px solid var(--fullscreen-border-subtle-70); + border-radius: 0.95rem; + background: var(--fullscreen-bg-item); + backdrop-filter: blur(6px); + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + width: 100%; + box-sizing: border-box; + /* Allow flex children to shrink without forcing horizontal overflow */ + min-width: 0; +} + +.tool-panel__fullscreen-item:focus-visible { + outline: 2px solid var(--fullscreen-accent-selected); + outline-offset: 3px; +} + +.tool-panel__fullscreen-item[aria-disabled="true"], +.tool-panel__fullscreen-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]):not(:disabled) { + transform: translateY(-2px); + border-color: var(--fullscreen-accent-hover); + box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14)); +} + +.tool-panel__fullscreen-item--selected { + border-color: var(--fullscreen-accent-selected); + box-shadow: 0 0 0 2px var(--fullscreen-accent-ring); +} + +.tool-panel__fullscreen-item--detailed { + min-height: 7.5rem; +} + +.tool-panel__fullscreen-item--with-star { + position: relative; +} + +.tool-panel__fullscreen-star { + position: absolute; + top: 0.5rem; + right: 0.5rem; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.tool-panel__fullscreen-item:hover .tool-panel__fullscreen-star { + opacity: 1; +} + +.tool-panel__fullscreen-list-item--with-star { + position: relative; +} + +.tool-panel__fullscreen-star-compact { + position: absolute; + top: 0.35rem; + right: 0.35rem; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.tool-panel__fullscreen-list-item:hover .tool-panel__fullscreen-star-compact { + opacity: 1; +} + +.tool-panel__fullscreen-group--special { + border-width: 1px; +} + +.tool-panel__fullscreen-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.75rem; + background: var(--fullscreen-bg-icon-detailed); + color: var(--fullscreen-text-icon); + flex-shrink: 0; + transition: transform 0.2s ease, box-shadow 0.2s ease; + position: relative; + overflow: hidden; +} + +.tool-panel__fullscreen-icon::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, + color-mix(in srgb, var(--text-primary) 12%, transparent), + color-mix(in srgb, var(--text-primary) 4%, transparent) + ); + opacity: 0; + transition: opacity 0.2s ease; + border-radius: 0.75rem; +} + +.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon { + transform: scale(1.08); + box-shadow: 0 4px 12px color-mix(in srgb, var(--text-primary) 15%, transparent); +} + +.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon::before { + opacity: 1; +} + +.tool-panel__fullscreen-icon svg { + font-size: 1.65rem; + position: relative; + z-index: var(--z-fullscreen-icon-svg); +} + +.tool-panel__fullscreen-body { + display: flex; + flex-direction: column; + gap: 0.4rem; + text-align: left; + /* Prevent long content (names, descriptions, shortcuts) from overflowing */ + flex: 1 1 auto; + min-width: 0; +} + +.tool-panel__fullscreen-name { + color: var(--text-primary); +} + +.tool-panel__fullscreen-description { + line-height: 1.45; +} + +/* Ensure long words or unbroken strings wrap instead of overflowing */ +.tool-panel__fullscreen-name, +.tool-panel__fullscreen-description, +.tool-panel__fullscreen-match { + overflow-wrap: anywhere; + word-break: break-word; + hyphens: auto; +} + +.tool-panel__fullscreen-match { + font-style: italic; +} + +.tool-panel__fullscreen-shortcut { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.2rem; + /* Allow hotkey chips to wrap on small widths to avoid overflow */ + flex-wrap: wrap; + row-gap: 0.25rem; +} + +.tool-panel__fullscreen-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.tool-panel__fullscreen-list-item { + all: unset; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 0.5rem 0.55rem 0.65rem; + border-radius: 0.65rem; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; + background: var(--fullscreen-bg-list-item); + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; + position: relative; +} + +.tool-panel__fullscreen-list-item[aria-disabled="true"], +.tool-panel__fullscreen-list-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]):not(:disabled), +.tool-panel__fullscreen-list-item--selected { + background: var(--fullscreen-accent-list-bg); + border-color: var(--fullscreen-accent-list-border); +} + +.tool-panel__fullscreen-list-item--selected { + transform: translateX(2px); +} + +.tool-panel__fullscreen-list-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 0.6rem; + background: var(--fullscreen-bg-icon-compact); + color: var(--fullscreen-text-icon-compact); + flex-shrink: 0; + transition: transform 0.2s ease, box-shadow 0.2s ease; + position: relative; + overflow: hidden; +} + +.tool-panel__fullscreen-list-icon::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, + color-mix(in srgb, var(--text-primary) 10%, transparent), + color-mix(in srgb, var(--text-primary) 3%, transparent) + ); + opacity: 0; + transition: opacity 0.2s ease; + border-radius: 0.6rem; +} + +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon { + transform: scale(1.06); + box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent); +} + +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon::before { + opacity: 1; +} + +.tool-panel__fullscreen-list-icon svg { + position: relative; + z-index: 1; +} + +.tool-panel__fullscreen-list-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + text-align: left; +} + +.tool-panel__fullscreen-empty { + padding: 2rem 1.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +@keyframes tool-panel-fullscreen-slide-in { + from { + transform: translateX(-6%) scaleX(0.85); + opacity: 0; + } + to { + transform: translateX(0) scaleX(1); + opacity: 1; + } +} + +@keyframes tool-panel-fullscreen-slide-out { + from { + transform: translateX(0) scaleX(1); + opacity: 1; + } + to { + transform: translateX(-6%) scaleX(0.85); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .tool-panel__fullscreen-surface-inner { + animation: none !important; + } + + .tool-panel__mode-toggle { + transition: none; + } + + .tool-panel__fullscreen-item, + .tool-panel__fullscreen-list-item { + transition: none; + } +} + +@media (max-width: 1440px) { + .tool-panel__fullscreen-content { + padding-inline: 1.5rem; + } + + .tool-panel__fullscreen-grid--compact { + column-width: 15rem; + } +} + +@media (max-width: 1280px) { + .tool-panel__fullscreen-controls { + flex-direction: column; + align-items: stretch; + } + + .tool-panel__fullscreen-controls .mantine-Switch-root { + justify-content: flex-end; + } + + .tool-panel__fullscreen-grid--compact { + column-width: 14rem; + } +} + + + diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index beefc2c453..88089df5d8 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -1,3 +1,4 @@ +import React, { useEffect, useMemo } from 'react'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import ToolPicker from './ToolPicker'; @@ -6,20 +7,27 @@ import ToolRenderer from './ToolRenderer'; import ToolSearch from './toolPicker/ToolSearch'; import { useSidebarContext } from "../../contexts/SidebarContext"; import rainbowStyles from '../../styles/rainbow.module.css'; -import { ScrollArea } from '@mantine/core'; +import { ActionIcon, ScrollArea } from '@mantine/core'; import { ToolId } from '../../types/toolId'; import { useMediaQuery } from '@mantine/hooks'; +import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; +import { useTranslation } from 'react-i18next'; +import FullscreenToolSurface from './FullscreenToolSurface'; +import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry'; +import { useLocalStorageState } from '../../hooks/tools/useJsonLocalStorageState'; +import { useRightRail } from '../../contexts/RightRailContext'; +import { Tooltip } from '../shared/Tooltip'; +import './ToolPanel.css'; // No props needed - component uses context export default function ToolPanel() { + const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { sidebarRefs } = useSidebarContext(); - const { toolPanelRef } = sidebarRefs; + const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs; const isMobile = useMediaQuery('(max-width: 1024px)'); - - // Use context-based hooks to eliminate prop drilling const { leftPanelView, isPanelVisible, @@ -27,84 +35,181 @@ export default function ToolPanel() { filteredTools, toolRegistry, setSearchQuery, + selectedToolKey, + handleToolSelect, + setPreviewFile, + toolPanelMode, + setToolPanelMode, + setLeftPanelView, + readerMode, } = useToolWorkflow(); - const { selectedToolKey, handleToolSelect } = useToolWorkflow(); - const { setPreviewFile } = useToolWorkflow(); + const { setAllRightRailButtonsDisabled } = useRightRail(); + + const isFullscreenMode = toolPanelMode === 'fullscreen'; + const toolPickerVisible = !readerMode; + const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible; + + + // Disable right rail buttons when fullscreen mode is active + useEffect(() => { + setAllRightRailButtonsDisabled(fullscreenExpanded); + }, [fullscreenExpanded, setAllRightRailButtonsDisabled]); + + // Use custom hooks for state management + const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', false); + const fullscreenGeometry = useToolPanelGeometry({ + enabled: fullscreenExpanded, + toolPanelRef, + quickAccessRef, + rightRailRef, + }); + + const toggleLabel = isFullscreenMode + ? t('toolPanel.toggle.sidebar', 'Switch to sidebar mode') + : t('toolPanel.toggle.fullscreen', 'Switch to fullscreen mode'); + + const handleModeToggle = () => { + const nextMode = isFullscreenMode ? 'sidebar' : 'fullscreen'; + setToolPanelMode(nextMode); + + if (nextMode === 'fullscreen' && leftPanelView !== 'toolPicker') { + setLeftPanelView('toolPicker'); + } + }; + + const computedWidth = () => { + if (isMobile) { + return '100%'; + } + + if (!isPanelVisible) { + return '0'; + } + + return '18.5rem'; + }; + + const matchedTextMap = useMemo(() => { + const map = new Map(); + filteredTools.forEach(({ item: [id], matchedText }) => { + if (matchedText) { + map.set(id, matchedText); + } + }); + return map; + }, [filteredTools]); return (
-
- {/* Search Bar - Always visible at the top */} + {!fullscreenExpanded && (
- -
- - {searchQuery.trim().length > 0 ? ( - // Searching view (replaces both picker and content) -
- handleToolSelect(id as ToolId)} - searchQuery={searchQuery} - /> -
- ) : leftPanelView === 'toolPicker' ? ( - // Tool Picker View -
- handleToolSelect(id as ToolId)} - filteredTools={filteredTools} - isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} +
+ + {!isMobile && leftPanelView === 'toolPicker' && ( + + + + + + )}
- ) : ( - // Selected Tool Content View -
- {/* Tool content */} -
- - {selectedToolKey && ( - - )} - + + {searchQuery.trim().length > 0 ? ( +
+ handleToolSelect(id as ToolId)} + searchQuery={searchQuery} + /> +
+ ) : leftPanelView === 'toolPicker' ? ( +
+ handleToolSelect(id as ToolId)} + filteredTools={filteredTools} + isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} + /> +
+ ) : ( +
+
+ + {selectedToolKey ? ( + + ) : ( +
+ {t('toolPanel.placeholder', 'Choose a tool to get started')} +
+ )} +
+
-
- )} -
+ )} +
+ )} + + {fullscreenExpanded && ( + handleToolSelect(id)} + onToggleDescriptions={() => setShowLegacyDescriptions((prev) => !prev)} + onExitFullscreenMode={() => setToolPanelMode('sidebar')} + toggleLabel={toggleLabel} + geometry={fullscreenGeometry} + /> + )}
); } diff --git a/frontend/src/components/tools/ToolPanelModePrompt.css b/frontend/src/components/tools/ToolPanelModePrompt.css new file mode 100644 index 0000000000..50e8ad7117 --- /dev/null +++ b/frontend/src/components/tools/ToolPanelModePrompt.css @@ -0,0 +1,200 @@ +.tool-panel-mode-prompt__modal { + background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + box-shadow: 0 32px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, transparent); + max-width: min(46rem, 100%); +} + +.tool-panel-mode-prompt__options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; +} + +.tool-panel-mode-prompt__card { + display: flex; + flex-direction: column; + gap: 1rem; + background: linear-gradient(145deg, + color-mix(in srgb, var(--bg-surface) 96%, transparent), + color-mix(in srgb, var(--bg-muted) 70%, transparent) + ); + width: 100%; + max-width: 19rem; +} + +.tool-panel-mode-prompt__card--sidebar { + border: 1px solid color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, var(--border-subtle)); + background: linear-gradient(165deg, + color-mix(in srgb, var(--bg-surface) 96%, transparent), + color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, transparent) + ); +} + +.tool-panel-mode-prompt__preview { + border-radius: 0.9rem; + border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + background: linear-gradient(135deg, color-mix(in srgb, var(--bg-muted) 82%, transparent), transparent 75%); + padding: 0.75rem; + width: 100%; + display: flex; + gap: 0.65rem; + min-height: 6.5rem; + margin-top: auto; +} + +.tool-panel-mode-prompt__preview--sidebar { + align-items: stretch; +} + +.tool-panel-mode-prompt__sidebar-panel { + width: 3rem; + border-radius: 0.65rem; + padding: 0.45rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + background: linear-gradient(180deg, + color-mix(in srgb, var(--bg-muted) 88%, transparent), + color-mix(in srgb, var(--bg-muted) 72%, transparent) + ); + border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); +} + +.tool-panel-mode-prompt__sidebar-search { + height: 0.5rem; + border-radius: 0.4rem; + background: color-mix(in srgb, var(--bg-background) 90%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent); +} + +.tool-panel-mode-prompt__sidebar-item { + height: 0.55rem; + border-radius: 0.35rem; + background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, var(--bg-muted)); +} + +.tool-panel-mode-prompt__sidebar-item--muted { + background: color-mix(in srgb, var(--bg-background) 88%, transparent); +} + +.tool-panel-mode-prompt__workspace { + flex: 1; + border-radius: 0.65rem; + border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); + padding: 0.5rem; + display: grid; + gap: 0.35rem; + grid-template-rows: 1.4fr 0.6fr; + background: linear-gradient(160deg, + color-mix(in srgb, var(--bg-background) 94%, transparent), + color-mix(in srgb, var(--bg-muted) 68%, transparent) + ); +} + +.tool-panel-mode-prompt__workspace-page { + border-radius: 0.45rem; + background: color-mix(in srgb, var(--bg-surface) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-background) 60%, transparent); +} + +.tool-panel-mode-prompt__workspace-page--secondary { + opacity: 0.55; +} + +.tool-panel-mode-prompt__preview--legacy { + align-items: flex-start; + justify-content: center; + padding: 0.65rem 0.6rem; +} + +.tool-panel-mode-prompt__legacy-columns { + width: 100%; + display: flex; + gap: 0.45rem; + justify-content: space-between; +} + +.tool-panel-mode-prompt__legacy-column { + flex: 1; + display: grid; + gap: 0.3rem; +} + +.tool-panel-mode-prompt__legacy-card { + border-radius: 0.45rem; + border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + background: linear-gradient(150deg, + color-mix(in srgb, var(--bg-muted) 88%, transparent), + color-mix(in srgb, var(--bg-background) 76%, transparent) + ); + height: 1.2rem; +} + +.tool-panel-mode-prompt__legacy-card--muted { + opacity: 0.55; +} + +.tool-panel-mode-prompt__preview--fullscreen { + align-items: flex-start; + justify-content: center; + padding: 0.65rem 0.6rem; +} + +.tool-panel-mode-prompt__fullscreen-columns { + width: 100%; + display: flex; + gap: 0.45rem; + justify-content: space-between; +} + +.tool-panel-mode-prompt__fullscreen-column { + flex: 1; + display: grid; + gap: 0.3rem; +} + +.tool-panel-mode-prompt__fullscreen-card { + border-radius: 0.45rem; + border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + background: linear-gradient(150deg, + color-mix(in srgb, var(--bg-muted) 88%, transparent), + color-mix(in srgb, var(--bg-background) 76%, transparent) + ); + height: 1.2rem; +} + +.tool-panel-mode-prompt__fullscreen-card--muted { + opacity: 0.55; +} + +.tool-panel-mode-prompt__card-content { + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; +} + +.tool-panel-mode-prompt__action { + box-shadow: none; +} + +.tool-panel-mode-prompt__action:hover { + box-shadow: 0 10px 18px color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, transparent); +} + +.tool-panel-mode-prompt__maybe-later { + color: color-mix(in srgb, var(--text-secondary) 90%, var(--text-muted)); +} + +.tool-panel-mode-prompt__maybe-later:hover { + background: color-mix(in srgb, var(--bg-muted) 78%, transparent); +} + +@media (max-width: 600px) { + .tool-panel-mode-prompt__options { + grid-template-columns: 1fr; + justify-items: stretch; + } +} diff --git a/frontend/src/components/tools/ToolPanelModePrompt.tsx b/frontend/src/components/tools/ToolPanelModePrompt.tsx new file mode 100644 index 0000000000..c6e9d32010 --- /dev/null +++ b/frontend/src/components/tools/ToolPanelModePrompt.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import './ToolPanelModePrompt.css'; +import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference'; +import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; + +// type moved to hook + +const ToolPanelModePrompt = () => { + const { t } = useTranslation(); + const { toolPanelMode, setToolPanelMode } = useToolWorkflow(); + const [opened, setOpened] = useState(false); + const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference(); + + useEffect(() => { + if (shouldShowPrompt) { + setOpened(true); + } + }, [shouldShowPrompt]); + + const handleSelect = (mode: ToolPanelMode) => { + setToolPanelMode(mode); + setPreferredMode(mode); + markPromptSeen(); + setOpened(false); + }; + + const handleDismiss = () => { + markPromptSeen(); + setOpened(false); + }; + + if (!hydrated) { + return null; + } + + return ( + + + + {t('toolPanel.modePrompt.description', 'Preview both layouts and decide how you want to explore Stirling PDF tools.')} + +
+ + + + + {t('toolPanel.modePrompt.sidebarTitle', 'Sidebar mode')} + + {t('toolPanel.modePrompt.sidebarDescription', 'Keep tools alongside your workspace for quick switching.')} + + + + {t('toolPanel.modePrompt.recommended', 'Recommended')} + + +
+
+ + + + + +
+
+
+
+
+
+ + + + + + + {t('toolPanel.modePrompt.fullscreenTitle', 'Fullscreen mode')} + + {t('toolPanel.modePrompt.fullscreenDescription', 'Browse every tool in a catalogue that covers the workspace until you pick one.')} + + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+
+
+ + + + ); +}; + +export default ToolPanelModePrompt; diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 288a857f36..f158f036e0 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -4,9 +4,15 @@ import { useTranslation } from "react-i18next"; import { ToolRegistryEntry } from "../../data/toolsTaxonomy"; import "./toolPicker/ToolPicker.css"; import { useToolSections } from "../../hooks/useToolSections"; +import type { SubcategoryGroup } from "../../hooks/useToolSections"; +import { useFavoriteToolItems } from "../../hooks/tools/useFavoriteToolItems"; import NoToolsFound from "./shared/NoToolsFound"; import { renderToolButtons } from "./shared/renderToolButtons"; -import { ToolId } from "src/types/toolId"; +import Badge from "../shared/Badge"; +import SubcategoryHeader from "./shared/SubcategoryHeader"; +import ToolButton from "./toolPicker/ToolButton"; +import { useToolWorkflow } from "../../contexts/ToolWorkflowContext"; +import { ToolId } from "../../types/toolId"; interface ToolPickerProps { selectedToolKey: string | null; @@ -62,11 +68,23 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa }, []); const { sections: visibleSections } = useToolSections(filteredTools); + const { favoriteTools, toolRegistry } = useToolWorkflow(); + + const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); const quickSection = useMemo( () => visibleSections.find(s => s.key === 'quick'), [visibleSections] ); + + const recommendedItems = useMemo(() => { + if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>; + const items: Array<{ id: string; tool: ToolRegistryEntry }> = []; + quickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry))); + return items; + }, [quickSection]); + + const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]); const allSection = useMemo( () => visibleSections.find(s => s.key === 'all'), [visibleSections] @@ -88,7 +106,9 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa }; // Build flat list by subcategory for search mode - const { searchGroups } = useToolSections(isSearching ? filteredTools : []); + const emptyFilteredTools: ToolPickerProps['filteredTools'] = []; + const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools; + const { searchGroups } = useToolSections(effectiveFilteredForSearch); return ( ) : ( - searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect)) + searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect, true, false, filteredTools, true)) )} ) : ( @@ -143,24 +163,46 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa onClick={() => scrollTo(quickAccessRef)} > {t("toolPicker.quickAccess", "QUICK ACCESS")} - - {quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} - + + {recommendedCount} +
- {quickSection?.subcategories.map(sc => - renderToolButtons(t, sc, selectedToolKey, onSelect, false) + {favoriteToolItems.length > 0 && ( + + +
+ {favoriteToolItems.map(({ id, tool }) => ( + + ))} +
+
+ )} + {recommendedItems.length > 0 && ( + + +
+ {recommendedItems.map(({ id, tool }) => ( + + ))} +
+
)}
@@ -189,25 +231,16 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa onClick={() => scrollTo(allToolsRef)} > {t("toolPicker.allTools", "ALL TOOLS")} - + {allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} - +
- {allSection?.subcategories.map(sc => - renderToolButtons(t, sc, selectedToolKey, onSelect, true) - )} + {allSection?.subcategories.map((sc: SubcategoryGroup) => + renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true) + )} diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index 97353ba09a..89d1b025ca 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -1,8 +1,8 @@ import { Suspense } from "react"; import { useToolWorkflow } from "../../contexts/ToolWorkflowContext"; import { BaseToolProps } from "../../types/tool"; +import { ToolId } from "../../types/toolId"; import ToolLoadingFallback from "./ToolLoadingFallback"; -import { ToolId } from "src/types/toolId"; interface ToolRendererProps extends BaseToolProps { selectedToolKey: ToolId; @@ -17,7 +17,14 @@ const ToolRenderer = ({ }: ToolRendererProps) => { // Get the tool from context (instead of direct hook call) const { toolRegistry } = useToolWorkflow(); - const selectedTool = toolRegistry[selectedToolKey]; + const selectedTool = (selectedToolKey in toolRegistry) + ? toolRegistry[selectedToolKey as ToolId] + : undefined; + + // Handle tools that only work in workbenches (read, multiTool) + if (selectedTool && !selectedTool.component && selectedTool.workbench) { + return null; // These tools render in their workbench, not in the sidebar + } if (!selectedTool || !selectedTool.component) { return
Tool not found: {selectedToolKey}
; diff --git a/frontend/src/components/tools/automate/AutomationCreation.tsx b/frontend/src/components/tools/automate/AutomationCreation.tsx index b5e793ccb3..9dee457539 100644 --- a/frontend/src/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/components/tools/automate/AutomationCreation.tsx @@ -10,6 +10,7 @@ import { Divider, Modal } from '@mantine/core'; +import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex'; import CheckIcon from '@mui/icons-material/Check'; import { ToolRegistry } from '../../../data/toolsTaxonomy'; import ToolConfigurationModal from './ToolConfigurationModal'; @@ -221,6 +222,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o onClose={handleCancelBack} title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')} centered + zIndex={Z_INDEX_AUTOMATE_MODAL} > diff --git a/frontend/src/components/tools/automate/AutomationEntry.tsx b/frontend/src/components/tools/automate/AutomationEntry.tsx index 8f739bcbfe..57f480f1ff 100644 --- a/frontend/src/components/tools/automate/AutomationEntry.tsx +++ b/frontend/src/components/tools/automate/AutomationEntry.tsx @@ -8,6 +8,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { Tooltip } from '../../shared/Tooltip'; import { ToolIcon } from '../../shared/ToolIcon'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; +import { ToolId } from 'src/types/toolId'; interface AutomationEntryProps { /** Optional title for the automation (usually for custom ones) */ @@ -31,7 +32,7 @@ interface AutomationEntryProps { /** Copy handler (for suggested automations) */ onCopy?: () => void; /** Tool registry to resolve operation names */ - toolRegistry?: Record; + toolRegistry?: Record; } export default function AutomationEntry({ @@ -55,8 +56,8 @@ export default function AutomationEntry({ // Helper function to resolve tool display names const getToolDisplayName = (operation: string): string => { - if (toolRegistry?.[operation]?.name) { - return toolRegistry[operation].name; + if (toolRegistry?.[operation as ToolId]?.name) { + return toolRegistry[operation as ToolId].name; } // Fallback to translation or operation key return t(`${operation}.title`, operation); diff --git a/frontend/src/components/tools/automate/AutomationRun.tsx b/frontend/src/components/tools/automate/AutomationRun.tsx index 2d6bda6fc6..d25008a3a5 100644 --- a/frontend/src/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/components/tools/automate/AutomationRun.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; @@ -30,7 +30,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null; // Initialize execution steps from automation - React.useEffect(() => { + useEffect(() => { if (automation?.operations) { const steps = automation.operations.map((op: any, index: number) => { const tool = toolRegistry[op.operation as keyof typeof toolRegistry]; @@ -47,7 +47,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio }, [automation, toolRegistry]); // Cleanup when component unmounts - React.useEffect(() => { + useEffect(() => { return () => { // Reset progress state when component unmounts setExecutionSteps([]); diff --git a/frontend/src/components/tools/automate/AutomationSelection.tsx b/frontend/src/components/tools/automate/AutomationSelection.tsx index a10ebb1393..edbec569ac 100644 --- a/frontend/src/components/tools/automate/AutomationSelection.tsx +++ b/frontend/src/components/tools/automate/AutomationSelection.tsx @@ -7,6 +7,7 @@ import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSugges import { AutomationConfig, SuggestedAutomation } from "../../../types/automation"; import { iconMap } from './iconMap'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; +import { ToolId } from '../../../types/toolId'; interface AutomationSelectionProps { savedAutomations: AutomationConfig[]; @@ -15,7 +16,7 @@ interface AutomationSelectionProps { onEdit: (automation: AutomationConfig) => void; onDelete: (automation: AutomationConfig) => void; onCopyFromSuggested: (automation: SuggestedAutomation) => void; - toolRegistry: Record; + toolRegistry: Record; } export default function AutomationSelection({ diff --git a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx index fca5892185..271fd91a14 100644 --- a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx +++ b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx @@ -9,6 +9,7 @@ import { Text, Alert } from '@mantine/core'; +import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex'; import SettingsIcon from '@mui/icons-material/Settings'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; @@ -103,6 +104,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, } size="lg" centered + zIndex={Z_INDEX_AUTOMATE_MODAL} > diff --git a/frontend/src/components/tools/automate/ToolList.tsx b/frontend/src/components/tools/automate/ToolList.tsx index 3cb2b7a7eb..bde0625f8d 100644 --- a/frontend/src/components/tools/automate/ToolList.tsx +++ b/frontend/src/components/tools/automate/ToolList.tsx @@ -6,12 +6,13 @@ import CloseIcon from "@mui/icons-material/Close"; import AddCircleOutline from "@mui/icons-material/AddCircleOutline"; import { AutomationTool } from "../../../types/automation"; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import { ToolId } from "../../../types/toolId"; import ToolSelector from "./ToolSelector"; import AutomationEntry from "./AutomationEntry"; interface ToolListProps { tools: AutomationTool[]; - toolRegistry: Record; + toolRegistry: Record; onToolUpdate: (index: number, updates: Partial) => void; onToolRemove: (index: number) => void; onToolConfigure: (index: number) => void; @@ -34,7 +35,7 @@ export default function ToolList({ const handleToolSelect = (index: number, newOperation: string) => { const defaultParams = getToolDefaultParameters(newOperation); - const toolEntry = toolRegistry[newOperation]; + const toolEntry = toolRegistry[newOperation as ToolId]; // If tool has no settingsComponent, it's automatically configured const isConfigured = !toolEntry?.automationSettings; diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index 02fea2c978..bbf8e95f8f 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -6,12 +6,12 @@ import { useToolSections } from '../../../hooks/useToolSections'; import { renderToolButtons } from '../shared/renderToolButtons'; import ToolSearch from '../toolPicker/ToolSearch'; import ToolButton from '../toolPicker/ToolButton'; -import { ToolId } from 'src/types/toolId'; +import { ToolId } from '../../../types/toolId'; interface ToolSelectorProps { onSelect: (toolKey: string) => void; excludeTools?: string[]; - toolRegistry: Record; // Pass registry as prop to break circular dependency + toolRegistry: Record; // Pass registry as prop to break circular dependency selectedValue?: string; // For showing current selection when editing existing tool placeholder?: string; // Custom placeholder text } @@ -54,16 +54,16 @@ export default function ToolSelector({ // Create filtered tool registry for ToolSearch const filteredToolRegistry = useMemo(() => { - const registry: Record = {}; + const registry: Record = {} as Record; baseFilteredTools.forEach(([key, tool]) => { - registry[key] = tool; + registry[key as ToolId] = tool; }); return registry; }, [baseFilteredTools]); // Transform filteredTools to the expected format for useToolSections const transformedFilteredTools = useMemo(() => { - return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + return filteredTools.map(([id, tool]) => ({ item: [id as ToolId, tool] as [ToolId, ToolRegistryEntry] })); }, [filteredTools]); // Use the same tool sections logic as the main ToolPicker @@ -89,7 +89,7 @@ export default function ToolSelector({ } // Find the "all" section which contains all tools without duplicates - const allSection = sections.find(s => (s as any).key === 'all'); + const allSection = sections.find(s => s.key === 'all'); return allSection?.subcategories || []; }, [isSearching, searchGroups, sections, baseFilteredTools]); @@ -101,7 +101,7 @@ export default function ToolSelector({ const renderedTools = useMemo(() => displayGroups.map((subcategory) => - renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true) + renderToolButtons(t, subcategory as any, null, handleToolSelect, !isSearching, true) ), [displayGroups, handleToolSelect, isSearching, t] ); @@ -143,8 +143,8 @@ export default function ToolSelector({ // Get display value for selected tool const getDisplayValue = () => { - if (selectedValue && toolRegistry[selectedValue]) { - return toolRegistry[selectedValue].name; + if (selectedValue && toolRegistry[selectedValue as ToolId]) { + return toolRegistry[selectedValue as ToolId].name; } return placeholder || t('automate.creation.tools.add', 'Add a tool...'); }; @@ -153,11 +153,11 @@ export default function ToolSelector({
{/* Always show the target - either selected tool or search input */} - {selectedValue && toolRegistry[selectedValue] && !opened ? ( + {selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? ( // Show selected tool in AutomationEntry style when tool is selected and dropdown closed
- {}} rounded={true} disableNavigation={true}>
) : ( diff --git a/frontend/src/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx new file mode 100644 index 0000000000..896d704395 --- /dev/null +++ b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '../../shared/Tooltip'; +import HotkeyDisplay from '../../hotkeys/HotkeyDisplay'; +import FavoriteStar from '../toolPicker/FavoriteStar'; +import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared'; + +interface CompactToolItemProps { + id: string; + tool: ToolRegistryEntry; + isSelected: boolean; + onClick: () => void; + tooltipPortalTarget?: HTMLElement | undefined; +} + +const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => { + const { t } = useTranslation(); + const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const categoryColor = getSubcategoryColor(tool.subcategoryId); + const iconBg = getIconBackground(categoryColor, false); + const iconClasses = 'tool-panel__fullscreen-list-icon'; + + let iconNode: React.ReactNode = null; + if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) { + const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>; + iconNode = React.cloneElement(element, { + style: { + ...(element.props.style || {}), + fontSize: '1.5rem', + }, + }); + } else { + iconNode = tool.icon; + } + + const compactButton = ( + + ); + + const tooltipContent = disabled + ? ( + {t('toolPanel.fullscreen.comingSoon', 'Coming soon:')} {tool.description} + ) + : ( +
+ {tool.description} + {binding && ( +
+ + {t('settings.hotkeys.shortcut', 'Shortcut')} + + +
+ )} +
+ ); + + return ( + + {compactButton} + + ); +}; + +export default CompactToolItem; + + diff --git a/frontend/src/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/components/tools/fullscreen/DetailedToolItem.tsx new file mode 100644 index 0000000000..c9b518b77d --- /dev/null +++ b/frontend/src/components/tools/fullscreen/DetailedToolItem.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import HotkeyDisplay from '../../hotkeys/HotkeyDisplay'; +import FavoriteStar from '../toolPicker/FavoriteStar'; +import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared'; + +interface DetailedToolItemProps { + id: string; + tool: ToolRegistryEntry; + isSelected: boolean; + onClick: () => void; +} + +const DetailedToolItem: React.FC = ({ id, tool, isSelected, onClick }) => { + const { t } = useTranslation(); + const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + + const categoryColor = getSubcategoryColor(tool.subcategoryId); + const iconBg = getIconBackground(categoryColor, true); + const iconClasses = 'tool-panel__fullscreen-icon'; + + let iconNode: React.ReactNode = null; + if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) { + const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>; + iconNode = React.cloneElement(element, { + style: { + ...(element.props.style || {}), + fontSize: '1.75rem', + }, + }); + } else { + iconNode = tool.icon; + } + + return ( + + ); +}; + +export default DetailedToolItem; + + diff --git a/frontend/src/components/tools/fullscreen/shared.ts b/frontend/src/components/tools/fullscreen/shared.ts new file mode 100644 index 0000000000..6355c3828b --- /dev/null +++ b/frontend/src/components/tools/fullscreen/shared.ts @@ -0,0 +1,45 @@ +import { useHotkeys } from '../../../contexts/HotkeyContext'; +import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext'; +import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; +import { ToolId } from '../../../types/toolId'; + +export const getItemClasses = (isDetailed: boolean): string => { + return isDetailed ? 'tool-panel__fullscreen-item--detailed' : ''; +}; + +export const getIconBackground = (categoryColor: string, isDetailed: boolean): string => { + const baseColor = isDetailed ? 'var(--fullscreen-bg-icon-detailed)' : 'var(--fullscreen-bg-icon-compact)'; + const blend1 = isDetailed ? '18%' : '15%'; + const blend2 = isDetailed ? '8%' : '6%'; + + return `linear-gradient(135deg, + color-mix(in srgb, ${categoryColor} ${blend1}, ${baseColor}), + color-mix(in srgb, ${categoryColor} ${blend2}, ${baseColor}) + )`; +}; + +export const getIconStyle = (): Record => { + return {}; +}; + +export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => { + return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool'; +}; + +export function useToolMeta(id: string, tool: ToolRegistryEntry) { + const { hotkeys } = useHotkeys(); + const { isFavorite, toggleFavorite } = useToolWorkflow(); + + const isFav = isFavorite(id as ToolId); + const binding = hotkeys[id as ToolId]; + const disabled = isToolDisabled(id, tool); + + return { + binding, + isFav, + toggleFavorite: () => toggleFavorite(id as ToolId), + disabled, + }; +} + + diff --git a/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.module.css b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.module.css new file mode 100644 index 0000000000..b6efb3bbc0 --- /dev/null +++ b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.module.css @@ -0,0 +1,50 @@ +.fileListContainer { + max-height: 16.25rem; + overflow-y: auto; + overflow-x: hidden; + width: 100%; +} + +.fileItem { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-sm); + align-items: center; + width: 100%; +} + +.fileNameContainer { + flex: 1; + min-width: 0; +} + +.fileName { + font-size: var(--mantine-font-size-sm); + font-weight: 400; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + word-break: break-word; +} + +.fileSize { + flex-shrink: 0; +} + +.removeButton { + flex-shrink: 0; +} + +.countLabel { + width: 140px; + flex-shrink: 0; +} + +.fileGroup { + flex: 1; + min-width: 0; + align-items: center; +} diff --git a/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx new file mode 100644 index 0000000000..43ba64db5a --- /dev/null +++ b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -0,0 +1,178 @@ +import { Stack, Text, Group, Select, SegmentedControl, NumberInput, Button, ActionIcon, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { type OverlayPdfsParameters, type OverlayMode } from '../../../hooks/tools/overlayPdfs/useOverlayPdfsParameters'; +import LocalIcon from '../../shared/LocalIcon'; +import { useFilesModalContext } from '../../../contexts/FilesModalContext'; +import styles from './OverlayPdfsSettings.module.css'; + +interface OverlayPdfsSettingsProps { + parameters: OverlayPdfsParameters; + onParameterChange: (key: K, value: OverlayPdfsParameters[K]) => void; + disabled?: boolean; +} + +export default function OverlayPdfsSettings({ parameters, onParameterChange, disabled = false }: OverlayPdfsSettingsProps) { + const { t } = useTranslation(); + const { openFilesModal } = useFilesModalContext(); + + const handleOverlayFilesChange = (files: File[]) => { + onParameterChange('overlayFiles', files); + // Reset counts to match number of files if in FixedRepeatOverlay + if (parameters.overlayMode === 'FixedRepeatOverlay') { + const nextCounts = files.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1); + onParameterChange('counts', nextCounts); + } + }; + + const handleModeChange = (mode: OverlayMode) => { + onParameterChange('overlayMode', mode); + if (mode !== 'FixedRepeatOverlay') { + onParameterChange('counts', []); + } else if (parameters.overlayFiles?.length > 0) { + onParameterChange('counts', parameters.overlayFiles.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1)); + } + }; + + const handleOpenOverlayFilesModal = () => { + if (disabled) return; + openFilesModal({ + customHandler: (files: File[]) => { + handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]); + } + }); + }; + + return ( + + + + {t('overlay-pdfs.mode.label', 'Overlay Mode')} +