diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 405643877..99fb34b0d 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 81f4fc7e4..8294d34f5 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", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index ce4d8db0b..c92c4c56e 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", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 2aca5e648..4f8c203ca 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,7 +127,7 @@ const FileManager: React.FC = ({ selectedTool }) => { radius="md" className="overflow-hidden p-0" withCloseButton={false} - zIndex={1100} + 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 90ee6eebe..5c76d2248 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 b1e7e93e3..d4ef7bd07 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 16bc02aa1..771499584 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 a3353df19..8436d2e29 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 2ed7f71b7..160547180 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 000000000..7bf3283e4 --- /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/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index 0ea80a79f..507dab16b 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 af9c8acff..7a8dd7fdd 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 2b4569dc5..44af1e215 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 543422609..775cbbee1 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 07f71c484..a79c15d72 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 d310edc40..94b1f551c 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 0b9d9a754..0ffa3102e 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 000000000..ce76b7375 --- /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 000000000..69b3c205d --- /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 aca9f88bc..5fef3d858 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 000000000..b8b50ae3d --- /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 beefc2c45..88089df5d 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 000000000..50e8ad711 --- /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 000000000..c6e9d3201 --- /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 288a857f3..f158f036e 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 97353ba09..89d1b025c 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 b5e793ccb..9dee45753 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 8f739bcbf..57f480f1f 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 2d6bda6fc..d25008a3a 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 a10ebb139..edbec569a 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 fca589218..271fd91a1 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 3cb2b7a7e..bde0625f8 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 02fea2c97..bbf8e95f8 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 000000000..896d70439 --- /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 000000000..c9b518b77 --- /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 000000000..6355c3828 --- /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/shared/renderToolButtons.tsx b/frontend/src/components/tools/shared/renderToolButtons.tsx index 659ffefca..e2d77ff85 100644 --- a/frontend/src/components/tools/shared/renderToolButtons.tsx +++ b/frontend/src/components/tools/shared/renderToolButtons.tsx @@ -15,7 +15,8 @@ export const renderToolButtons = ( onSelect: (id: ToolId) => void, showSubcategoryHeader: boolean = true, disableNavigation: boolean = false, - searchResults?: Array<{ item: [string, any]; matchedText?: string }> + searchResults?: Array<{ item: [string, any]; matchedText?: string }>, + hasStars: boolean = false ) => { // Create a map of matched text for quick lookup const matchedTextMap = new Map(); @@ -43,6 +44,7 @@ export const renderToolButtons = ( onSelect={onSelect} disableNavigation={disableNavigation} matchedSynonym={matchedSynonym} + hasStars={hasStars} /> ); })} diff --git a/frontend/src/components/tools/toolPicker/FavoriteStar.tsx b/frontend/src/components/tools/toolPicker/FavoriteStar.tsx new file mode 100644 index 000000000..c8df7098f --- /dev/null +++ b/frontend/src/components/tools/toolPicker/FavoriteStar.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { ActionIcon } from "@mantine/core"; +import type { MantineSize } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import StarRoundedIcon from '@mui/icons-material/StarRounded'; +import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded'; + +interface FavoriteStarProps { + isFavorite: boolean; + onToggle: () => void; + className?: string; + size?: MantineSize; +} + +const FavoriteStar: React.FC = ({ isFavorite, onToggle, className, size = "xs" }) => { + const { t } = useTranslation(); + + return ( + { + e.stopPropagation(); + onToggle(); + }} + className={className} + aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')} + > + {isFavorite ? ( + + ) : ( + + )} + + ); +}; + +export default FavoriteStar; + + diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 8f547e037..72affa2d0 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -9,7 +9,9 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers"; import FitText from "../../shared/FitText"; import { useHotkeys } from "../../../contexts/HotkeyContext"; import HotkeyDisplay from "../../hotkeys/HotkeyDisplay"; -import { ToolId } from "src/types/toolId"; +import FavoriteStar from "./FavoriteStar"; +import { useToolWorkflow } from "../../../contexts/ToolWorkflowContext"; +import { ToolId } from "../../../types/toolId"; interface ToolButtonProps { id: ToolId; @@ -19,15 +21,18 @@ interface ToolButtonProps { rounded?: boolean; disableNavigation?: boolean; matchedSynonym?: string; + hasStars?: boolean; } -const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => { +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => { const { t } = useTranslation(); // Special case: read and multiTool are navigational tools that are always available const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool'; const { hotkeys } = useHotkeys(); const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); + const { isFavorite, toggleFavorite } = useToolWorkflow(); + const fav = isFavorite(id as ToolId); const handleClick = (id: ToolId) => { if (isUnavailable) return; @@ -107,8 +112,12 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", + overflow: 'visible' + }, label: { overflow: 'visible' } }} > @@ -128,8 +137,12 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", + overflow: 'visible' + }, label: { overflow: 'visible' } }} > @@ -146,16 +159,36 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, justify="flex-start" className="tool-button" aria-disabled={isUnavailable} - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }} + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", + cursor: isUnavailable ? 'not-allowed' : undefined, + overflow: 'visible' + }, + label: { overflow: 'visible' } + }} > {buttonContent} ); + const star = hasStars && !isUnavailable ? ( + toggleFavorite(id as ToolId)} + className="tool-button-star" + size="xs" + /> + ) : null; + return ( - - {buttonElement} - +
+ {star} + + {buttonElement} + +
); }; diff --git a/frontend/src/components/tools/toolPicker/ToolPicker.css b/frontend/src/components/tools/toolPicker/ToolPicker.css index 427230a75..b88215471 100644 --- a/frontend/src/components/tools/toolPicker/ToolPicker.css +++ b/frontend/src/components/tools/toolPicker/ToolPicker.css @@ -71,6 +71,24 @@ line-height: 1; } +/* Container to enable hover-only favourite star overlay */ +.tool-button-container { + position: relative; +} + +.tool-button-star { + position: absolute; + top: 0.35rem; + right: 0.35rem; + opacity: 0; + transition: opacity 0.2s ease; + z-index: var(--z-toolpicker-star); /* lower than sticky section headers */ +} + +.tool-button-container:hover .tool-button-star { + opacity: 1; +} + .search-input-container { margin-top: 0.5rem; margin-bottom: 0.5rem; diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx index 90b86f4ef..d94003d2c 100644 --- a/frontend/src/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -6,12 +6,13 @@ import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; import { TextInput } from "../../shared/TextInput"; import "./ToolPicker.css"; import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch"; +import { ToolId } from "src/types/toolId"; interface ToolSearchProps { value: string; onChange: (value: string) => void; - toolRegistry: Readonly>; - onToolSelect?: (toolId: string) => void; + toolRegistry: Partial>; + onToolSelect?: (toolId: ToolId) => void; mode: "filter" | "dropdown" | "unstyled"; selectedToolKey?: string | null; placeholder?: string; @@ -81,15 +82,15 @@ const ToolSearch = ({ }, [autoFocus]); const searchInput = ( - } - autoComplete="off" - onFocus={onFocus} - /> + } + autoComplete="off" + onFocus={onFocus} + /> ); if (mode === "filter") { @@ -126,7 +127,7 @@ const ToolSearch = ({ key={id} variant="subtle" onClick={() => { - onToolSelect?.(id); + onToolSelect?.(id as ToolId); setDropdownOpen(false); }} leftSection={
{tool.icon}
} diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index 71dbfa90b..5ed8a1d46 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -94,7 +94,7 @@ const EmbedPdfViewerContent = ({ }, [previewFile, fileWithUrl]); // Handle scroll wheel zoom with accumulator for smooth trackpad pinch - React.useEffect(() => { + useEffect(() => { let accumulator = 0; const handleWheel = (event: WheelEvent) => { @@ -128,7 +128,7 @@ const EmbedPdfViewerContent = ({ }, [zoomActions]); // Handle keyboard zoom shortcuts - React.useEffect(() => { + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!isViewerHovered) return; diff --git a/frontend/src/constants/convertSupportedFornats.ts b/frontend/src/constants/convertSupportedFornats.ts new file mode 100644 index 000000000..b9bea3227 --- /dev/null +++ b/frontend/src/constants/convertSupportedFornats.ts @@ -0,0 +1,21 @@ +// Central list of formats supported by Convert operations +export const CONVERT_SUPPORTED_FORMATS = [ + // Microsoft Office + 'doc', 'docx', 'dot', 'dotx', 'csv', 'xls', 'xlsx', 'xlt', 'xltx', 'slk', 'dif', 'ppt', 'pptx', + // OpenDocument + 'odt', 'ott', 'ods', 'ots', 'odp', 'otp', 'odg', 'otg', + // Text formats + 'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md', + // Images + 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'pbm', 'pgm', 'ppm', 'ras', 'xbm', 'xpm', 'svg', 'svm', 'wmf', 'webp', + // StarOffice + 'sda', 'sdc', 'sdd', 'sdw', 'stc', 'std', 'sti', 'stw', 'sxd', 'sxg', 'sxi', 'sxw', + // Email formats + 'eml', + // Archive formats + 'zip', + // Other + 'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf', +]; + + diff --git a/frontend/src/contexts/RightRailContext.tsx b/frontend/src/contexts/RightRailContext.tsx index be3b7276c..0d51e265e 100644 --- a/frontend/src/contexts/RightRailContext.tsx +++ b/frontend/src/contexts/RightRailContext.tsx @@ -4,9 +4,11 @@ import { RightRailAction, RightRailButtonConfig } from '../types/rightRail'; interface RightRailContextValue { buttons: RightRailButtonConfig[]; actions: Record; + allButtonsDisabled: boolean; registerButtons: (buttons: RightRailButtonConfig[]) => void; unregisterButtons: (ids: string[]) => void; setAction: (id: string, action: RightRailAction) => void; + setAllRightRailButtonsDisabled: (disabled: boolean) => void; clear: () => void; } @@ -15,6 +17,7 @@ const RightRailContext = createContext(undefi export function RightRailProvider({ children }: { children: React.ReactNode }) { const [buttons, setButtons] = useState([]); const [actions, setActions] = useState>({}); + const [allButtonsDisabled, setAllButtonsDisabled] = useState(false); const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => { setButtons(prev => { @@ -43,12 +46,25 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) { setActions(prev => ({ ...prev, [id]: action })); }, []); + const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => { + setAllButtonsDisabled(disabled); + }, []); + const clear = useCallback(() => { setButtons([]); setActions({}); }, []); - const value = useMemo(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]); + const value = useMemo(() => ({ + buttons, + actions, + allButtonsDisabled, + registerButtons, + unregisterButtons, + setAction, + setAllRightRailButtonsDisabled, + clear + }), [buttons, actions, allButtonsDisabled, registerButtons, unregisterButtons, setAction, setAllRightRailButtonsDisabled, clear]); return ( diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx index 1035cfb99..edcbc0711 100644 --- a/frontend/src/contexts/SidebarContext.tsx +++ b/frontend/src/contexts/SidebarContext.tsx @@ -7,6 +7,7 @@ export function SidebarProvider({ children }: SidebarProviderProps) { // All sidebar state management const quickAccessRef = useRef(null); const toolPanelRef = useRef(null); + const rightRailRef = useRef(null); const [sidebarsVisible, setSidebarsVisible] = useState(true); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); @@ -21,7 +22,8 @@ export function SidebarProvider({ children }: SidebarProviderProps) { const sidebarRefs: SidebarRefs = useMemo(() => ({ quickAccessRef, toolPanelRef, - }), [quickAccessRef, toolPanelRef]); + rightRailRef, + }), [quickAccessRef, toolPanelRef, rightRailRef]); const contextValue: SidebarContextValue = useMemo(() => ({ sidebarState, diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index 73ef058bf..d010dfdfd 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -3,72 +3,26 @@ * Eliminates prop drilling with a single, simple context */ -import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react'; +import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react'; import { useToolManagement } from '../hooks/useToolManagement'; import { PageEditorFunctions } from '../types/pageEditor'; import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy'; import { useNavigationActions, useNavigationState } from './NavigationContext'; import { ToolId, isValidToolId } from '../types/toolId'; -import { useNavigationUrlSync } from '../hooks/useUrlSync'; import { getDefaultWorkbench } from '../types/workbench'; import { filterToolRegistryByQuery } from '../utils/toolSearch'; +import { useToolHistory } from '../hooks/tools/useUserToolActivity'; +import { + ToolWorkflowState, + TOOL_PANEL_MODE_STORAGE_KEY, + createInitialState, + toolWorkflowReducer, + ToolPanelMode, +} from './toolWorkflow/toolWorkflowState'; +import { usePreferences } from './PreferencesContext'; // State interface -interface ToolWorkflowState { - // UI State - sidebarsVisible: boolean; - leftPanelView: 'toolPicker' | 'toolContent' | 'hidden'; - readerMode: boolean; - - // File/Preview State - previewFile: File | null; - pageEditorFunctions: PageEditorFunctions | null; - - // Search State - searchQuery: string; -} - -// Actions -type ToolWorkflowAction = - | { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean } - | { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' } - | { type: 'SET_READER_MODE'; payload: boolean } - | { type: 'SET_PREVIEW_FILE'; payload: File | null } - | { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null } - | { type: 'SET_SEARCH_QUERY'; payload: string } - | { type: 'RESET_UI_STATE' }; - -// Initial state -const initialState: ToolWorkflowState = { - sidebarsVisible: true, - leftPanelView: 'toolPicker', - readerMode: false, - previewFile: null, - pageEditorFunctions: null, - searchQuery: '', -}; - -// Reducer -function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState { - switch (action.type) { - case 'SET_SIDEBARS_VISIBLE': - return { ...state, sidebarsVisible: action.payload }; - case 'SET_LEFT_PANEL_VIEW': - return { ...state, leftPanelView: action.payload }; - case 'SET_READER_MODE': - return { ...state, readerMode: action.payload }; - case 'SET_PREVIEW_FILE': - return { ...state, previewFile: action.payload }; - case 'SET_PAGE_EDITOR_FUNCTIONS': - return { ...state, pageEditorFunctions: action.payload }; - case 'SET_SEARCH_QUERY': - return { ...state, searchQuery: action.payload }; - case 'RESET_UI_STATE': - return { ...initialState, searchQuery: state.searchQuery }; // Preserve search - default: - return state; - } -} +// Types and reducer/state moved to './toolWorkflow/state' // Context value interface interface ToolWorkflowContextValue extends ToolWorkflowState { @@ -82,11 +36,12 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { setSidebarsVisible: (visible: boolean) => void; setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void; setReaderMode: (mode: boolean) => void; + setToolPanelMode: (mode: ToolPanelMode) => void; setPreviewFile: (file: File | null) => void; setPageEditorFunctions: (functions: PageEditorFunctions | null) => void; setSearchQuery: (query: string) => void; - // Tool Actions + selectTool: (toolId: ToolId | null) => void; clearToolSelection: () => void; @@ -103,6 +58,11 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { // Computed values filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search isPanelVisible: boolean; + + // Tool History + favoriteTools: ToolId[]; + toggleFavorite: (toolId: ToolId) => void; + isFavorite: (toolId: ToolId) => boolean; } const ToolWorkflowContext = createContext(undefined); @@ -113,7 +73,8 @@ interface ToolWorkflowProviderProps { } export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { - const [state, dispatch] = useReducer(toolWorkflowReducer, initialState); + const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState); + const { preferences } = usePreferences(); // Store reset functions for tools const [toolResetFunctions, setToolResetFunctions] = React.useState void>>({}); @@ -128,6 +89,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { getSelectedTool, } = useToolManagement(); + // Tool history hook + const { + favoriteTools, + toggleFavorite, + isFavorite, + } = useToolHistory(); + // Get selected tool from navigation context const selectedTool = getSelectedTool(navigationState.selectedTool); @@ -148,6 +116,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { dispatch({ type: 'SET_READER_MODE', payload: mode }); }, [actions]); + const setToolPanelMode = useCallback((mode: ToolPanelMode) => { + dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode }); + }, []); + + const setPreviewFile = useCallback((file: File | null) => { dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); if (file) { @@ -163,6 +136,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { dispatch({ type: 'SET_SEARCH_QUERY', payload: query }); }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, state.toolPanelMode); + }, [state.toolPanelMode]); + + // Keep tool panel mode in sync with user preference. This ensures the + // Config setting (Default tool picker mode) immediately affects the app + // and persists across reloads. + useEffect(() => { + if (!preferences) return; + const preferredMode = preferences.defaultToolPanelMode; + if (preferredMode && preferredMode !== state.toolPanelMode) { + dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode }); + if (typeof window !== 'undefined') { + window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, preferredMode); + } + } + }, [preferences.defaultToolPanelMode]); + // Tool reset methods const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => { setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction })); @@ -190,7 +185,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { return; } - // Handle multiTool selection - enable page editor workbench and hide left panel + // Handle multiTool selection - enable page editor workbench if (toolId === 'multiTool') { setReaderMode(false); setLeftPanelView('hidden'); @@ -239,15 +234,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { [state.sidebarsVisible, state.readerMode, state.leftPanelView] ); - // URL sync for proper tool navigation - useNavigationUrlSync( - navigationState.selectedTool, - handleToolSelect, - handleBackToTools, - toolRegistry as ToolRegistry, - true - ); - // Properly memoized context value const contextValue = useMemo((): ToolWorkflowContextValue => ({ // State @@ -261,6 +247,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setSidebarsVisible, setLeftPanelView, setReaderMode, + setToolPanelMode, setPreviewFile, setPageEditorFunctions, setSearchQuery, @@ -280,6 +267,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Computed filteredTools, isPanelVisible, + + // Tool History + favoriteTools, + toggleFavorite, + isFavorite, }), [ state, navigationState.selectedTool, @@ -289,6 +281,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setSidebarsVisible, setLeftPanelView, setReaderMode, + setToolPanelMode, setPreviewFile, setPageEditorFunctions, setSearchQuery, @@ -300,6 +293,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { handleReaderToggle, filteredTools, isPanelVisible, + favoriteTools, + toggleFavorite, + isFavorite, ]); return ( @@ -313,7 +309,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { export function useToolWorkflow(): ToolWorkflowContextValue { const context = useContext(ToolWorkflowContext); if (!context) { - console.error('ToolWorkflowContext not found. Current stack:', new Error().stack); throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); } diff --git a/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts b/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts new file mode 100644 index 000000000..dbf96c082 --- /dev/null +++ b/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts @@ -0,0 +1,88 @@ +import { PageEditorFunctions } from '../../types/pageEditor'; + +// State & Modes +export type ToolPanelMode = 'sidebar' | 'fullscreen'; + +export interface ToolWorkflowState { + // UI State + sidebarsVisible: boolean; + leftPanelView: 'toolPicker' | 'toolContent' | 'hidden'; + readerMode: boolean; + toolPanelMode: ToolPanelMode; + + previewFile: File | null; + pageEditorFunctions: PageEditorFunctions | null; + + // Search State + searchQuery: string; +} + +// Actions +export type ToolWorkflowAction = + | { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean } + | { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' } + | { type: 'SET_READER_MODE'; payload: boolean } + | { type: 'SET_TOOL_PANEL_MODE'; payload: ToolPanelMode } + | { type: 'SET_PREVIEW_FILE'; payload: File | null } + | { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null } + | { type: 'SET_SEARCH_QUERY'; payload: string } + | { type: 'RESET_UI_STATE' }; + +// Storage keys +export const TOOL_PANEL_MODE_STORAGE_KEY = 'toolPanelModePreference'; + +export const getStoredToolPanelMode = (): ToolPanelMode => { + if (typeof window === 'undefined') { + return 'sidebar'; + } + + const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY); + if (stored === 'fullscreen') { + return 'fullscreen'; + } + + return 'sidebar'; +}; + +export const baseState: Omit = { + sidebarsVisible: true, + leftPanelView: 'toolPicker', + readerMode: false, + previewFile: null, + pageEditorFunctions: null, + searchQuery: '', +}; + +export const createInitialState = (): ToolWorkflowState => ({ + ...baseState, + toolPanelMode: getStoredToolPanelMode(), +}); + +export function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState { + switch (action.type) { + case 'SET_SIDEBARS_VISIBLE': + return { ...state, sidebarsVisible: action.payload }; + case 'SET_LEFT_PANEL_VIEW': + return { ...state, leftPanelView: action.payload }; + case 'SET_READER_MODE': + return { ...state, readerMode: action.payload }; + case 'SET_TOOL_PANEL_MODE': + return { ...state, toolPanelMode: action.payload }; + case 'SET_PREVIEW_FILE': + return { ...state, previewFile: action.payload }; + case 'SET_PAGE_EDITOR_FUNCTIONS': + return { ...state, pageEditorFunctions: action.payload }; + case 'SET_SEARCH_QUERY': + return { ...state, searchQuery: action.payload }; + case 'RESET_UI_STATE': + return { + ...baseState, + toolPanelMode: state.toolPanelMode, + searchQuery: state.searchQuery, + }; + default: + return state; + } +} + + diff --git a/frontend/src/data/toolsTaxonomy.ts b/frontend/src/data/toolsTaxonomy.ts index 79890dc6d..789e3b23f 100644 --- a/frontend/src/data/toolsTaxonomy.ts +++ b/frontend/src/data/toolsTaxonomy.ts @@ -4,6 +4,17 @@ import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation'; import { BaseToolProps } from '../types/tool'; import { WorkbenchType } from '../types/workbench'; import { ToolId } from '../types/toolId'; +import DrawRoundedIcon from '@mui/icons-material/DrawRounded'; +import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded'; +import VerifiedUserRoundedIcon from '@mui/icons-material/VerifiedUserRounded'; +import RateReviewRoundedIcon from '@mui/icons-material/RateReviewRounded'; +import ViewAgendaRoundedIcon from '@mui/icons-material/ViewAgendaRounded'; +import FileDownloadRoundedIcon from '@mui/icons-material/FileDownloadRounded'; +import DeleteSweepRoundedIcon from '@mui/icons-material/DeleteSweepRounded'; +import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; +import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; +import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; export enum SubcategoryId { SIGNING = 'signing', @@ -66,17 +77,46 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [ ]; export const SUBCATEGORY_COLOR_MAP: Record = { - [SubcategoryId.SIGNING]: '#FF7892', - [SubcategoryId.DOCUMENT_SECURITY]: '#FF7892', - [SubcategoryId.VERIFICATION]: '#1BB1D4', - [SubcategoryId.DOCUMENT_REVIEW]: '#48BD54', - [SubcategoryId.PAGE_FORMATTING]: '#7882FF', - [SubcategoryId.EXTRACTION]: '#1BB1D4', - [SubcategoryId.REMOVAL]: '#7882FF', - [SubcategoryId.AUTOMATION]: '#69DC95', - [SubcategoryId.GENERAL]: '#69DC95', - [SubcategoryId.ADVANCED_FORMATTING]: '#F55454', - [SubcategoryId.DEVELOPER_TOOLS]: '#F55454', + [SubcategoryId.SIGNING]: 'var(--category-color-signing)', // Green + [SubcategoryId.DOCUMENT_SECURITY]: 'var(--category-color-security)', // Orange + [SubcategoryId.VERIFICATION]: 'var(--category-color-verification)', // Orange + [SubcategoryId.DOCUMENT_REVIEW]: 'var(--category-color-general)', // Blue + [SubcategoryId.PAGE_FORMATTING]: 'var(--category-color-formatting)', // Purple + [SubcategoryId.EXTRACTION]: 'var(--category-color-extraction)', // Cyan + [SubcategoryId.REMOVAL]: 'var(--category-color-removal)', // Red + [SubcategoryId.AUTOMATION]: 'var(--category-color-automation)', // Pink + [SubcategoryId.GENERAL]: 'var(--category-color-general)', // Blue + [SubcategoryId.ADVANCED_FORMATTING]: 'var(--category-color-formatting)', // Purple + [SubcategoryId.DEVELOPER_TOOLS]: 'var(--category-color-developer)', // Gray +}; + +export const getSubcategoryIcon = (subcategory: SubcategoryId): React.ReactNode => { + switch (subcategory) { + case SubcategoryId.SIGNING: + return React.createElement(DrawRoundedIcon); + case SubcategoryId.DOCUMENT_SECURITY: + return React.createElement(SecurityRoundedIcon); + case SubcategoryId.VERIFICATION: + return React.createElement(VerifiedUserRoundedIcon); + case SubcategoryId.DOCUMENT_REVIEW: + return React.createElement(RateReviewRoundedIcon); + case SubcategoryId.PAGE_FORMATTING: + return React.createElement(ViewAgendaRoundedIcon); + case SubcategoryId.EXTRACTION: + return React.createElement(FileDownloadRoundedIcon); + case SubcategoryId.REMOVAL: + return React.createElement(DeleteSweepRoundedIcon); + case SubcategoryId.AUTOMATION: + return React.createElement(SmartToyRoundedIcon); + case SubcategoryId.GENERAL: + return React.createElement(BuildRoundedIcon); + case SubcategoryId.ADVANCED_FORMATTING: + return React.createElement(TuneRoundedIcon); + case SubcategoryId.DEVELOPER_TOOLS: + return React.createElement(CodeRoundedIcon); + default: + return React.createElement(BuildRoundedIcon); + } }; export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id); diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 04ac93fa9..b695b9477 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -112,85 +112,7 @@ import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSett const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI // Convert tool supported file formats -export const CONVERT_SUPPORTED_FORMATS = [ - // Microsoft Office - "doc", - "docx", - "dot", - "dotx", - "csv", - "xls", - "xlsx", - "xlt", - "xltx", - "slk", - "dif", - "ppt", - "pptx", - // OpenDocument - "odt", - "ott", - "ods", - "ots", - "odp", - "otp", - "odg", - "otg", - // Text formats - "txt", - "text", - "xml", - "rtf", - "html", - "lwp", - "md", - // Images - "bmp", - "gif", - "jpeg", - "jpg", - "png", - "tif", - "tiff", - "pbm", - "pgm", - "ppm", - "ras", - "xbm", - "xpm", - "svg", - "svm", - "wmf", - "webp", - // StarOffice - "sda", - "sdc", - "sdd", - "sdw", - "stc", - "std", - "sti", - "stw", - "sxd", - "sxg", - "sxi", - "sxw", - // Email formats - "eml", - // Archive formats - "zip", - // Other - "dbf", - "fods", - "vsd", - "vor", - "vor3", - "vor4", - "uop", - "pct", - "ps", - "pdf", -]; +import { CONVERT_SUPPORTED_FORMATS } from "../constants/convertSupportedFornats"; // Hook to get the translated tool registry export function useFlatToolRegistry(): ToolRegistry { diff --git a/frontend/src/hooks/tools/useFavoriteToolItems.ts b/frontend/src/hooks/tools/useFavoriteToolItems.ts new file mode 100644 index 000000000..9101e3e3e --- /dev/null +++ b/frontend/src/hooks/tools/useFavoriteToolItems.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { ToolId } from '../../types/toolId'; +import { ToolRegistryEntry } from '../../data/toolsTaxonomy'; + +export function useFavoriteToolItems( + favoriteTools: ToolId[], + toolRegistry: Partial> +): Array<{ id: ToolId; tool: ToolRegistryEntry }> { + return useMemo(() => { + return favoriteTools + .map((toolId) => { + const tool = toolRegistry[toolId as ToolId]; + return tool ? { id: toolId as ToolId, tool } : null; + }) + .filter((x): x is { id: ToolId; tool: ToolRegistryEntry } => x !== null) + .filter(({ id, tool }) => Boolean(tool.component) || Boolean(tool.link) || id === 'read' || id === 'multiTool'); + }, [favoriteTools, toolRegistry]); +} + + diff --git a/frontend/src/hooks/tools/useJsonLocalStorageState.ts b/frontend/src/hooks/tools/useJsonLocalStorageState.ts new file mode 100644 index 000000000..739251f4e --- /dev/null +++ b/frontend/src/hooks/tools/useJsonLocalStorageState.ts @@ -0,0 +1,30 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; + +export function useLocalStorageState(key: string, defaultValue: T): [T, Dispatch>] { + const [state, setState] = useState(() => { + if (typeof window === 'undefined') { + return defaultValue; + } + + const stored = window.localStorage.getItem(key); + if (stored === null) { + return defaultValue; + } + + try { + return JSON.parse(stored) as T; + } catch { + return defaultValue; + } + }); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(key, JSON.stringify(state)); + }, [key, state]); + + return [state, setState]; +} diff --git a/frontend/src/hooks/tools/useToolPanelGeometry.ts b/frontend/src/hooks/tools/useToolPanelGeometry.ts new file mode 100644 index 000000000..df7b62b42 --- /dev/null +++ b/frontend/src/hooks/tools/useToolPanelGeometry.ts @@ -0,0 +1,119 @@ +import { useLayoutEffect, useState, RefObject, useRef } from 'react'; + +export interface ToolPanelGeometry { + left: number; + top: number; + width: number; + height: number; +} + +interface UseToolPanelGeometryOptions { + enabled: boolean; + toolPanelRef: RefObject; + quickAccessRef: RefObject; + rightRailRef?: RefObject; +} + +export function useToolPanelGeometry({ + enabled, + toolPanelRef, + quickAccessRef, + rightRailRef, +}: UseToolPanelGeometryOptions) { + const [geometry, setGeometry] = useState(null); + const scheduleUpdateRef = useRef<() => void>(() => {}); + + useLayoutEffect(() => { + if (!enabled) { + setGeometry(null); + return; + } + + const panelEl = toolPanelRef.current; + if (!panelEl) { + setGeometry(null); + return; + } + + const rightRailEl = () => (rightRailRef?.current ?? null); + + let rafId: number | null = null; + + const computeAndSetGeometry = () => { + const rect = panelEl.getBoundingClientRect(); + const rail = rightRailEl(); + const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0; + const width = Math.max(360, window.innerWidth - rect.left - rightOffset); + const height = Math.max(rect.height, window.innerHeight - rect.top); + setGeometry({ + left: rect.left, + top: rect.top, + width, + height, + }); + }; + + const scheduleUpdate = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + computeAndSetGeometry(); + rafId = null; + }); + }; + scheduleUpdateRef.current = scheduleUpdate; + + // Initial geometry calculation (no debounce) + computeAndSetGeometry(); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => scheduleUpdate()); + resizeObserver.observe(panelEl); + if (quickAccessRef.current) { + resizeObserver.observe(quickAccessRef.current); + } + const rail = rightRailEl(); + if (rail) { + resizeObserver.observe(rail); + } + // Observe root element to react to viewport-driven layout changes + if (document.documentElement) { + resizeObserver.observe(document.documentElement); + } + } else { + // Fallback for environments without ResizeObserver + const handleResize = () => scheduleUpdate(); + window.addEventListener('resize', handleResize); + // Ensure cleanup of the fallback listener + resizeObserver = { + disconnect: () => window.removeEventListener('resize', handleResize), + } as unknown as ResizeObserver; + } + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + scheduleUpdateRef.current = () => {}; + resizeObserver?.disconnect(); + }; + }, [enabled, quickAccessRef, toolPanelRef, rightRailRef]); + + // Secondary effect: (re)attach observers when refs' .current become available later + useLayoutEffect(() => { + if (!enabled) return; + if (typeof ResizeObserver === 'undefined') return; + const qa = quickAccessRef.current; + const rail = rightRailRef?.current ?? null; + if (!qa && !rail) return; + + const ro = new ResizeObserver(() => scheduleUpdateRef.current()); + if (qa) ro.observe(qa); + if (rail) ro.observe(rail); + return () => ro.disconnect(); + }, [enabled, quickAccessRef.current, rightRailRef?.current]); + + return geometry; +} diff --git a/frontend/src/hooks/tools/useUserToolActivity.ts b/frontend/src/hooks/tools/useUserToolActivity.ts new file mode 100644 index 000000000..a1149c443 --- /dev/null +++ b/frontend/src/hooks/tools/useUserToolActivity.ts @@ -0,0 +1,70 @@ +import { useState, useEffect, useCallback } from 'react'; +import { ToolId } from '../../types/toolId'; + +const RECENT_TOOLS_KEY = 'stirlingpdf.recentTools'; +const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools'; + +export function useToolHistory() { + const [recentTools, setRecentTools] = useState([]); + const [favoriteTools, setFavoriteTools] = useState([]); + + // Load from localStorage on mount + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const recentStr = window.localStorage.getItem(RECENT_TOOLS_KEY); + const favoritesStr = window.localStorage.getItem(FAVORITE_TOOLS_KEY); + + if (recentStr) { + try { + const recent = JSON.parse(recentStr) as ToolId[]; + setRecentTools(recent); + } catch { + // Ignore parse errors + } + } + + if (favoritesStr) { + try { + const favorites = JSON.parse(favoritesStr) as ToolId[]; + setFavoriteTools(favorites); + } catch { + // Ignore parse errors + } + } + }, []); + + + // Toggle favorite status + const toggleFavorite = useCallback((toolId: ToolId) => { + if (typeof window === 'undefined') { + return; + } + + setFavoriteTools((prev) => { + const isFavorite = prev.includes(toolId); + const updated = isFavorite + ? prev.filter((id) => id !== toolId) + : [...prev, toolId]; + window.localStorage.setItem(FAVORITE_TOOLS_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + // Check if a tool is favorited + const isFavorite = useCallback( + (toolId: ToolId): boolean => { + return favoriteTools.includes(toolId); + }, + [favoriteTools] + ); + + return { + recentTools, + favoriteTools, + toggleFavorite, + isFavorite, + }; +} diff --git a/frontend/src/hooks/useFocusTrap.ts b/frontend/src/hooks/useFocusTrap.ts new file mode 100644 index 000000000..0fa783bdb --- /dev/null +++ b/frontend/src/hooks/useFocusTrap.ts @@ -0,0 +1,76 @@ +import { useEffect, RefObject } from 'react'; + +const FOCUSABLE_ELEMENTS = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(', '); + +export function useFocusTrap(containerRef: RefObject, enabled: boolean = true) { + useEffect(() => { + if (!enabled || !containerRef.current) { + return; + } + + const container = containerRef.current; + const getFocusableElements = () => + Array.from(container.querySelectorAll(FOCUSABLE_ELEMENTS)); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + + const focusableElements = getFocusableElements(); + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement; + + // Check if focus is within the container + if (!container.contains(activeElement)) { + event.preventDefault(); + firstElement.focus(); + return; + } + + // Shift + Tab (backwards) + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } + // Tab (forwards) + else { + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }; + + // Focus first element on mount + const focusableElements = getFocusableElements(); + if (focusableElements.length > 0) { + // Small delay to ensure the element is fully rendered + setTimeout(() => { + focusableElements[0]?.focus(); + }, 100); + } + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [containerRef, enabled]); +} + + diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 054e99304..fd5606e2e 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -36,7 +36,7 @@ export const useToolManagement = (): ToolManagementResult => { (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { if (isToolAvailable(toolKey)) { const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry]; - availableToolRegistry[toolKey] = { + availableToolRegistry[toolKey as ToolId] = { ...baseTool, name: baseTool.name, description: baseTool.description, diff --git a/frontend/src/hooks/useToolPanelModePreference.ts b/frontend/src/hooks/useToolPanelModePreference.ts new file mode 100644 index 000000000..b8656eeae --- /dev/null +++ b/frontend/src/hooks/useToolPanelModePreference.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TOOL_PANEL_MODE_STORAGE_KEY, ToolPanelMode } from '../contexts/toolWorkflow/toolWorkflowState'; + +const PROMPT_SEEN_KEY = 'toolPanelModePromptSeen'; + +export function useToolPanelModePreference() { + const [hydrated, setHydrated] = useState(false); + + const getPreferredMode = useCallback((): ToolPanelMode | null => { + if (typeof window === 'undefined') return null; + const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY); + return stored === 'sidebar' || stored === 'fullscreen' ? stored : null; + }, []); + + const setPreferredMode = useCallback((mode: ToolPanelMode) => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, mode); + }, []); + + const hasSeenPrompt = useCallback((): boolean => { + if (typeof window === 'undefined') return true; + return window.localStorage.getItem(PROMPT_SEEN_KEY) === 'true'; + }, []); + + const markPromptSeen = useCallback(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(PROMPT_SEEN_KEY, 'true'); + }, []); + + const shouldShowPrompt = useMemo(() => { + const seen = hasSeenPrompt(); + const pref = getPreferredMode(); + return !seen && !pref; + }, [getPreferredMode, hasSeenPrompt]); + + useEffect(() => { + setHydrated(true); + }, []); + + return { + hydrated, + getPreferredMode, + setPreferredMode, + hasSeenPrompt, + markPromptSeen, + shouldShowPrompt, + } as const; +} + + diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts index 40eac31e6..8bfe18a64 100644 --- a/frontend/src/hooks/useToolSections.ts +++ b/frontend/src/hooks/useToolSections.ts @@ -115,7 +115,7 @@ export function useToolSections( seen.add(toolId); const sub = tool.subcategoryId; if (!subMap[sub]) subMap[sub] = []; - subMap[sub].push({ id: toolId, tool }); + subMap[sub].push({ id: toolId as ToolId, tool }); }); const entries = Object.entries(subMap); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 4b9514a3c..168a07fbb 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -16,6 +16,7 @@ import FileManager from "../components/FileManager"; import LocalIcon from "../components/shared/LocalIcon"; import { useFilesModalContext } from "../contexts/FilesModalContext"; import AppConfigModal from "../components/shared/AppConfigModal"; +import ToolPanelModePrompt from "../components/tools/ToolPanelModePrompt"; import "./HomePage.css"; @@ -30,7 +31,14 @@ export default function HomePage() { const { quickAccessRef } = sidebarRefs; - const { selectedTool, selectedToolKey, handleToolSelect, handleBackToTools } = useToolWorkflow(); + const { + selectedTool, + selectedToolKey, + handleToolSelect, + handleBackToTools, + readerMode, + setLeftPanelView, + } = useToolWorkflow(); const { openFilesModal } = useFilesModalContext(); const { colorScheme } = useMantineColorScheme(); @@ -110,6 +118,23 @@ export default function HomePage() { }; }, [isMobile]); + // Automatically switch to workbench when read mode or multiTool is activated in mobile + useEffect(() => { + if (isMobile && (readerMode || selectedToolKey === 'multiTool')) { + setActiveMobileView('workbench'); + } + }, [isMobile, readerMode, selectedToolKey]); + + // When navigating back to tools view in mobile with a workbench-only tool, show tool picker + useEffect(() => { + if (isMobile && activeMobileView === 'tools' && selectedTool) { + // Check if this is a workbench-only tool (has workbench but no component) + if (selectedTool.workbench && !selectedTool.component) { + setLeftPanelView('toolPicker'); + } + } + }, [isMobile, activeMobileView, selectedTool, setLeftPanelView]); + const baseUrl = getBaseUrl(); // Update document meta when tool changes @@ -126,6 +151,7 @@ export default function HomePage() { return (
+ {isMobile ? (
@@ -231,8 +257,7 @@ export default function HomePage() { h="100%" className="flex-nowrap flex" > - + diff --git a/frontend/src/services/preferencesService.ts b/frontend/src/services/preferencesService.ts index b7c22094e..2f126b297 100644 --- a/frontend/src/services/preferencesService.ts +++ b/frontend/src/services/preferencesService.ts @@ -1,13 +1,16 @@ +import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; export interface UserPreferences { autoUnzip: boolean; autoUnzipFileLimit: number; + defaultToolPanelMode: ToolPanelMode; } export const DEFAULT_PREFERENCES: UserPreferences = { autoUnzip: true, autoUnzipFileLimit: 4, + defaultToolPanelMode: 'sidebar', }; class PreferencesService { diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 3b0ac694b..e15e4e26d 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -1,6 +1,17 @@ /* CSS variables for Tailwind + Mantine integration */ :root { + /* Global animation durations */ + --fullscreen-anim-duration-in: 0.28s; + --fullscreen-anim-duration-out: 0.22s; + + /* Z-index constants (added in this PR) */ + --z-over-fullscreen-surface: 1400; + --z-fullscreen-surface: 1200; + --z-fullscreen-favorite-star: 2; + --z-fullscreen-icon-svg: 1; + --z-toolpicker-star: 1; + /* Standard gray scale */ --gray-50: 249 250 251; --gray-100: 243 244 246; @@ -48,6 +59,22 @@ --color-yellow-200: #fef08a; --color-yellow-300: #fde047; --color-yellow-400: #facc15; + + /* Category colors - consistent across light and dark modes */ + --category-color-removal: #ef4444; /* Red for removal tools */ + --category-color-security: #f59e0b; /* Orange for security tools */ + --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ + --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ + --category-color-signing: #10b981; /* Green for signing tools */ + --category-color-general: #3b82f6; /* Blue for general tools */ + --category-color-verification: #f97316; /* Orange for verification tools */ + --category-color-automation: #ec4899; /* Pink for automation tools */ + --category-color-developer: #6b7280; /* Gray for developer tools */ + --category-color-default: #6b7280; /* Default gray */ + + /* Special section colors - consistent across light and dark modes */ + --special-color-favorites: #FFC107; /* Yellow/gold for favorites */ + --special-color-recommended: #1BB1D4; /* Cyan for recommended */ --color-yellow-500: #eab308; --color-yellow-600: #ca8a04; --color-yellow-700: #a16207; @@ -277,6 +304,22 @@ --color-gray-800: #e5e7eb; --color-gray-900: #f3f4f6; + /* Category colors - same as light mode for consistency */ + --category-color-removal: #ef4444; /* Red for removal tools */ + --category-color-security: #f59e0b; /* Orange for security tools */ + --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ + --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ + --category-color-signing: #10b981; /* Green for signing tools */ + --category-color-general: #3b82f6; /* Blue for general tools */ + --category-color-verification: #f97316; /* Orange for verification tools */ + --category-color-automation: #ec4899; /* Pink for automation tools */ + --category-color-developer: #6b7280; /* Gray for developer tools */ + --category-color-default: #6b7280; /* Default gray */ + + /* Special section colors - same as light mode for consistency */ + --special-color-favorites: #FFC107; /* Yellow/gold for favorites */ + --special-color-recommended: #1BB1D4; /* Cyan for recommended */ + /* Success (green) - dark */ --color-green-50: #052e16; --color-green-100: #064e3b; @@ -445,4 +488,11 @@ /* Smooth transitions for theme switching */ * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; -} \ No newline at end of file +} +:root { + --shadow-color: rgba(15, 23, 42, 0.55); +} + +[data-theme="dark"] { + --shadow-color: rgba(0, 0, 0, 0.75); +} diff --git a/frontend/src/styles/zIndex.ts b/frontend/src/styles/zIndex.ts new file mode 100644 index 000000000..5abe9cf9b --- /dev/null +++ b/frontend/src/styles/zIndex.ts @@ -0,0 +1,9 @@ +// Centralized z-index constants for new usages added in this branch. +// Keep values identical to their original inline usages. + +export const Z_INDEX_FULLSCREEN_SURFACE = 1000; +export const Z_INDEX_AUTOMATE_MODAL = 1100; +export const Z_INDEX_FILE_MANAGER_MODAL = 1200; +export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300; + + diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index ba47df4f1..4ec6dc646 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFileSelection } from "../contexts/FileContext"; import { useNavigationActions } from "../contexts/NavigationContext"; @@ -48,7 +48,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }; // Register reset function with the tool workflow context - only once on mount - React.useEffect(() => { + useEffect(() => { const stableResetFunction = () => { if (resetFunctionRef.current) { resetFunctionRef.current(); diff --git a/frontend/src/types/sidebar.ts b/frontend/src/types/sidebar.ts index b93db61d6..ba6a8683e 100644 --- a/frontend/src/types/sidebar.ts +++ b/frontend/src/types/sidebar.ts @@ -7,6 +7,7 @@ export interface SidebarState { export interface SidebarRefs { quickAccessRef: React.RefObject; toolPanelRef: React.RefObject; + rightRailRef: React.RefObject; } export interface SidebarInfo { diff --git a/frontend/src/utils/toolSearch.ts b/frontend/src/utils/toolSearch.ts index 8bd963564..b2ec9b590 100644 --- a/frontend/src/utils/toolSearch.ts +++ b/frontend/src/utils/toolSearch.ts @@ -8,7 +8,7 @@ export interface RankedToolItem { } export function filterToolRegistryByQuery( - toolRegistry: Record, + toolRegistry: Record, query: string ): RankedToolItem[] { const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]; @@ -85,10 +85,10 @@ export function filterToolRegistryByQuery( ordered.push({ item: [id, tool], matchedText }); }; - for (const { id, tool } of exactName) push(id, tool, tool.name); - for (const { id, tool, text } of exactSyn) push(id, tool, text); - for (const { id, tool, text } of fuzzyName) push(id, tool, text); - for (const { id, tool, text } of fuzzySyn) push(id, tool, text); + for (const { id, tool } of exactName) push(id as ToolId, tool, tool.name); + for (const { id, tool, text } of exactSyn) push(id as ToolId, tool, text); + for (const { id, tool, text } of fuzzyName) push(id as ToolId, tool, text); + for (const { id, tool, text } of fuzzySyn) push(id as ToolId, tool, text); if (ordered.length > 0) return ordered;