diff --git a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg index 6c99f5001..001c6a01c 100644 --- a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg +++ b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 18a6add5e..7d01af4f5 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -382,6 +382,10 @@ "title": "Add image", "desc": "Adds a image onto a set location on the PDF" }, + "attachments": { + "title": "Add Attachments", + "desc": "Add or remove embedded files (attachments) to/from a PDF" + }, "watermark": { "title": "Add Watermark", "desc": "Add a custom watermark to your PDF document." @@ -597,6 +601,30 @@ "replace-color": { "title": "Advanced Colour options", "desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size" + }, + "fakeScan": { + "title": "Fake Scan", + "desc": "Create a PDF that looks like it was scanned" + }, + "editTableOfContents": { + "title": "Edit Table of Contents", + "desc": "Add or edit bookmarks and table of contents in PDF documents" + }, + "automate": { + "title": "Automate", + "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "manageCertificates": { + "title": "Manage Certificates", + "desc": "Import, export, or delete digital certificate files used for signing PDFs." + }, + "read": { + "title": "Read", + "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." + }, + "reorganizePages": { + "title": "Reorganize Pages", + "desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." } }, "viewPdf": { @@ -777,6 +805,15 @@ "upload": "Add image", "submit": "Add image" }, + "attachments": { + "tags": "attachments,add,remove,embed,file", + "title": "Add Attachments", + "header": "Add Attachments", + "add": "Add Attachment", + "remove": "Remove Attachment", + "embed": "Embed Attachment", + "submit": "Add Attachments" + }, "watermark": { "title": "Add Watermark", "desc": "Add text or image watermarks to PDF files", @@ -1740,7 +1777,7 @@ "title": "How we use Cookies", "description": { "1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.", - "2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." + "2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." }, "acceptAllBtn": "Okay", "acceptNecessaryBtn": "No Thanks", @@ -1764,7 +1801,7 @@ "1": "Strictly Necessary Cookies", "2": "Always Enabled" }, - "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off." + "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off." }, "analytics": { "title": "Analytics", @@ -1823,7 +1860,31 @@ }, "toolPicker": { "searchPlaceholder": "Search tools...", - "noToolsFound": "No tools found" + "noToolsFound": "No tools found", + "allTools": "ALL TOOLS", + "quickAccess": "QUICK ACCESS", + "subcategories": { + "Signing": "Signing", + "Document Security": "Document Security", + "Verification": "Verification", + "Document Review": "Document Review", + "Page Formatting": "Page Formatting", + "Extraction": "Extraction", + "Removal": "Removal", + "Automation": "Automation", + "General": "General", + "Advanced Formatting": "Advanced Formatting", + "Developer Tools": "Developer Tools" + } + }, + "quickAccess": { + "read": "Read", + "sign": "Sign", + "automate": "Automate", + "files": "Files", + "activity": "Activity", + "config": "Config", + "allTools": "All Tools" }, "fileUpload": { "selectFile": "Select a file", diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index e6f101803..732b37d7b 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; -import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileContext } from '../../contexts/FileContext'; @@ -28,9 +28,9 @@ export default function Workbench() { setPreviewFile, setPageEditorFunctions, setSidebarsVisible - } = useWorkbenchState(); + } = useToolWorkflow(); - const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection(); + const { selectedToolKey, selectedTool, handleToolSelect } = useToolWorkflow(); const { addToActiveFiles } = useFileHandler(); const handlePreviewClose = () => { diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx new file mode 100644 index 000000000..f2b308b26 --- /dev/null +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { ActionIcon } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from './Tooltip'; +import AppsIcon from '@mui/icons-material/AppsRounded'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; + +interface AllToolsNavButtonProps { + activeButton: string; + setActiveButton: (id: string) => void; +} + +const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton }) => { + const { t } = useTranslation(); + const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); + + const handleClick = () => { + setActiveButton('tools'); + // Preserve existing behavior used in QuickAccessBar header + handleReaderToggle(); + handleBackToTools(); + }; + + // Do not highlight All Tools when a specific tool is open (indicator is shown) + const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker'; + + const iconNode = ( + + + + ); + + return ( + + +
+ + {iconNode} + + + {t("quickAccess.allTools", "All Tools")} + +
+
+ ); +}; + +export default AllToolsNavButton; + + diff --git a/frontend/src/components/shared/FitText.tsx b/frontend/src/components/shared/FitText.tsx new file mode 100644 index 000000000..0a5e2711b --- /dev/null +++ b/frontend/src/components/shared/FitText.tsx @@ -0,0 +1,79 @@ +import React, { CSSProperties, useMemo, useRef } from 'react'; +import { useAdjustFontSizeToFit } from './fitText/textFit'; + +type FitTextProps = { + text: string; + fontSize?: number; // px; if omitted, uses computed style + minimumFontScale?: number; // 0..1 + lines?: number; // max lines + className?: string; + style?: CSSProperties; + as?: 'span' | 'div'; + /** + * Insert zero-width soft breaks after these characters to prefer wrapping at them + * when multi-line is enabled. Defaults to '/'. Ignored when lines === 1. + */ + softBreakChars?: string | string[]; +}; + +const FitText: React.FC = ({ + text, + fontSize, + minimumFontScale = 0.8, + lines = 1, + className, + style, + as = 'span', + softBreakChars = ['-','_','/'], +}) => { + const ref = useRef(null); + + // Hook runs after mount and on size/text changes; uses observers internally + useAdjustFontSizeToFit(ref as any, { + maxFontSizePx: fontSize, + minFontScale: minimumFontScale, + maxLines: lines, + singleLine: lines === 1, + }); + + // Memoize the HTML tag to render (span/div) from the `as` prop so + // React doesn't create a new component function on each render. + const ElementTag: any = useMemo(() => as, [as]); + + // For the / character, insert zero-width soft breaks to prefer wrapping at them + const displayText = useMemo(() => { + if (!text) return text; + if (!lines || lines <= 1) return text; + const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars]; + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(${chars.filter(Boolean).map(esc).join('|')})`, 'g'); + return text.replace(re, `$1\u200B`); + }, [text, lines, softBreakChars]); + + const clampStyles: CSSProperties = { + // Multi-line clamp with ellipsis fallback + whiteSpace: lines === 1 ? 'nowrap' : 'normal', + overflow: 'visible', + textOverflow: 'ellipsis', + display: lines > 1 ? ('-webkit-box' as any) : undefined, + WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined, + WebkitLineClamp: lines > 1 ? (lines as any) : undefined, + lineClamp: lines > 1 ? (lines as any) : undefined, + // Favor shrinking over breaking words; only break at natural spaces or softBreakChars + wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any), + overflowWrap: 'normal', + hyphens: 'manual', + // fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size + fontSize: fontSize ? `${fontSize}rem` : undefined, + }; + + return ( + + {displayText} + + ); +}; + +export default FitText; + + diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 7aed3632b..bc041a923 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,143 +1,93 @@ -import React, { useState, useRef, forwardRef } from "react"; -import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; +import React, { useState, useRef, forwardRef, useEffect } from "react"; +import { ActionIcon, Stack, Divider } from "@mantine/core"; +import { useTranslation } from 'react-i18next'; import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; -import AppsIcon from "@mui/icons-material/AppsRounded"; import SettingsIcon from "@mui/icons-material/SettingsRounded"; -import AutoAwesomeIcon from "@mui/icons-material/AutoAwesomeRounded"; import FolderIcon from "@mui/icons-material/FolderRounded"; -import PersonIcon from "@mui/icons-material/PersonRounded"; -import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { ButtonConfig } from '../../types/sidebar'; -import './QuickAccessBar.css'; - -function NavHeader({ - activeButton, - setActiveButton -}: { - activeButton: string; - setActiveButton: (id: string) => void; -}) { - const { handleReaderToggle, handleBackToTools } = useToolWorkflow(); - return ( - <> -
- - - - - - - - - - -
- {/* Divider after top icons */} - - {/* All Tools button below divider */} - -
- { - setActiveButton('tools'); - handleReaderToggle(); - handleBackToTools(); - }} - style={{ - backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', - color: activeButton === 'tools' ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', - border: 'none', - borderRadius: '8px', - }} - className={activeButton === 'tools' ? 'activeIconScale' : ''} - > - - - - - - All Tools - -
-
- - ); -} +import './quickAccessBar/QuickAccessBar.css'; +import AllToolsNavButton from './AllToolsNavButton'; +import ActiveToolButton from "./quickAccessBar/ActiveToolButton"; +import { + isNavButtonActive, + getNavButtonStyle, + getActiveNavButton, +} from './quickAccessBar/QuickAccessBar'; const QuickAccessBar = forwardRef(({ }, ref) => { + const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); - const { handleReaderToggle } = useToolWorkflow(); + const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); const isOverflow = useIsOverflowing(scrollableRef); + useEffect(() => { + const next = getActiveNavButton(selectedToolKey, readerMode); + setActiveButton(next); + }, [leftPanelView, selectedToolKey, toolRegistry, readerMode]); + const handleFilesButtonClick = () => { openFilesModal(); }; + const buttonConfigs: ButtonConfig[] = [ { id: 'read', - name: 'Read', + name: t("quickAccess.read", "Read"), icon: , - tooltip: 'Read documents', size: 'lg', isRound: false, type: 'navigation', onClick: () => { setActiveButton('read'); + handleBackToTools(); handleReaderToggle(); } }, { id: 'sign', - name: 'Sign', - icon: - - signature - , - tooltip: 'Sign your document', + name: t("quickAccess.sign", "Sign"), + icon: + + signature + , size: 'lg', isRound: false, type: 'navigation', - onClick: () => setActiveButton('sign') + onClick: () => { + setActiveButton('sign'); + handleToolSelect('sign'); + } }, { id: 'automate', - name: 'Automate', - icon: , - tooltip: 'Automate workflows', + name: t("quickAccess.automate", "Automate"), + icon: + + automation + , size: 'lg', isRound: false, type: 'navigation', - onClick: () => setActiveButton('automate') + onClick: () => { + setActiveButton('automate'); + handleToolSelect('automate'); + } }, { id: 'files', - name: 'Files', - icon: , - tooltip: 'Manage files', + name: t("quickAccess.files", "Files"), + icon: , isRound: true, size: 'lg', type: 'modal', @@ -145,12 +95,11 @@ const QuickAccessBar = forwardRef(({ }, { id: 'activity', - name: 'Activity', - icon: - - vital_signs - , - tooltip: 'View activity and analytics', + name: t("quickAccess.activity", "Activity"), + icon: + + vital_signs + , isRound: true, size: 'lg', type: 'navigation', @@ -158,9 +107,8 @@ const QuickAccessBar = forwardRef(({ }, { id: 'config', - name: 'Config', + name: t("quickAccess.config", "Config"), icon: , - tooltip: 'Configure settings', size: 'lg', type: 'modal', onClick: () => { @@ -169,60 +117,28 @@ const QuickAccessBar = forwardRef(({ } ]; - const CIRCULAR_BORDER_RADIUS = '50%'; - const ROUND_BORDER_RADIUS = '8px'; - const getBorderRadius = (config: ButtonConfig): string => { - return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; - }; - - const isButtonActive = (config: ButtonConfig): boolean => { - return ( - (config.type === 'navigation' && activeButton === config.id) || - (config.type === 'modal' && config.id === 'files' && isFilesModalOpen) || - (config.type === 'modal' && config.id === 'config' && configModalOpen) - ); - }; - - const getButtonStyle = (config: ButtonConfig) => { - const isActive = isButtonActive(config); - - if (isActive) { - return { - backgroundColor: `var(--icon-${config.id}-bg)`, - color: `var(--icon-${config.id}-color)`, - border: 'none', - borderRadius: getBorderRadius(config), - }; - } - - // Inactive state for all buttons - return { - backgroundColor: 'var(--icon-inactive-bg)', - color: 'var(--icon-inactive-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - }; return (
{/* Fixed header outside scrollable area */}
- + + +
{/* Conditional divider when overflowing */} {isOverflow && ( - )} @@ -241,63 +157,63 @@ const QuickAccessBar = forwardRef(({ {buttonConfigs.slice(0, -1).map((config, index) => ( - +
{ + config.onClick(); + }} + style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} + className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} data-testid={`${config.id}-button`} > {config.icon} - + {config.name}
-
- + + {/* Add divider after Automate button (index 2) */} {index === 2 && ( - + )}
))}
- + {/* Spacer to push Config button to bottom */}
- + {/* Config button at the bottom */} {buttonConfigs .filter(config => config.id === 'config') .map(config => ( -
{config.icon} - + {config.name}
-
))}
diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx index 27cf5101f..21c46cf72 100644 --- a/frontend/src/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, ReactNode } from 'react'; -import { MantineProvider, ColorSchemeScript } from '@mantine/core'; +import { MantineProvider } from '@mantine/core'; import { useRainbowTheme } from '../../hooks/useRainbowTheme'; import { mantineTheme } from '../../theme/mantineTheme'; import rainbowStyles from '../../styles/rainbow.module.css'; @@ -34,22 +34,19 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode; return ( - <> - - - + +
-
- {children} -
- - - + {children} +
+
+
); } diff --git a/frontend/src/components/shared/TextInput.tsx b/frontend/src/components/shared/TextInput.tsx new file mode 100644 index 000000000..e44e8efb2 --- /dev/null +++ b/frontend/src/components/shared/TextInput.tsx @@ -0,0 +1,104 @@ +import React, { forwardRef } from 'react'; +import { useMantineColorScheme } from '@mantine/core'; +import styles from './textInput/TextInput.module.css'; + +/** + * Props for the TextInput component + */ +export interface TextInputProps { + /** The input value (required) */ + value: string; + /** Callback when input value changes (required) */ + onChange: (value: string) => void; + /** Placeholder text */ + placeholder?: string; + /** Optional left icon */ + icon?: React.ReactNode; + /** Whether to show the clear button (default: true) */ + showClearButton?: boolean; + /** Custom clear handler (defaults to setting value to empty string) */ + onClear?: () => void; + /** Additional CSS classes */ + className?: string; + /** Additional inline styles */ + style?: React.CSSProperties; + /** HTML autocomplete attribute (default: 'off') */ + autoComplete?: string; + /** Whether the input is disabled (default: false) */ + disabled?: boolean; + /** Whether the input is read-only (default: false) */ + readOnly?: boolean; + /** Accessibility label */ + 'aria-label'?: string; +} + +export const TextInput = forwardRef(({ + value, + onChange, + placeholder, + icon, + showClearButton = true, + onClear, + className = '', + style, + autoComplete = 'off', + disabled = false, + readOnly = false, + 'aria-label': ariaLabel, + ...props +}, ref) => { + const { colorScheme } = useMantineColorScheme(); + + const handleClear = () => { + if (onClear) { + onClear(); + } else { + onChange(''); + } + }; + + const shouldShowClearButton = showClearButton && value.trim().length > 0 && !disabled && !readOnly; + + return ( +
+ {icon && ( + + {icon} + + )} + onChange(e.currentTarget.value)} + autoComplete={autoComplete} + className={styles.input} + disabled={disabled} + readOnly={readOnly} + aria-label={ariaLabel} + style={{ + backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', + color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', + paddingRight: shouldShowClearButton ? '40px' : '12px', + paddingLeft: icon ? '40px' : '12px', + }} + {...props} + /> + {shouldShowClearButton && ( + + )} +
+ ); +}); diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index e42f92083..c415eddf5 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -15,6 +15,7 @@ export interface TooltipProps { children: React.ReactElement; offset?: number; maxWidth?: number | string; + minWidth?: number | string; open?: boolean; onOpenChange?: (open: boolean) => void; arrow?: boolean; @@ -23,6 +24,8 @@ export interface TooltipProps { title: string; logo?: React.ReactNode; }; + delay?: number; + containerStyle?: React.CSSProperties; } export const Tooltip: React.FC = ({ @@ -32,18 +35,28 @@ export const Tooltip: React.FC = ({ tips, children, offset: gap = 8, - maxWidth = 280, + maxWidth, + minWidth, open: controlledOpen, onOpenChange, arrow = false, portalTarget, header, + delay = 0, + containerStyle={}, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); const triggerRef = useRef(null); const tooltipRef = useRef(null); - const hoverTimeoutRef = useRef | null>(null); + const openTimeoutRef = useRef | null>(null); + + const clearTimers = () => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + }; // Get sidebar context for tooltip positioning const sidebarContext = sidebarTooltip ? useSidebarContext() : null; @@ -53,6 +66,7 @@ export const Tooltip: React.FC = ({ const open = isControlled ? controlledOpen : internalOpen; const handleOpenChange = (newOpen: boolean) => { + clearTimers(); if (isControlled) { onOpenChange?.(newOpen); } else { @@ -63,6 +77,7 @@ export const Tooltip: React.FC = ({ if (!newOpen) { setIsPinned(false); } + }; const handleTooltipClick = (e: React.MouseEvent) => { @@ -97,6 +112,13 @@ export const Tooltip: React.FC = ({ } }, [isPinned]); + useEffect(() => { + return () => { + clearTimers(); + }; + }, []); + + const getArrowClass = () => { // No arrow for sidebar tooltips if (sidebarTooltip) return null; @@ -118,8 +140,8 @@ export const Tooltip: React.FC = ({ ''; }; - // Only show tooltip when position is ready and correct - const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true); + // Always mount when open so we can measure; hide until positioned to avoid flash + const shouldShowTooltip = open; const tooltipElement = shouldShowTooltip ? (
= ({ position: 'fixed', top: coords.top, left: coords.left, - maxWidth, + width: (maxWidth !== undefined ? maxWidth : '25rem'), + minWidth: minWidth, zIndex: 9999, - visibility: 'visible', - opacity: 1, + visibility: positionReady ? 'visible' : 'hidden', + opacity: positionReady ? 1 : 0, color: 'var(--text-primary)', + ...containerStyle, }} className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} onClick={handleTooltipClick} @@ -176,27 +200,23 @@ export const Tooltip: React.FC = ({ ) : null; const handleMouseEnter = (e: React.MouseEvent) => { - // Clear any existing timeout - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - - // Only show on hover if not pinned + clearTimers(); if (!isPinned) { - handleOpenChange(true); + const effectiveDelay = Math.max(0, delay || 0); + openTimeoutRef.current = setTimeout(() => { + handleOpenChange(true); + }, effectiveDelay); } (children.props as any)?.onMouseEnter?.(e); }; const handleMouseLeave = (e: React.MouseEvent) => { - // Only hide on mouse leave if not pinned + clearTimers(); + openTimeoutRef.current = null; + if (!isPinned) { - // Add a small delay to prevent flickering - hoverTimeoutRef.current = setTimeout(() => { - handleOpenChange(false); - }, 100); + handleOpenChange(false); } (children.props as any)?.onMouseLeave?.(e); @@ -207,6 +227,7 @@ export const Tooltip: React.FC = ({ if (open) { setIsPinned(!isPinned); } else { + clearTimers(); handleOpenChange(true); setIsPinned(true); } diff --git a/frontend/src/components/shared/fitText/FitText.README.md b/frontend/src/components/shared/fitText/FitText.README.md new file mode 100644 index 000000000..827ab5d8f --- /dev/null +++ b/frontend/src/components/shared/fitText/FitText.README.md @@ -0,0 +1,112 @@ +# FitText Component + +Adaptive text component that automatically scales font size down so the content fits within its container, with optional multi-line clamping. Built with a small hook wrapper around ResizeObserver and MutationObserver for reliable, responsive fitting. + +## Features + +- 📏 Auto-fit text to available width (and optional line count) +- 🧵 Single-line and multi-line support with clamping and ellipsis +- 🔁 React hook + component interface +- ⚡ Efficient: observers and rAF, minimal layout thrash +- 🎛️ Configurable min scale, max font size, and step size + +## Behavior + +- On mount and whenever size/text changes, the font is reduced (never increased) until the text fits the given constraints. +- If `lines` is provided, height is constrained to an estimated maximum based on computed line-height. + +## Basic Usage + +```tsx +import FitText from '@/components/shared/FitText'; + +export function CardTitle({ title }: { title: string }) { + return ( + + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `text` | `string` | — | The string to render and fit | +| `fontSize` | `number` | computed | Maximum starting font size in rem (e.g., 1.2, 0.9) | +| `minimumFontScale` | `number` | `0.8` | Smallest scale relative to the max (0..1) | +| `lines` | `number` | `1` | Maximum number of lines to display and fit | +| `className` | `string` | — | Optional class on the rendered element | +| `style` | `CSSProperties` | — | Inline styles (merged with internal clamp styles) | +| `as` | `'span' | 'div'` | `'span'` | HTML tag to render | + +Notes: +- For multi-line, the component applies WebKit line clamping (with reasonable fallbacks) and fits within that height. +- The component only scales down; if the content already fits, it keeps the starting size. + +## Examples + +### Single-line title (default) + +```tsx + +``` + +### Multi-line label (up to 3 lines) + +```tsx + +``` + +### Explicit starting size + +```tsx + +``` + +### Render as a div + +```tsx + +``` + +## Hook Usage (Advanced) + +If you need to control your own element, you can use the underlying hook directly. + +```tsx +import React, { useRef } from 'react'; +import { useAdjustFontSizeToFit } from '@/components/shared/fitText/textFit'; + +export function CustomFit() { + const ref = useRef(null); + + useAdjustFontSizeToFit(ref as any, { + maxFontSizePx: 20, + minFontScale: 0.6, + maxLines: 2, + singleLine: false, + }); + + return ( + + Arbitrary text that will scale to fit two lines. + + ); +} +``` + +## Tips + +- For predictable measurements, ensure the container has a fixed width (or stable layout) when fitting occurs. +- Avoid animating width while fitting; update after animation completes for best results. +- When you need more control of typography, pass `fontSize` to define the starting ceiling. +- **Important**: The `fontSize` prop expects `rem` values (e.g., 1.2, 0.9) to ensure text scales with global font size changes. + + diff --git a/frontend/src/components/shared/fitText/textFit.ts b/frontend/src/components/shared/fitText/textFit.ts new file mode 100644 index 000000000..37da2dc78 --- /dev/null +++ b/frontend/src/components/shared/fitText/textFit.ts @@ -0,0 +1,102 @@ +import { RefObject, useEffect } from 'react'; + +export type AdjustFontSizeOptions = { + /** Max font size to start from. Defaults to the element's computed font size. */ + maxFontSizePx?: number; + /** Minimum scale relative to max size (like React Native's minimumFontScale). Default 0.7 */ + minFontScale?: number; + /** Step as a fraction of max size used while shrinking. Default 0.05 (5%). */ + stepScale?: number; + /** Limit the number of lines to fit. If omitted, only width is considered for multi-line. */ + maxLines?: number; + /** If true, force single-line fitting (uses nowrap). Default false. */ + singleLine?: boolean; +}; + +/** + * Imperative util: progressively reduces font-size until content fits within the element + * (width and optional line count). Returns a cleanup that disconnects observers. + */ +export function adjustFontSizeToFit( + element: HTMLElement, + options: AdjustFontSizeOptions = {} +): () => void { + if (!element) return () => {}; + + const computed = window.getComputedStyle(element); + const baseFontPx = options.maxFontSizePx ?? parseFloat(computed.fontSize || '16'); + const minScale = Math.max(0.1, options.minFontScale ?? 0.7); + const stepScale = Math.max(0.005, options.stepScale ?? 0.05); + const singleLine = options.singleLine ?? false; + const maxLines = options.maxLines; + + // Ensure measurement is consistent + if (singleLine) { + element.style.whiteSpace = 'nowrap'; + } + // Never split within words; only allow natural breaks (spaces) or explicit soft breaks + element.style.wordBreak = 'keep-all'; + element.style.overflowWrap = 'normal'; + // Disable automatic hyphenation to avoid mid-word breaks; use only manual opportunities + element.style.setProperty('hyphens', 'manual'); + element.style.overflow = 'visible'; + + const minFontPx = baseFontPx * minScale; + const stepPx = Math.max(0.5, baseFontPx * stepScale); + + const fit = () => { + // Reset to largest before measuring + element.style.fontSize = `${baseFontPx}px`; + + // Calculate target height threshold for line limit + let maxHeight = Number.POSITIVE_INFINITY; + if (typeof maxLines === 'number' && maxLines > 0) { + const cs = window.getComputedStyle(element); + const lineHeight = parseFloat(cs.lineHeight) || baseFontPx * 1.2; + maxHeight = lineHeight * maxLines + 0.1; // small epsilon + } + + let current = baseFontPx; + // Guard against excessive loops + let iterations = 0; + while (iterations < 200) { + const fitsWidth = element.scrollWidth <= element.clientWidth + 1; // tolerance + const fitsHeight = element.scrollHeight <= maxHeight + 1; + const fits = fitsWidth && fitsHeight; + if (fits || current <= minFontPx) break; + current = Math.max(minFontPx, current - stepPx); + element.style.fontSize = `${current}px`; + iterations += 1; + } + }; + + // Defer to next frame to ensure layout is ready + const raf = requestAnimationFrame(fit); + + const ro = new ResizeObserver(() => fit()); + ro.observe(element); + if (element.parentElement) ro.observe(element.parentElement); + + const mo = new MutationObserver(() => fit()); + mo.observe(element, { characterData: true, childList: true, subtree: true }); + + return () => { + cancelAnimationFrame(raf); + try { ro.disconnect(); } catch {} + try { mo.disconnect(); } catch {} + }; +} + +/** React hook wrapper for convenience */ +export function useAdjustFontSizeToFit( + ref: RefObject, + options: AdjustFontSizeOptions = {} +) { + useEffect(() => { + if (!ref.current) return; + const cleanup = adjustFontSizeToFit(ref.current, options); + return cleanup; + }, [ref, options.maxFontSizePx, options.minFontScale, options.stepScale, options.maxLines, options.singleLine]); +} + + diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx new file mode 100644 index 000000000..8362c8224 --- /dev/null +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -0,0 +1,188 @@ +/** + * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar + * + * When a user selects a tool from the All Tools list, this component displays the tool's + * icon and name at the top of the navigation bar. It provides a quick way to see which + * tool is currently active and offers a back button to return to the All Tools list. + * + * Features: + * - Shows tool icon and name when a tool is selected + * - Hover to reveal back arrow for returning to All Tools + * - Smooth slide-down/slide-up animations + * - Only appears for tools that don't have dedicated nav buttons (read, sign, automate) + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { ActionIcon } from '@mantine/core'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext'; +import FitText from '../FitText'; +import { Tooltip } from '../Tooltip'; + +interface ActiveToolButtonProps { + activeButton: string; + setActiveButton: (id: string) => void; +} + +const NAV_IDS = ['read', 'sign', 'automate']; + +const ActiveToolButton: React.FC = ({ activeButton, setActiveButton }) => { + const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); + + // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) + const indicatorShouldShow = Boolean( + selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey) + ); + + // Local animation and hover state + const [indicatorTool, setIndicatorTool] = useState(null); + const [indicatorVisible, setIndicatorVisible] = useState(false); + const [replayAnim, setReplayAnim] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [isBackHover, setIsBackHover] = useState(false); + const prevKeyRef = useRef(null); + const collapseTimeoutRef = useRef(null); + const animTimeoutRef = useRef(null); + const replayRafRef = useRef(null); + + const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey }; + + const clearTimers = () => { + if (collapseTimeoutRef.current) { + window.clearTimeout(collapseTimeoutRef.current); + collapseTimeoutRef.current = null; + } + if (animTimeoutRef.current) { + window.clearTimeout(animTimeoutRef.current); + animTimeoutRef.current = null; + } + }; + + const playGrowDown = () => { + clearTimers(); + setIndicatorTool(selectedTool); + setIndicatorVisible(true); + // Force a replay even if the class is already applied + setReplayAnim(false); + if (replayRafRef.current) { + cancelAnimationFrame(replayRafRef.current); + replayRafRef.current = null; + } + replayRafRef.current = requestAnimationFrame(() => { + setReplayAnim(true); + }); + setIsAnimating(true); + prevKeyRef.current = (selectedToolKey as string) || null; + animTimeoutRef.current = window.setTimeout(() => { + setReplayAnim(false); + setIsAnimating(false); + animTimeoutRef.current = null; + }, 500); + } + + const firstShow = () => { + clearTimers(); + setIndicatorTool(selectedTool); + setIndicatorVisible(true); + setIsAnimating(true); + prevKeyRef.current = (selectedToolKey as string) || null; + animTimeoutRef.current = window.setTimeout(() => { + setIsAnimating(false); + animTimeoutRef.current = null; + }, 500); + } + + const triggerCollapse = () => { + clearTimers(); + setIndicatorVisible(false); + setIsAnimating(true); + collapseTimeoutRef.current = window.setTimeout(() => { + setIndicatorTool(null); + prevKeyRef.current = null; + setIsAnimating(false); + collapseTimeoutRef.current = null; + }, 500); // match CSS transition duration + } + + useEffect(() => { + if (indicatorShouldShow) { + clearTimers(); + if (!indicatorVisible) { + firstShow(); + return; + } + if (!indicatorTool) { + firstShow(); + } else if (isSwitchingToNewTool()) { + playGrowDown(); + } else { + // keep reference in sync + prevKeyRef.current = (selectedToolKey as string) || null; + } + } else if (indicatorTool || indicatorVisible) { + triggerCollapse(); + } + }, [indicatorShouldShow, selectedTool, selectedToolKey]); + + useEffect(() => { + return () => { + clearTimers(); + if (replayRafRef.current) { + cancelAnimationFrame(replayRafRef.current); + replayRafRef.current = null; + } + }; + }, []); + + return ( + <> +
+ {indicatorTool && ( +
+
+ + setIsBackHover(true)} + onMouseLeave={() => setIsBackHover(false)} + onClick={() => { + setActiveButton('tools'); + handleBackToTools(); + }} + aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name} + style={{ + backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)', + color: isBackHover ? '#fff' : 'var(--icon-tools-color)', + border: 'none', + borderRadius: '8px', + cursor: 'pointer' + }} + > + + {isBackHover ? ( + + ) : ( + indicatorTool.icon + )} + + + + +
+
+ )} +
+ + ); +}; + +export default ActiveToolButton; + + diff --git a/frontend/src/components/shared/QuickAccessBar.css b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css similarity index 61% rename from frontend/src/components/shared/QuickAccessBar.css rename to frontend/src/components/shared/quickAccessBar/QuickAccessBar.css index b1d22fcc3..05f226417 100644 --- a/frontend/src/components/shared/QuickAccessBar.css +++ b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css @@ -69,6 +69,8 @@ font-size: 0.75rem; text-rendering: optimizeLegibility; font-synthesis: none; + text-align: center; + display: block; } .all-tools-text.active { @@ -111,6 +113,22 @@ font-size: 0.75rem; text-rendering: optimizeLegibility; font-synthesis: none; + text-align: center; + display: block; +} + +/* Allow wrapping under the active top indicator; constrain to two lines */ +.current-tool-label { + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; /* show up to two lines */ + line-clamp: 2; + -webkit-box-orient: vertical; + word-break: keep-all; + overflow-wrap: normal; + hyphens: manual; } .button-text.active { @@ -176,4 +194,71 @@ .quick-access-bar { scrollbar-width: auto; scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +/* Animated current tool indicator that slides in from the top and pushes content down */ +/* Container grows down so it pushes items below during animation */ +.current-tool-slot { + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 450ms ease-out, opacity 300ms ease-out; +} + +.current-tool-enter { + animation: currentToolGrowDown 450ms ease-out; +} + +.current-tool-slot.visible { + max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */ + opacity: 1; + border-bottom: 1px solid var(--color-gray-300); + padding-bottom: 0.75rem; /* push border down for spacing */ + margin-bottom: 1rem; +} + +/* Replay the grow-down animation when switching tools while visible */ +.current-tool-slot.replay .current-tool-content { + animation: currentToolGrowDown 450ms ease-out; +} + +/* Also animate the container itself when replaying so it "pushes down" again */ +.current-tool-slot.replay { + animation: currentToolGrowDown 450ms ease-out; +} + +@keyframes currentToolGrowDown { + 0% { + max-height: 0; + opacity: 0; + } + 100% { + max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */ + opacity: 1; + } +} + +/* Divider that animates growing from top */ +.current-tool-divider { + width: 3.75rem; + border-color: var(--color-gray-300); + margin: 0.5rem auto 0.5rem auto; + transform-origin: top; + animation: dividerGrowDown 350ms ease-out; + animation-fill-mode: both; +} + +@keyframes dividerGrowDown { + 0% { + transform: scaleY(0); + opacity: 0; + margin-top: 0; + margin-bottom: 0; + } + 100% { + transform: scaleY(1); + opacity: 1; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } } \ No newline at end of file diff --git a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.ts b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.ts new file mode 100644 index 000000000..efadc4a72 --- /dev/null +++ b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.ts @@ -0,0 +1,83 @@ +import { ButtonConfig } from '../../../types/sidebar'; + +// Border radius constants +export const ROUND_BORDER_RADIUS = '0.5rem'; + +/** + * Check if a navigation button is currently active + */ +export const isNavButtonActive = ( + config: ButtonConfig, + activeButton: string, + isFilesModalOpen: boolean, + configModalOpen: boolean, + selectedToolKey?: string | null, + leftPanelView?: 'toolPicker' | 'toolContent' +): boolean => { + const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id; + const isActiveByContext = + config.type === 'navigation' && + leftPanelView === 'toolContent' && + selectedToolKey === config.id; + const isActiveByModal = + (config.type === 'modal' && config.id === 'files' && isFilesModalOpen) || + (config.type === 'modal' && config.id === 'config' && configModalOpen); + + return isActiveByLocalState || isActiveByContext || isActiveByModal; +}; + +/** + * Get button styles based on active state + */ +export const getNavButtonStyle = ( + config: ButtonConfig, + activeButton: string, + isFilesModalOpen: boolean, + configModalOpen: boolean, + selectedToolKey?: string | null, + leftPanelView?: 'toolPicker' | 'toolContent' +) => { + const isActive = isNavButtonActive( + config, + activeButton, + isFilesModalOpen, + configModalOpen, + selectedToolKey, + leftPanelView + ); + + if (isActive) { + return { + backgroundColor: `var(--icon-${config.id}-bg)`, + color: `var(--icon-${config.id}-color)`, + border: 'none', + borderRadius: ROUND_BORDER_RADIUS, + }; + } + + // Inactive state for all buttons + return { + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: ROUND_BORDER_RADIUS, + }; +}; + + +/** + * Determine the active nav button based on current tool state and registry + */ +export const getActiveNavButton = ( + selectedToolKey: string | null, + readerMode: boolean +): string => { + // Reader mode takes precedence and should highlight the Read nav item + if (readerMode) { + return 'read'; + } + // If a tool is selected, highlight it immediately even if the panel view + // transition to 'toolContent' has not completed yet. This prevents a brief + // period of no-highlight during rapid navigation. + return selectedToolKey ? selectedToolKey : 'tools'; +}; diff --git a/frontend/src/components/shared/textInput/TextInput.module.css b/frontend/src/components/shared/textInput/TextInput.module.css new file mode 100644 index 000000000..7ccf30063 --- /dev/null +++ b/frontend/src/components/shared/textInput/TextInput.module.css @@ -0,0 +1,73 @@ +.container { + position: relative; + display: flex; + align-items: center; +} + +.icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + z-index: 1; + font-size: 16px; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.input { + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 6px; + font-size: 14px; + outline: none; + box-shadow: none; + transition: box-shadow 0.2s ease; +} + +.input::placeholder { + color: var(--search-text-and-icon-color); + opacity: 1; +} + +.input:focus { + outline: none; + box-shadow: none; +} + +.input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.input:read-only { + cursor: default; +} + +.clearButton { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: background-color 0.2s ease; +} + +.clearButton:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +[data-mantine-color-scheme="dark"] .clearButton:hover { + background-color: rgba(255, 255, 255, 0.1); +} diff --git a/frontend/src/components/shared/tooltip/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md index df1a977b0..d9b339c14 100644 --- a/frontend/src/components/shared/tooltip/Tooltip.README.md +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -14,6 +14,7 @@ A flexible, accessible tooltip component that supports both regular positioning - 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin - 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content - 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior +- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay` ## Behavior @@ -61,6 +62,7 @@ function MyComponent() { | `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element | | `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into | | `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo | +| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately | ### TooltipTip Interface @@ -106,6 +108,17 @@ interface TooltipTip { ``` +### Optional Hover Delay + +```tsx +// Show after a 1s hover + + +// Custom long-hover duration (2 seconds) + +``` + + ### Custom JSX Content ```tsx @@ -214,6 +227,12 @@ Links automatically get proper styling with hover states and open in new tabs wh - Calculates optimal position based on trigger element's `getBoundingClientRect()` - **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped +## Timing Details + +- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned). +- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps. +- Only one tooltip can be open at a time; hovering a new trigger closes others immediately. + ### Sidebar Tooltips - When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar - Vertical positioning follows the trigger but clamps to viewport diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css index 209f0dcbd..46902c04b 100644 --- a/frontend/src/components/shared/tooltip/Tooltip.module.css +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -10,7 +10,6 @@ pointer-events: auto; z-index: 9999; transition: opacity 100ms ease-out, transform 100ms ease-out; - min-width: 25rem; max-width: 50vh; max-height: 80vh; color: var(--text-primary); diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx new file mode 100644 index 000000000..949bd2f64 --- /dev/null +++ b/frontend/src/components/tools/SearchResults.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react'; +import { Box, Stack, Text } from '@mantine/core'; +import { ToolRegistryEntry } from '../../data/toolsTaxonomy'; +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'; + +interface SearchResultsProps { + filteredTools: [string, ToolRegistryEntry][]; + onSelect: (id: string) => void; +} + +const SearchResults: React.FC = ({ filteredTools, onSelect }) => { + const { t } = useTranslation(); + const { searchGroups } = useToolSections(filteredTools); + + if (searchGroups.length === 0) { + return ; + } + + return ( + + {searchGroups.map(group => ( + + + + {group.tools.map(({ id, tool }) => ( + + ))} + + + ))} + {/* global spacer to allow scrolling past last row in search mode */} +
+ + ); +}; + +export default SearchResults; + + diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index 67617a657..a846978d6 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { TextInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; -import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import ToolPicker from './ToolPicker'; +import SearchResults from './SearchResults'; import ToolRenderer from './ToolRenderer'; +import ToolSearch from './toolPicker/ToolSearch'; import { useSidebarContext } from "../../contexts/SidebarContext"; import rainbowStyles from '../../styles/rainbow.module.css'; @@ -23,12 +24,13 @@ export default function ToolPanel() { isPanelVisible, searchQuery, filteredTools, + toolRegistry, setSearchQuery, handleBackToTools - } = useToolPanelState(); + } = useToolWorkflow(); - const { selectedToolKey, handleToolSelect } = useToolSelection(); - const { setPreviewFile } = useWorkbenchState(); + const { selectedToolKey, handleToolSelect } = useToolWorkflow(); + const { setPreviewFile } = useToolWorkflow(); return (
{/* Search Bar - Always visible at the top */} -
- + setSearchQuery(e.currentTarget.value)} - autoComplete="off" - size="sm" + onChange={setSearchQuery} + toolRegistry={toolRegistry} + mode="filter" />
- {leftPanelView === 'toolPicker' ? ( + {searchQuery.trim().length > 0 ? ( + // Searching view (replaces both picker and content) +
+
+ +
+
+ ) : leftPanelView === 'toolPicker' ? ( // Tool Picker View
0)} />
) : ( @@ -76,10 +94,12 @@ export default function ToolPanel() {
{/* Tool content */}
- + {selectedToolKey && ( + + )}
)} diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index d392f21b6..9dec8f45a 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,43 +1,230 @@ -import React from "react"; -import { Box, Text, Stack, Button } from "@mantine/core"; +import React, { useMemo, useRef, useLayoutEffect, useState } from "react"; +import { Box, Text, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ToolRegistry } from "../../types/tool"; +import { ToolRegistryEntry } from "../../data/toolsTaxonomy"; +import ToolButton from "./toolPicker/ToolButton"; +import "./toolPicker/ToolPicker.css"; +import { useToolSections } from "../../hooks/useToolSections"; +import SubcategoryHeader from "./shared/SubcategoryHeader"; +import NoToolsFound from "./shared/NoToolsFound"; interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - /** Pre-filtered tools to display */ - filteredTools: [string, ToolRegistry[string]][]; + filteredTools: [string, ToolRegistryEntry][]; + isSearching?: boolean; } -const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => { +// Helper function to render tool buttons for a subcategory +const renderToolButtons = ( + subcategory: any, + selectedToolKey: string | null, + onSelect: (id: string) => void, + showSubcategoryHeader: boolean = true +) => ( + + {showSubcategoryHeader && ( + + )} + + {subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => ( + + ))} + + +); + +const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => { const { t } = useTranslation(); + const [quickHeaderHeight, setQuickHeaderHeight] = useState(0); + const [allHeaderHeight, setAllHeaderHeight] = useState(0); + + const scrollableRef = useRef(null); + const quickHeaderRef = useRef(null); + const allHeaderRef = useRef(null); + const quickAccessRef = useRef(null); + const allToolsRef = useRef(null); + + // On resize adjust headers height to offset height + useLayoutEffect(() => { + const update = () => { + if (quickHeaderRef.current) { + setQuickHeaderHeight(quickHeaderRef.current.offsetHeight); + } + if (allHeaderRef.current) { + setAllHeaderHeight(allHeaderRef.current.offsetHeight); + } + }; + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + + const { sections: visibleSections } = useToolSections(filteredTools); + + const quickSection = useMemo( + () => visibleSections.find(s => (s as any).key === 'quick'), + [visibleSections] + ); + const allSection = useMemo( + () => visibleSections.find(s => (s as any).key === 'all'), + [visibleSections] + ); + + const scrollTo = (ref: React.RefObject) => { + const container = scrollableRef.current; + const target = ref.current; + if (container && target) { + const stackedOffset = ref === allToolsRef + ? (quickHeaderHeight + allHeaderHeight) + : quickHeaderHeight; + const top = target.offsetTop - container.offsetTop - (stackedOffset || 0); + container.scrollTo({ + top: Math.max(0, top), + behavior: "smooth" + }); + } + }; + + // Build flat list by subcategory for search mode + const { searchGroups } = useToolSections(isSearching ? filteredTools : []); return ( - - - {filteredTools.length === 0 ? ( - - {t("toolPicker.noToolsFound", "No tools found")} - + + + {isSearching ? ( + + {searchGroups.length === 0 ? ( + + ) : ( + searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect)) + )} + ) : ( - filteredTools.map(([id, { icon, name }]) => ( - - )) + {t("toolPicker.quickAccess", "QUICK ACCESS")} + + {quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
+ + + + {quickSection?.subcategories.map(sc => + renderToolButtons(sc, selectedToolKey, onSelect, false) + )} + + + )} - + + {allSection && ( + <> +
scrollTo(allToolsRef)} + > + {t("toolPicker.allTools", "ALL TOOLS")} + + {allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
+ + + + {allSection?.subcategories.map(sc => + renderToolButtons(sc, selectedToolKey, onSelect, true) + )} + + + + )} + + {!quickSection && !allSection && } + + {/* bottom spacer to allow scrolling past the last row */} +
+ + )} + ); }; diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index 493470935..4a3146613 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -26,7 +26,7 @@ const ToolRenderer = ({ // Wrap lazy-loaded component with Suspense return ( - }> + }> { - const endpoints = new Set(); - Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => { - Object.values(toEndpoints).forEach(endpoint => { - endpoints.add(endpoint); - }); - }); - return Array.from(endpoints); - }, []); + const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []); const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); diff --git a/frontend/src/components/tools/shared/NoToolsFound.tsx b/frontend/src/components/tools/shared/NoToolsFound.tsx new file mode 100644 index 000000000..1b60f0cb7 --- /dev/null +++ b/frontend/src/components/tools/shared/NoToolsFound.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +const NoToolsFound: React.FC = () => { + const { t } = useTranslation(); + + return ( + + {t("toolPicker.noToolsFound", "No tools found")} + + ); +}; + +export default NoToolsFound; diff --git a/frontend/src/components/tools/shared/SubcategoryHeader.tsx b/frontend/src/components/tools/shared/SubcategoryHeader.tsx new file mode 100644 index 000000000..b403ebae8 --- /dev/null +++ b/frontend/src/components/tools/shared/SubcategoryHeader.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface SubcategoryHeaderProps { + label: string; + mt?: string | number; + mb?: string | number; +} + +export const SubcategoryHeader: React.FC = ({ label, mt = '1rem', mb = '0.25rem' }) => ( +
+
+ {label} +
+
+); + +export default SubcategoryHeader; diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx new file mode 100644 index 000000000..af668a1fa --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Button } from "@mantine/core"; +import { Tooltip } from "../../shared/Tooltip"; +import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import FitText from "../../shared/FitText"; + +interface ToolButtonProps { + id: string; + tool: ToolRegistryEntry; + isSelected: boolean; + onSelect: (id: string) => void; +} + +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { + const handleClick = (id: string) => { + if (tool.link) { + // Open external link in new tab + window.open(tool.link, '_blank', 'noopener,noreferrer'); + return; + } + // Normal tool selection + onSelect(id); + }; + + return ( + + + + ); +}; + +export default ToolButton; \ No newline at end of file diff --git a/frontend/src/components/tools/toolPicker/ToolPicker.css b/frontend/src/components/tools/toolPicker/ToolPicker.css new file mode 100644 index 000000000..815aee489 --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolPicker.css @@ -0,0 +1,79 @@ +.tool-picker-scrollable { + overflow-y: auto !important; + overflow-x: hidden !important; + scrollbar-width: thin; + scrollbar-color: var(--mantine-color-gray-4) transparent; +} + +.tool-picker-scrollable::-webkit-scrollbar { + width: 0.375rem; +} + +.tool-picker-scrollable::-webkit-scrollbar-track { + background: transparent; +} + +.tool-picker-scrollable::-webkit-scrollbar-thumb { + background-color: var(--mantine-color-gray-4); + border-radius: 0.1875rem; +} + +.tool-picker-scrollable::-webkit-scrollbar-thumb:hover { + background-color: var(--mantine-color-gray-5); +} + +.search-input { + margin: 1rem; +} + +.tool-subcategory-title { + text-transform: uppercase; + padding-bottom: 0.5rem; + font-size: 0.75rem; + color: var(--tool-subcategory-text-color); + /* Align the text with tool labels to account for icon gutter */ + padding-left: 1rem; +} + +/* New row-style subcategory header with rule */ +.tool-subcategory-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.tool-subcategory-row-title { + text-transform: uppercase; + font-weight: 600; + font-size: 0.75rem; + color: var(--tool-subcategory-text-color); + white-space: nowrap; + overflow: visible; +} + +.tool-subcategory-row-rule { + height: 1px; + background-color: var(--tool-subcategory-rule-color); + flex: 1 1 auto; +} + +/* Compact tool buttons */ +.tool-button { + font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */ + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.tool-button .mantine-Button-label { + font-size: .85rem; +} + +.tool-button-icon { + font-size: 1rem; + line-height: 1; +} + +.search-input-container { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} \ No newline at end of file diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx new file mode 100644 index 000000000..f01a9f87d --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -0,0 +1,129 @@ +import React, { useState, useRef, useEffect, useMemo } from "react"; +import { Stack, Button, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import { TextInput } from "../../shared/TextInput"; +import './ToolPicker.css'; + +interface ToolSearchProps { + value: string; + onChange: (value: string) => void; + toolRegistry: Readonly>; + onToolSelect?: (toolId: string) => void; + mode: 'filter' | 'dropdown'; + selectedToolKey?: string | null; +} + +const ToolSearch = ({ + value, + onChange, + toolRegistry, + onToolSelect, + mode = 'filter', + selectedToolKey +}: ToolSearchProps) => { + const { t } = useTranslation(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const searchRef = useRef(null); + + const filteredTools = useMemo(() => { + if (!value.trim()) return []; + return Object.entries(toolRegistry) + .filter(([id, tool]) => { + if (mode === 'dropdown' && id === selectedToolKey) return false; + return tool.name.toLowerCase().includes(value.toLowerCase()) || + tool.description.toLowerCase().includes(value.toLowerCase()); + }) + .slice(0, 6) + .map(([id, tool]) => ({ id, tool })); + }, [value, toolRegistry, mode, selectedToolKey]); + + const handleSearchChange = (searchValue: string) => { + onChange(searchValue); + if (mode === 'dropdown') { + setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const searchInput = ( +
+ search} + autoComplete="off" + /> +
+ ); + + if (mode === 'filter') { + return searchInput; + } + + return ( +
+ {searchInput} + {dropdownOpen && filteredTools.length > 0 && ( +
+ + {filteredTools.map(({ id, tool }) => ( + + ))} + +
+ )} +
+ ); +}; + +export default ToolSearch; \ No newline at end of file diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index d75f2ff5e..637906a22 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -5,9 +5,8 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react'; import { useToolManagement } from '../hooks/useToolManagement'; -import { useToolUrlRouting } from '../hooks/useToolUrlRouting'; -import { Tool } from '../types/tool'; import { PageEditorFunctions } from '../types/pageEditor'; +import { ToolRegistryEntry } from '../data/toolsTaxonomy'; // State interface interface ToolWorkflowState { @@ -70,9 +69,9 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio interface ToolWorkflowContextValue extends ToolWorkflowState { // Tool management (from hook) selectedToolKey: string | null; - selectedTool: Tool | null; + selectedTool: ToolRegistryEntry | null; toolRegistry: any; // From useToolManagement - + // UI Actions setSidebarsVisible: (visible: boolean) => void; setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void; @@ -91,7 +90,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { handleReaderToggle: () => void; // Computed values - filteredTools: [string, any][]; // Filtered by search + filteredTools: [string, ToolRegistryEntry][]; // Filtered by search isPanelVisible: boolean; } @@ -143,11 +142,22 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro // Workflow actions (compound actions that coordinate multiple state changes) const handleToolSelect = useCallback((toolId: string) => { + // Special-case: if tool is a dedicated reader tool, enter reader mode and do not go to toolContent + if (toolId === 'read' || toolId === 'view-pdf') { + setReaderMode(true); + setLeftPanelView('toolPicker'); + clearToolSelection(); + setSearchQuery(''); + return; + } + selectTool(toolId); onViewChange?.('fileEditor'); setLeftPanelView('toolContent'); setReaderMode(false); - }, [selectTool, onViewChange, setLeftPanelView, setReaderMode]); + // Clear search so the tool content becomes visible immediately + setSearchQuery(''); + }, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery, clearToolSelection]); const handleBackToTools = useCallback(() => { setLeftPanelView('toolPicker'); @@ -159,20 +169,6 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro setReaderMode(true); }, [setReaderMode]); - // URL routing functionality - const { getToolUrlSlug, getToolKeyFromSlug } = useToolUrlRouting({ - selectedToolKey, - toolRegistry, - selectTool, - clearToolSelection, - // During initial load, we want the full UI side-effects (like before): - onInitSelect: handleToolSelect, - // For back/forward nav, keep it lightweight like before (selection only): - onPopStateSelect: selectTool, - // If your app serves under a subpath, provide basePath here (e.g., '/app') - // basePath: '' - }); - // Filter tools based on search query const filteredTools = useMemo(() => { if (!toolRegistry) return []; @@ -228,9 +224,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue { throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); } return context; -} - -// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly) -export const useToolSelection = useToolWorkflow; -export const useToolPanelState = useToolWorkflow; -export const useWorkbenchState = useToolWorkflow; +} \ No newline at end of file diff --git a/frontend/src/data/toolsTaxonomy.ts b/frontend/src/data/toolsTaxonomy.ts new file mode 100644 index 000000000..cb6b18c0d --- /dev/null +++ b/frontend/src/data/toolsTaxonomy.ts @@ -0,0 +1,102 @@ +import { type TFunction } from 'i18next'; +import React from 'react'; + +export enum SubcategoryId { + SIGNING = 'signing', + DOCUMENT_SECURITY = 'documentSecurity', + VERIFICATION = 'verification', + DOCUMENT_REVIEW = 'documentReview', + PAGE_FORMATTING = 'pageFormatting', + EXTRACTION = 'extraction', + REMOVAL = 'removal', + AUTOMATION = 'automation', + GENERAL = 'general', + ADVANCED_FORMATTING = 'advancedFormatting', + DEVELOPER_TOOLS = 'developerTools' +} + +export enum ToolCategory { + STANDARD_TOOLS = 'Standard Tools', + ADVANCED_TOOLS = 'Advanced Tools', + RECOMMENDED_TOOLS = 'Recommended Tools' +} + +export type ToolRegistryEntry = { + icon: React.ReactNode; + name: string; + component: React.ComponentType | null; + view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external'; + description: string; + category: ToolCategory; + subcategory: SubcategoryId; + maxFiles?: number; + supportedFormats?: string[]; + endpoints?: string[]; + link?: string; + type?: string; +} + +export type ToolRegistry = Record; + +export const SUBCATEGORY_ORDER: SubcategoryId[] = [ + SubcategoryId.SIGNING, + SubcategoryId.DOCUMENT_SECURITY, + SubcategoryId.VERIFICATION, + SubcategoryId.DOCUMENT_REVIEW, + SubcategoryId.PAGE_FORMATTING, + SubcategoryId.EXTRACTION, + SubcategoryId.REMOVAL, + SubcategoryId.AUTOMATION, + SubcategoryId.GENERAL, + SubcategoryId.ADVANCED_FORMATTING, + SubcategoryId.DEVELOPER_TOOLS, +]; + +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', +}; + +export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF'; + +export const getSubcategoryLabel = (t: TFunction, id: SubcategoryId): string => t(`toolPicker.subcategories.${id}`, id); + + + +export const getAllEndpoints = (registry: ToolRegistry): string[] => { + const lists: string[][] = []; + Object.values(registry).forEach(entry => { + if (entry.endpoints && entry.endpoints.length > 0) { + lists.push(entry.endpoints); + } + }); + return Array.from(new Set(lists.flat())); +}; + +export const getConversionEndpoints = (extensionToEndpoint: Record>): string[] => { + const endpoints = new Set(); + Object.values(extensionToEndpoint).forEach(toEndpoints => { + Object.values(toEndpoints).forEach(endpoint => { + endpoints.add(endpoint); + }); + }); + return Array.from(endpoints); +}; + +export const getAllApplicationEndpoints = ( + registry: ToolRegistry, + extensionToEndpoint?: Record> +): string[] => { + const toolEp = getAllEndpoints(registry); + const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : []; + return Array.from(new Set([...toolEp, ...convEp])); +}; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx new file mode 100644 index 000000000..5f6460217 --- /dev/null +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -0,0 +1,606 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import SplitPdfPanel from "../tools/Split"; +import CompressPdfPanel from "../tools/Compress"; +import OCRPanel from '../tools/OCR'; +import ConvertPanel from '../tools/Convert'; +import Sanitize from '../tools/Sanitize'; +import AddPassword from '../tools/AddPassword'; +import ChangePermissions from '../tools/ChangePermissions'; +import RemovePassword from '../tools/RemovePassword'; +import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy'; +import AddWatermark from '../tools/AddWatermark'; + +// Hook to get the translated tool registry +export function useFlatToolRegistry(): ToolRegistry { + const { t } = useTranslation(); + + return { + // Signing + + "certSign": { + icon: workspace_premium, + name: t("home.certSign.title", "Sign with Certificate"), + component: null, + view: "sign", + description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.SIGNING + }, + "sign": { + icon: signature, + name: t("home.sign.title", "Sign"), + component: null, + view: "sign", + description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.SIGNING + }, + + + // Document Security + + "addPassword": { + icon: password, + name: t("home.addPassword.title", "Add Password"), + component: AddPassword, + view: "security", + description: t("home.addPassword.desc", "Add password protection and restrictions to PDF files"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["add-password"] + }, + "add-watermark": { + icon: branding_watermark, + name: t("home.watermark.title", "Add Watermark"), + component: AddWatermark, + view: "format", + maxFiles: -1, + description: t("home.watermark.desc", "Add a custom watermark to your PDF document."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY, + endpoints: ["add-watermark"] + }, + "add-stamp": { + icon: approval, + name: t("home.AddStampRequest.title", "Add Stamp to PDF"), + component: null, + view: "format", + description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY + }, + "sanitize": { + icon: cleaning_services, + name: t("home.sanitize.title", "Sanitize"), + component: Sanitize, + view: "security", + maxFiles: -1, + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY, + description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"), + endpoints: ["sanitize-pdf"] + }, + "flatten": { + icon: layers_clear, + name: t("home.flatten.title", "Flatten"), + component: null, + view: "format", + description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY + }, + "unlock-pdf-forms": { + icon: preview_off, + name: t("home.unlockPDFForms.title", "Unlock PDF Forms"), + component: null, + view: "security", + description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY + }, + "manage-certificates": { + icon: license, + name: t("home.manageCertificates.title", "Manage Certificates"), + component: null, + view: "security", + description: t("home.manageCertificates.desc", "Import, export, or delete digital certificate files used for signing PDFs."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY + }, + "change-permissions": { + icon: lock, + name: t("home.changePermissions.title", "Change Permissions"), + component: ChangePermissions, + view: "security", + description: t("home.changePermissions.desc", "Change document restrictions and permissions"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["add-password"] + }, + // Verification + + "get-all-info-on-pdf": { + icon: fact_check, + name: t("home.getPdfInfo.title", "Get ALL Info on PDF"), + component: null, + view: "extract", + description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.VERIFICATION + }, + "validate-pdf-signature": { + icon: verified, + name: t("home.validateSignature.title", "Validate PDF Signature"), + component: null, + view: "security", + description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.VERIFICATION + }, + + + // Document Review + + "read": { + icon: article, + name: t("home.read.title", "Read"), + component: null, + view: "view", + description: t("home.read.desc", "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_REVIEW + }, + "change-metadata": { + icon: assignment, + name: t("home.changeMetadata.title", "Change Metadata"), + component: null, + view: "format", + description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.DOCUMENT_REVIEW + }, + // Page Formatting + + "cropPdf": { + icon: crop, + name: t("home.crop.title", "Crop PDF"), + component: null, + view: "format", + description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "rotate": { + icon: rotate_right, + name: t("home.rotate.title", "Rotate"), + component: null, + view: "format", + description: t("home.rotate.desc", "Easily rotate your PDFs."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "splitPdf": { + icon: content_cut, + name: t("home.split.title", "Split"), + component: SplitPdfPanel, + view: "split", + description: t("home.split.desc", "Split PDFs into multiple documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "reorganize-pages": { + icon: move_down, + name: t("home.reorganizePages.title", "Reorganize Pages"), + component: null, + view: "pageEditor", + description: t("home.reorganizePages.desc", "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "adjust-page-size-scale": { + icon: crop_free, + name: t("home.scalePages.title", "Adjust page size/scale"), + component: null, + view: "format", + description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "add-page-numbers": { + icon: 123, + name: t("home.add-page-numbers.title", "Add Page Numbers"), + component: null, + view: "format", + description: t("home.add-page-numbers.desc", "Add Page numbers throughout a document in a set location"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "multi-page-layout": { + icon: dashboard, + name: t("home.pageLayout.title", "Multi-Page Layout"), + component: null, + view: "format", + description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "single-large-page": { + icon: looks_one, + name: t("home.PdfToSinglePage.title", "PDF to Single Large Page"), + component: null, + view: "format", + description: t("home.PdfToSinglePage.desc", "Merges all PDF pages into one large single page"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING + }, + "add-attachments": { + icon: attachment, + name: t("home.attachments.title", "Add Attachments"), + component: null, + view: "format", + description: t("home.attachments.desc", "Add or remove embedded files (attachments) to/from a PDF"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.PAGE_FORMATTING, + }, + + + // Extraction + + "extract-pages": { + icon: upload, + name: t("home.extractPage.title", "Extract Pages"), + component: null, + view: "extract", + description: t("home.extractPage.desc", "Extract specific pages from a PDF document"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.EXTRACTION + }, + "extract-images": { + icon: filter, + name: t("home.extractImages.title", "Extract Images"), + component: null, + view: "extract", + description: t("home.extractImages.desc", "Extract images from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.EXTRACTION + }, + + + // Removal + + "remove": { + icon: delete, + name: t("home.removePages.title", "Remove Pages"), + component: null, + view: "remove", + description: t("home.removePages.desc", "Remove specific pages from a PDF document"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL + }, + "remove-blank-pages": { + icon: scan_delete, + name: t("home.removeBlanks.title", "Remove Blank Pages"), + component: null, + view: "remove", + description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL + }, + "remove-annotations": { + icon: thread_unread, + name: t("home.removeAnnotations.title", "Remove Annotations"), + component: null, + view: "remove", + description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL + }, + "remove-image": { + icon: remove_selection, + name: t("home.removeImagePdf.title", "Remove Image"), + component: null, + view: "format", + description: t("home.removeImagePdf.desc", "Remove images from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL + }, + "remove-password": { + icon: lock_open_right, + name: t("home.removePassword.title", "Remove Password"), + component: RemovePassword, + view: "security", + description: t("home.removePassword.desc", "Remove password protection from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL, + endpoints: ["remove-password"], + maxFiles: -1, + + }, + "remove-certificate-sign": { + icon: remove_moderator, + name: t("home.removeCertSign.title", "Remove Certificate Signatures"), + component: null, + view: "security", + description: t("home.removeCertSign.desc", "Remove digital signatures from PDF documents"), + category: ToolCategory.STANDARD_TOOLS, + subcategory: SubcategoryId.REMOVAL + }, + + + // Automation + + "automate": { + icon: automation, + name: t("home.automate.title", "Automate"), + component: null, + view: "format", + description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.AUTOMATION + }, + "auto-rename-pdf-file": { + icon: match_word, + name: t("home.auto-rename.title", "Auto Rename PDF File"), + component: null, + view: "format", + description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.AUTOMATION + }, + "auto-split-pages": { + icon: split_scene_right, + name: t("home.autoSplitPDF.title", "Auto Split Pages"), + component: null, + view: "format", + description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.AUTOMATION + }, + "auto-split-by-size-count": { + icon: content_cut, + name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"), + component: null, + view: "format", + description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.AUTOMATION + }, + + + // Advanced Formatting + + "adjust-colors-contrast": { + icon: palette, + name: t("home.adjust-contrast.title", "Adjust Colors/Contrast"), + component: null, + view: "format", + description: t("home.adjust-contrast.desc", "Adjust colors and contrast of PDF documents"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "repair": { + icon: build, + name: t("home.repair.title", "Repair"), + component: null, + view: "format", + description: t("home.repair.desc", "Repair corrupted or damaged PDF files"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "detect-split-scanned-photos": { + icon: scanner, + name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"), + component: null, + view: "format", + description: t("home.ScannerImageSplit.desc", "Detect and split scanned photos into separate pages"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "overlay-pdfs": { + icon: layers, + name: t("home.overlay-pdfs.title", "Overlay PDFs"), + component: null, + view: "format", + description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "replace-and-invert-color": { + icon: format_color_fill, + name: t("home.replaceColorPdf.title", "Replace & Invert Color"), + component: null, + view: "format", + description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "add-image": { + icon: image, + name: t("home.addImage.title", "Add Image"), + component: null, + view: "format", + description: t("home.addImage.desc", "Add images to PDF documents"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "edit-table-of-contents": { + icon: bookmark_add, + name: t("home.editTableOfContents.title", "Edit Table of Contents"), + component: null, + view: "format", + description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + "scanner-effect": { + icon: scanner, + name: t("home.fakeScan.title", "Scanner Effect"), + component: null, + view: "format", + description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.ADVANCED_FORMATTING + }, + + + // Developer Tools + + "show-javascript": { + icon: javascript, + name: t("home.showJS.title", "Show JavaScript"), + component: null, + view: "extract", + description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.DEVELOPER_TOOLS + }, + "dev-api": { + icon: open_in_new, + name: t("home.devApi.title", "API"), + component: null, + view: "external", + description: t("home.devApi.desc", "Link to API documentation"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.DEVELOPER_TOOLS, + link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html" + }, + "dev-folder-scanning": { + icon: open_in_new, + name: t("home.devFolderScanning.title", "Automated Folder Scanning"), + component: null, + view: "external", + description: t("home.devFolderScanning.desc", "Link to automated folder scanning guide"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.DEVELOPER_TOOLS, + link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/" + }, + "dev-sso-guide": { + icon: open_in_new, + name: t("home.devSsoGuide.title", "SSO Guide"), + component: null, + view: "external", + description: t("home.devSsoGuide.desc", "Link to SSO guide"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.DEVELOPER_TOOLS, + link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration", + }, + "dev-airgapped": { + icon: open_in_new, + name: t("home.devAirgapped.title", "Air-gapped Setup"), + component: null, + view: "external", + description: t("home.devAirgapped.desc", "Link to air-gapped setup guide"), + category: ToolCategory.ADVANCED_TOOLS, + subcategory: SubcategoryId.DEVELOPER_TOOLS, + link: "https://docs.stirlingpdf.com/Pro/#activation" + }, + + + // Recommended Tools + "compare": { + icon: compare, + name: t("home.compare.title", "Compare"), + component: null, + view: "format", + description: t("home.compare.desc", "Compare two PDF documents and highlight differences"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL + }, + "compressPdfs": { + icon: zoom_in_map, + name: t("home.compressPdfs.title", "Compress"), + component: CompressPdfPanel, + view: "compress", + description: t("home.compressPdfs.desc", "Compress PDFs to reduce their file size."), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL, + maxFiles: -1 + }, + "convert": { + icon: sync_alt, + name: t("home.fileToPDF.title", "Convert"), + component: ConvertPanel, + view: "convert", + description: t("home.fileToPDF.desc", "Convert files to and from PDF format"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL, + maxFiles: -1, + endpoints: [ + "pdf-to-img", + "img-to-pdf", + "pdf-to-word", + "pdf-to-presentation", + "pdf-to-text", + "pdf-to-html", + "pdf-to-xml", + "html-to-pdf", + "markdown-to-pdf", + "file-to-pdf", + "pdf-to-csv", + "pdf-to-markdown", + "pdf-to-pdfa", + "eml-to-pdf" + ], + supportedFormats: [ + // 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" + ] + }, + "mergePdfs": { + icon: library_add, + name: t("home.merge.title", "Merge"), + component: null, + view: "merge", + description: t("home.merge.desc", "Merge multiple PDFs into a single document"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL, + maxFiles: -1 + }, + "multi-tool": { + icon: dashboard_customize, + name: t("home.multiTool.title", "Multi-Tool"), + component: null, + view: "pageEditor", + description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL, + maxFiles: -1 + }, + "ocr": { + icon: quick_reference_all, + name: t("home.ocr.title", "OCR"), + component: OCRPanel, + view: "convert", + description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL, + maxFiles: -1 + }, + "redact": { + icon: visibility_off, + name: t("home.redact.title", "Redact"), + component: null, + view: "redact", + description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"), + category: ToolCategory.RECOMMENDED_TOOLS, + subcategory: SubcategoryId.GENERAL + }, + }; +} \ No newline at end of file diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index efbaa35a9..718bc9370 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -4,4 +4,5 @@ declare module "../tools/Merge"; declare module "../components/PageEditor"; declare module "../components/Viewer"; declare module "*.js"; -declare module '*.module.css'; \ No newline at end of file +declare module '*.module.css'; +declare module 'pdfjs-dist'; \ No newline at end of file diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts index 96d80fb9d..ea7d0bdf0 100644 --- a/frontend/src/hooks/usePdfSignatureDetection.ts +++ b/frontend/src/hooks/usePdfSignatureDetection.ts @@ -34,7 +34,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe const page = await pdf.getPage(i); const annotations = await page.getAnnotations({ intent: 'display' }); - annotations.forEach(annotation => { + annotations.forEach((annotation: any) => { if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') { foundSignature = true; } diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index a3c8f6e67..b16ed1228 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -18,7 +18,13 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb if (stored && ['light', 'dark', 'rainbow'].includes(stored)) { return stored as ThemeMode; } - return initialTheme; + try { + // Fallback to OS preference if available + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : initialTheme; + } catch { + return initialTheme; + } }); // Track rapid toggles for easter egg diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 5ac19d74e..c19e5b6af 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,137 +1,14 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import ContentCutIcon from "@mui/icons-material/ContentCut"; -import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; -import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; -import ApiIcon from "@mui/icons-material/Api"; -import CleaningServicesIcon from "@mui/icons-material/CleaningServices"; -import LockIcon from "@mui/icons-material/Lock"; -import BrandingWatermarkIcon from "@mui/icons-material/BrandingWatermark"; -import LockOpenIcon from "@mui/icons-material/LockOpen"; +import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; +import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; -import { Tool, ToolDefinition, ToolRegistry } from "../types/tool"; - - -// Add entry here with maxFiles, endpoints, and lazy component -const toolDefinitions: Record = { - split: { - id: "split", - icon: , - component: React.lazy(() => import("../tools/Split")), - maxFiles: 1, - category: "manipulation", - description: "Split PDF files into smaller parts", - endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"] - }, - compress: { - id: "compress", - icon: , - component: React.lazy(() => import("../tools/Compress")), - maxFiles: -1, - category: "optimization", - description: "Reduce PDF file size", - endpoints: ["compress-pdf"] - }, - convert: { - id: "convert", - icon: , - component: React.lazy(() => import("../tools/Convert")), - maxFiles: -1, - category: "manipulation", - description: "Change to and from PDF and other formats", - endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"], - supportedFormats: [ - // 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" - ] - }, - swagger: { - id: "swagger", - icon: , - component: React.lazy(() => import("../tools/SwaggerUI")), - maxFiles: 0, - category: "utility", - description: "Open API documentation", - endpoints: ["swagger-ui"] - }, - ocr: { - id: "ocr", - icon: - quick_reference_all - , - component: React.lazy(() => import("../tools/OCR")), - maxFiles: -1, - category: "utility", - description: "Extract text from images using OCR", - endpoints: ["ocr-pdf"] - }, - sanitize: { - id: "sanitize", - icon: , - component: React.lazy(() => import("../tools/Sanitize")), - maxFiles: -1, - category: "security", - description: "Remove potentially harmful elements from PDF files", - endpoints: ["sanitize-pdf"] - }, - addPassword: { - id: "addPassword", - icon: , - component: React.lazy(() => import("../tools/AddPassword")), - maxFiles: -1, - category: "security", - description: "Add password protection and restrictions to PDF files", - endpoints: ["add-password"] - }, - changePermissions: { - id: "changePermissions", - icon: , - component: React.lazy(() => import("../tools/ChangePermissions")), - maxFiles: -1, - category: "security", - description: "Change document restrictions and permissions", - endpoints: ["add-password"] - }, - watermark: { - id: "watermark", - icon: , - component: React.lazy(() => import("../tools/AddWatermark")), - maxFiles: -1, - category: "security", - description: "Add text or image watermarks to PDF files", - endpoints: ["add-watermark"] - }, - removePassword: { - id: "removePassword", - icon: , - component: React.lazy(() => import("../tools/RemovePassword")), - maxFiles: -1, - category: "security", - description: "Remove password protection from PDF files", - endpoints: ["remove-password"] - }, - -}; interface ToolManagementResult { selectedToolKey: string | null; - selectedTool: Tool | null; + selectedTool: ToolRegistryEntry | null; toolSelectedFileIds: string[]; - toolRegistry: ToolRegistry; + toolRegistry: Record; selectTool: (toolKey: string) => void; clearToolSelection: () => void; setToolSelectedFileIds: (fileIds: string[]) => void; @@ -143,33 +20,41 @@ export const useToolManagement = (): ToolManagementResult => { const [selectedToolKey, setSelectedToolKey] = useState(null); const [toolSelectedFileIds, setToolSelectedFileIds] = useState([]); - const allEndpoints = Array.from(new Set( - Object.values(toolDefinitions).flatMap(tool => tool.endpoints || []) - )); + // Build endpoints list from registry entries with fallback to legacy mapping + const baseRegistry = useFlatToolRegistry(); + const registryDerivedEndpoints = useMemo(() => { + const endpointsByTool: Record = {}; + Object.entries(baseRegistry).forEach(([key, entry]) => { + if (entry.endpoints && entry.endpoints.length > 0) { + endpointsByTool[key] = entry.endpoints; + } + }); + return endpointsByTool; + }, [baseRegistry]); + + const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { if (endpointsLoading) return true; - const tool = toolDefinitions[toolKey]; - if (!tool?.endpoints) return true; - return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true); - }, [endpointsLoading, endpointStatus]); + const endpoints = baseRegistry[toolKey]?.endpoints || []; + return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); + }, [endpointsLoading, endpointStatus, baseRegistry]); - const toolRegistry: ToolRegistry = useMemo(() => { - const availableTools: ToolRegistry = {}; - Object.keys(toolDefinitions).forEach(toolKey => { + const toolRegistry: Record = useMemo(() => { + const availableToolRegistry: Record = {}; + Object.keys(baseRegistry).forEach(toolKey => { if (isToolAvailable(toolKey)) { - const toolDef = toolDefinitions[toolKey]; - availableTools[toolKey] = { - ...toolDef, - name: t(`${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)), - title: t(`${toolKey}.title`, toolDef.description || toolKey), - description: t(`${toolKey}.desc`, toolDef.description || `${toolKey} tool`) + const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry]; + availableToolRegistry[toolKey] = { + ...baseTool, + name: t(baseTool.name), + description: t(baseTool.description) }; } }); - return availableTools; - }, [t, isToolAvailable]); + return availableToolRegistry; + }, [isToolAvailable, t, baseRegistry]); useEffect(() => { if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) { @@ -197,7 +82,6 @@ export const useToolManagement = (): ToolManagementResult => { selectedTool, toolSelectedFileIds, toolRegistry, - selectTool, clearToolSelection, setToolSelectedFileIds, diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts new file mode 100644 index 000000000..4c1a0c05d --- /dev/null +++ b/frontend/src/hooks/useToolSections.ts @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; + +import { SUBCATEGORY_ORDER, ToolCategory, ToolRegistryEntry } from '../data/toolsTaxonomy'; +import { useTranslation } from 'react-i18next'; + +type GroupedTools = { + [category: string]: { + [subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>; + }; +}; + +export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) { + const { t } = useTranslation(); + + const groupedTools = useMemo(() => { + const grouped: GroupedTools = {}; + filteredTools.forEach(([id, tool]) => { + const category = tool.category; + const subcategory = tool.subcategory; + if (!grouped[category]) grouped[category] = {}; + if (!grouped[category][subcategory]) grouped[category][subcategory] = []; + grouped[category][subcategory].push({ id, tool }); + }); + return grouped; + }, [filteredTools]); + + const sections = useMemo(() => { + const getOrderIndex = (name: string) => { + const idx = SUBCATEGORY_ORDER.indexOf(name as any); + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; + }; + + const quick: Record> = {}; + const all: Record> = {}; + + Object.entries(groupedTools).forEach(([origCat, subs]) => { + const upperCat = origCat.toUpperCase(); + + Object.entries(subs).forEach(([sub, tools]) => { + if (!all[sub]) all[sub] = []; + all[sub].push(...tools); + }); + + if (upperCat === ToolCategory.RECOMMENDED_TOOLS.toUpperCase()) { + Object.entries(subs).forEach(([sub, tools]) => { + if (!quick[sub]) quick[sub] = []; + quick[sub].push(...tools); + }); + } + }); + + const sortSubs = (obj: Record>) => + Object.entries(obj) + .sort(([a], [b]) => { + const ai = getOrderIndex(a); + const bi = getOrderIndex(b); + if (ai !== bi) return ai - bi; + return a.localeCompare(b); + }) + .map(([subcategory, tools]) => ({ subcategory, tools })); + + const built = [ + { key: 'quick', title: t('toolPicker.quickAccess', 'QUICK ACCESS'), subcategories: sortSubs(quick) }, + { key: 'all', title: t('toolPicker.allTools', 'ALL TOOLS'), subcategories: sortSubs(all) } + ]; + + return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0)); + }, [groupedTools]); + + const searchGroups = useMemo(() => { + const subMap: Record> = {}; + const seen = new Set(); + filteredTools.forEach(([id, tool]) => { + if (seen.has(id)) return; + seen.add(id); + const sub = tool.subcategory; + if (!subMap[sub]) subMap[sub] = []; + subMap[sub].push({ id, tool }); + }); + return Object.entries(subMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([subcategory, tools]) => ({ subcategory, tools })); + }, [filteredTools]); + + return { sections, searchGroups }; +} + + diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index e8d719e24..740eec3dc 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,11 +2,22 @@ import '@mantine/core/styles.css'; import './index.css'; // Import Tailwind CSS import React from 'react'; import ReactDOM from 'react-dom/client'; -import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core'; +import { ColorSchemeScript } from '@mantine/core'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; import './i18n'; // Initialize i18next +// Compute initial color scheme +function getInitialScheme(): 'light' | 'dark' { + const stored = localStorage.getItem('stirling-theme'); + if (stored === 'light' || stored === 'dark') return stored; + try { + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light'; + } catch { + return 'light'; + } +} const container = document.getElementById('root'); if (!container) { @@ -15,12 +26,10 @@ if (!container) { const root = ReactDOM.createRoot(container); // Finds the root DOM element root.render( - - - - - - + + + + ); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index a931f0090..d26a40caa 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; -import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; +import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { useDocumentMeta } from "../hooks/useDocumentMeta"; @@ -24,24 +24,24 @@ function HomePageContent() { const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); - const { selectedTool } = useToolSelection(); + const { selectedTool, selectedToolKey } = useToolWorkflow(); const baseUrl = getBaseUrl(); // Update document meta when tool changes useDocumentMeta({ - title: selectedTool?.title ? `${selectedTool.title} - Stirling PDF` : 'Stirling PDF', + title: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF', description: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), - ogTitle: selectedTool?.title ? `${selectedTool.title} - Stirling PDF` : 'Stirling PDF', + ogTitle: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF', ogDescription: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), - ogImage: selectedTool ? `${baseUrl}/og_images/${selectedTool.id}.png` : `${baseUrl}/og_images/home.png`, + ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`, ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl }); // Update file selection context when tool changes useEffect(() => { if (selectedTool) { - setMaxFiles(selectedTool.maxFiles); + setMaxFiles(selectedTool.maxFiles ?? -1); setIsToolMode(true); } else { setMaxFiles(-1); @@ -76,4 +76,4 @@ export default function HomePage() { ); -} +} \ No newline at end of file diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 9ec48bca7..634cae91c 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -81,7 +81,7 @@ --text-secondary: #4b5563; --text-muted: #6b7280; --border-subtle: #e5e7eb; - --border-default: #d1d5db; + --border-default: #E2E8F0; --border-strong: #9ca3af; --hover-bg: #f9fafb; --active-bg: #f3f4f6; @@ -117,11 +117,31 @@ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; + /* New theme colors for text and icons */ + --tools-text-and-icon-color: #374151; + + /* Tool picker sticky header variables (light mode) */ + --tool-header-bg: #DBEFFF; + --tool-header-border: #BEE2FF; + --tool-header-text: #1E88E5; + --tool-header-badge-bg: #C0DDFF; + --tool-header-badge-text: #004E99; + + /* Subcategory title styling (light mode) */ + --tool-subcategory-text-color: #9CA3AF; /* lighter text */ + --tool-subcategory-rule-color: #E5E7EB; /* doubly lighter rule line */ --accent-interactive: #4A90E2; --text-instruction: #4A90E2; --text-brand: var(--color-gray-700); --text-brand-accent: #DC2626; + /* Placeholder text colors */ + --search-text-and-icon-color: #6B7382; + + /* Tool panel search bar background colors */ + --tool-panel-search-bg: #EFF1F4; + --tool-panel-search-border-bottom: #EFF1F4; + /* container */ --landing-paper-bg: var(--bg-surface); --landing-inner-paper-bg: #EEF8FF; @@ -177,7 +197,7 @@ --bg-raised: #1F2329; --bg-muted: #1F2329; --bg-background: #2A2F36; - --bg-toolbar: #272A2E; + --bg-toolbar: #1F2329; --bg-file-manager: #1F2329; --bg-file-list: #2A2F36; --btn-open-file: #0A8BFF; @@ -185,7 +205,7 @@ --text-secondary: #d1d5db; --text-muted: #9ca3af; --border-subtle: #2A2F36; - --border-default: #374151; + --border-default: #3A4047; --border-strong: #4b5563; --hover-bg: #374151; --active-bg: #4b5563; @@ -251,6 +271,27 @@ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4); --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4); + + --tools-text-and-icon-color: #D0D6DC; + + /* Tool picker sticky header variables (dark mode) */ + --tool-header-bg: #2A2F36; + --tool-header-border: #3A4047; + --tool-header-text: #D0D6DC; + --tool-header-badge-bg: #4B525A; + --tool-header-badge-text: #FFFFFF; + + /* Subcategory title styling (dark mode) */ + --tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */ + --tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */ + + /* Placeholder text colors (dark mode) */ + --search-text-and-icon-color: #FFFFFF !important; + + /* Tool panel search bar background colors (dark mode) */ + --tool-panel-search-bg: #1F2329; + --tool-panel-search-border-bottom: #4B525A; + } /* Dropzone drop state styling */ diff --git a/frontend/src/types/sidebar.ts b/frontend/src/types/sidebar.ts index b286f0b82..b93db61d6 100644 --- a/frontend/src/types/sidebar.ts +++ b/frontend/src/types/sidebar.ts @@ -32,7 +32,6 @@ export interface ButtonConfig { id: string; name: string; icon: React.ReactNode; - tooltip: string; isRound?: boolean; size?: 'sm' | 'md' | 'lg' | 'xl'; onClick: () => void; diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts index 90e8607da..e5e8c24e2 100644 --- a/frontend/src/types/tool.ts +++ b/frontend/src/types/tool.ts @@ -34,7 +34,7 @@ export interface ToolResult { } export interface ToolConfiguration { - maxFiles: number; + maxFiles?: number; supportedFormats?: string[]; }