From 7dedf45b5dd626a00f87c9793a65c57f26ee4f5d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:52:40 +0100 Subject: [PATCH 1/4] prune fix (#4133) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 6 +++--- .github/workflows/deploy-on-v2-commit.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 3ece9fc4f..bd546078d 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -352,10 +352,10 @@ jobs: docker-compose up -d # Clean up unused Docker resources to save space - docker system prune -af --volumes + docker system prune -af --volumes || true # Clean up old backend/frontend images (older than 2 weeks) - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH # Set port for output @@ -490,7 +490,7 @@ jobs: fi # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true # Note: We don't remove the commit-based images since they can be reused across PRs # Only remove PR-specific containers and directories diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 8c3218869..941db40cf 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -177,8 +177,8 @@ jobs: docker-compose down || true docker-compose pull docker-compose up -d - docker system prune -af --volumes - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker system prune -af --volumes || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH - name: Cleanup temporary files From f4e4831c0d1838851eaa85fc077f6c8213130b7a Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:02:08 +0100 Subject: [PATCH 2/4] Feature/v2/landing page (#4128) # Description of Changes - Added landing page styling - Clicking add files now opens native file manager --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../branding/StirlingPDFLogoBlackText.svg | 4 + .../branding/StirlingPDFLogoGreyText.svg | 4 + .../branding/StirlingPDFLogoNoTextDark.svg | 4 + .../branding/StirlingPDFLogoNoTextLight.svg | 4 + .../branding/StirlingPDFLogoWhiteText.svg | 4 + .../src/components/shared/LandingPage.tsx | 158 +++++++++++++++--- frontend/src/styles/theme.css | 53 ++++++ 7 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 frontend/public/branding/StirlingPDFLogoBlackText.svg create mode 100644 frontend/public/branding/StirlingPDFLogoGreyText.svg create mode 100644 frontend/public/branding/StirlingPDFLogoNoTextDark.svg create mode 100644 frontend/public/branding/StirlingPDFLogoNoTextLight.svg create mode 100644 frontend/public/branding/StirlingPDFLogoWhiteText.svg diff --git a/frontend/public/branding/StirlingPDFLogoBlackText.svg b/frontend/public/branding/StirlingPDFLogoBlackText.svg new file mode 100644 index 000000000..a4a1a1f87 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoBlackText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoGreyText.svg b/frontend/public/branding/StirlingPDFLogoGreyText.svg new file mode 100644 index 000000000..deac1ee16 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoGreyText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg new file mode 100644 index 000000000..6c99f5001 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg new file mode 100644 index 000000000..a0fa9cee5 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoWhiteText.svg b/frontend/public/branding/StirlingPDFLogoWhiteText.svg new file mode 100644 index 000000000..ade693787 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoWhiteText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 977f1f280..40b765547 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -1,28 +1,146 @@ import React from 'react'; -import { Container, Stack, Text, Button } from '@mantine/core'; -import FolderIcon from '@mui/icons-material/FolderRounded'; -import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; +import { useFileHandler } from '../../hooks/useFileHandler'; -interface LandingPageProps { - title: string; -} +const LandingPage = () => { + const { addMultipleFiles } = useFileHandler(); + const fileInputRef = React.useRef(null); + const { colorScheme } = useMantineColorScheme(); + const { t } = useTranslation(); + + const handleFileDrop = async (files: File[]) => { + await addMultipleFiles(files); + }; + + const handleAddFilesClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length > 0) { + await addMultipleFiles(files); + } + // Reset the input so the same file can be selected again + event.target.value = ''; + }; -const LandingPage = ({ title }: LandingPageProps) => { - const { openFilesModal } = useFilesModalContext(); return ( - - - - {title} - - - + Stirling PDF Logo + +
+ {/* Logo positioned absolutely in top right corner */} + + + {/* Centered content container */} +
+ {/* Stirling PDF Branding */} + + Stirling PDF + + + {/* Add Files Button */} + + + {/* Hidden file input for native file picker */} + + +
+ + {/* Instruction Text */} + + {t('fileUpload.dragFilesInOrClick', 'Drag files in or click "Add Files" to browse')} + +
+
); }; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 7cdb46c55..71443411f 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -106,6 +106,30 @@ /* Inactive icon colors for light mode */ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; + + --accent-interactive: #4A90E2; + --text-instruction: #4A90E2; + --text-brand: var(--color-gray-700); + --text-brand-accent: #DC2626; + + /* container */ + --landing-paper-bg: var(--bg-surface); + --landing-inner-paper-bg: #EEF8FF; + --landing-inner-paper-border: #CDEAFF; + --landing-button-bg: var(--bg-surface); + --landing-button-color: var(--icon-tools-bg); + --landing-button-border: #E0F2F7; + --landing-button-hover-bg: rgb(251, 251, 251); + + /* drop state */ + --landing-drop-paper-bg: #E3F2FD; + --landing-drop-inner-paper-bg: #BBDEFB; + --landing-drop-inner-paper-border: #90CAF9; + + /* shadows */ + --drop-shadow-color: rgba(0, 0, 0, 0.08); + --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); + --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); } [data-mantine-color-scheme="dark"] { @@ -177,6 +201,29 @@ --icon-inactive-bg: #2A2F36; --icon-inactive-color: #6E7581; + --accent-interactive: #ffffff; + --text-instruction: #ffffff; + --text-brand: var(--color-gray-800); + --text-brand-accent: #EF4444; + + /* container */ + --landing-paper-bg: #171A1F; + --landing-inner-paper-bg: var(--bg-raised); + --landing-inner-paper-border: #2D3237; + --landing-button-bg: #2B3037; + --landing-button-color: #ffffff; + --landing-button-border: #2D3237; + --landing-button-hover-bg: #4c525b; + + /* drop state */ + --landing-drop-paper-bg: #1A2332; + --landing-drop-inner-paper-bg: #2A3441; + --landing-drop-inner-paper-border: #3A4451; + + /* shadows */ + --drop-shadow-color: rgba(255, 255, 255, 0.08); + --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); + --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04)); /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); @@ -185,6 +232,12 @@ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4); } +/* Dropzone drop state styling */ +[data-accept] .dropzone-inner { + background-color: var(--landing-drop-inner-paper-bg) !important; + border-color: var(--landing-drop-inner-paper-border) !important; +} + /* Smooth transitions for theme switching */ * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; From 9861332040612ed154eedce120c488769fb29cc0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:09:41 +0100 Subject: [PATCH 3/4] Feature/v2/tooltips (#4112) # Description of Changes - added tooltips to ocr and compress - added the tooltip component which can be used either directly, or through the toolstep component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/public/logo-tooltip.svg | 4 + .../src/components/shared/QuickAccessBar.tsx | 36 +-- frontend/src/components/shared/Tooltip.tsx | 243 ++++++++++++++++++ .../shared/tooltip/Tooltip.README.md | 223 ++++++++++++++++ .../shared/tooltip/Tooltip.module.css | 191 ++++++++++++++ .../shared/tooltip/TooltipContent.tsx | 78 ++++++ .../src/components/tools/shared/ToolStep.tsx | 49 +++- .../src/components/tooltips/CompressTips.ts | 30 +++ frontend/src/components/tooltips/OCRTips.ts | 36 +++ frontend/src/contexts/SidebarContext.tsx | 47 ++++ frontend/src/hooks/useTooltipPosition.ts | 177 +++++++++++++ frontend/src/pages/HomePage.tsx | 27 +- frontend/src/styles/theme.css | 15 ++ frontend/src/tools/Compress.tsx | 3 + frontend/src/tools/OCR.tsx | 3 + frontend/src/types/sidebar.ts | 46 ++++ frontend/src/types/tips.ts | 13 + frontend/src/utils/genericUtils.ts | 42 +++ frontend/src/utils/sidebarUtils.ts | 34 +++ 19 files changed, 1256 insertions(+), 41 deletions(-) create mode 100644 frontend/public/logo-tooltip.svg create mode 100644 frontend/src/components/shared/Tooltip.tsx create mode 100644 frontend/src/components/shared/tooltip/Tooltip.README.md create mode 100644 frontend/src/components/shared/tooltip/Tooltip.module.css create mode 100644 frontend/src/components/shared/tooltip/TooltipContent.tsx create mode 100644 frontend/src/components/tooltips/CompressTips.ts create mode 100644 frontend/src/components/tooltips/OCRTips.ts create mode 100644 frontend/src/contexts/SidebarContext.tsx create mode 100644 frontend/src/hooks/useTooltipPosition.ts create mode 100644 frontend/src/types/sidebar.ts create mode 100644 frontend/src/types/tips.ts create mode 100644 frontend/src/utils/genericUtils.ts create mode 100644 frontend/src/utils/sidebarUtils.ts diff --git a/frontend/public/logo-tooltip.svg b/frontend/public/logo-tooltip.svg new file mode 100644 index 000000000..2d53f287c --- /dev/null +++ b/frontend/public/logo-tooltip.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 2f78a0a9f..fb27b1c2c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, forwardRef } from "react"; import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -8,32 +8,12 @@ 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 rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar'; import './QuickAccessBar.css'; -interface QuickAccessBarProps { - onToolsClick: () => void; - onReaderToggle: () => void; - selectedToolKey?: string; - toolRegistry: any; - leftPanelView: 'toolPicker' | 'toolContent'; - readerMode: boolean; -} - -interface ButtonConfig { - id: string; - name: string; - icon: React.ReactNode; - tooltip: string; - isRound?: boolean; - size?: 'sm' | 'md' | 'lg' | 'xl'; - onClick: () => void; - type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions -} - function NavHeader({ activeButton, setActiveButton, @@ -104,14 +84,10 @@ function NavHeader({ ); } -const QuickAccessBar = ({ +const QuickAccessBar = forwardRef(({ onToolsClick, onReaderToggle, - selectedToolKey, - toolRegistry, - leftPanelView, - readerMode, -}: QuickAccessBarProps) => { +}, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -234,6 +210,8 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */} @@ -335,6 +313,6 @@ const QuickAccessBar = ({ />
); -}; +}); export default QuickAccessBar; \ No newline at end of file diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx new file mode 100644 index 000000000..6deda77c4 --- /dev/null +++ b/frontend/src/components/shared/Tooltip.tsx @@ -0,0 +1,243 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; +import { useTooltipPosition } from '../../hooks/useTooltipPosition'; +import { TooltipContent, TooltipTip } from './tooltip/TooltipContent'; +import { useSidebarContext } from '../../contexts/SidebarContext'; +import styles from './tooltip/Tooltip.module.css' + +export interface TooltipProps { + sidebarTooltip?: boolean; + position?: 'right' | 'left' | 'top' | 'bottom'; + content?: React.ReactNode; + tips?: TooltipTip[]; + children: React.ReactElement; + offset?: number; + maxWidth?: number | string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + arrow?: boolean; + portalTarget?: HTMLElement; + header?: { + title: string; + logo?: React.ReactNode; + }; +} + +export const Tooltip: React.FC = ({ + sidebarTooltip = false, + position = 'right', + content, + tips, + children, + offset: gap = 8, + maxWidth = 280, + open: controlledOpen, + onOpenChange, + arrow = false, + portalTarget, + header, +}) => { + const [internalOpen, setInternalOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + + // Get sidebar context for tooltip positioning + const sidebarContext = sidebarTooltip ? useSidebarContext() : null; + + // Always use controlled mode - if no controlled props provided, use internal state + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = (newOpen: boolean) => { + if (isControlled) { + onOpenChange?.(newOpen); + } else { + setInternalOpen(newOpen); + } + + // Reset pin state when closing + if (!newOpen) { + setIsPinned(false); + } + }; + + const handleTooltipClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsPinned(true); + }; + + const handleDocumentClick = (e: MouseEvent) => { + // If tooltip is pinned and we click outside of it, unpin it + if (isPinned && isClickOutside(e, tooltipRef.current)) { + setIsPinned(false); + handleOpenChange(false); + } + }; + + // Use the positioning hook + const { coords, positionReady } = useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs: sidebarContext?.sidebarRefs, + sidebarState: sidebarContext?.sidebarState + }); + + // Add document click listener for unpinning + useEffect(() => { + if (isPinned) { + return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); + } + }, [isPinned]); + + const getArrowClass = () => { + // No arrow for sidebar tooltips + if (sidebarTooltip) return null; + + switch (position) { + case 'top': return "tooltip-arrow tooltip-arrow-top"; + case 'bottom': return "tooltip-arrow tooltip-arrow-bottom"; + case 'left': return "tooltip-arrow tooltip-arrow-left"; + case 'right': return "tooltip-arrow tooltip-arrow-right"; + default: return "tooltip-arrow tooltip-arrow-right"; + } + }; + + const getArrowStyleClass = (arrowClass: string) => { + const styleKey = arrowClass.split(' ')[1]; + // Handle both kebab-case and camelCase CSS module exports + return styles[styleKey as keyof typeof styles] || + styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] || + ''; + }; + + // Only show tooltip when position is ready and correct + const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true); + + const tooltipElement = shouldShowTooltip ? ( +
+ {isPinned && ( + + )} + {arrow && getArrowClass() && ( +
+ )} + {header && ( +
+
+ {header.logo || Stirling PDF} +
+ {header.title} +
+ )} + +
+ ) : 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 + if (!isPinned) { + handleOpenChange(true); + } + + (children.props as any)?.onMouseEnter?.(e); + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + // Only hide on mouse leave if not pinned + if (!isPinned) { + // Add a small delay to prevent flickering + hoverTimeoutRef.current = setTimeout(() => { + handleOpenChange(false); + }, 100); + } + + (children.props as any)?.onMouseLeave?.(e); + }; + + const handleClick = (e: React.MouseEvent) => { + // Toggle pin state on click + if (open) { + setIsPinned(!isPinned); + } else { + handleOpenChange(true); + setIsPinned(true); + } + + (children.props as any)?.onClick?.(e); + }; + + // Take the child element and add tooltip behavior to it + const childWithTooltipHandlers = React.cloneElement(children as any, { + // Keep track of the element for positioning + ref: (node: HTMLElement) => { + triggerRef.current = node; + // Don't break if the child already has a ref + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') { + originalRef(node); + } else if (originalRef && typeof originalRef === 'object') { + originalRef.current = node; + } + }, + // Add mouse events to show/hide tooltip + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + }); + + return ( + <> + {childWithTooltipHandlers} + {portalTarget && document.body.contains(portalTarget) + ? tooltipElement && createPortal(tooltipElement, portalTarget) + : tooltipElement} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md new file mode 100644 index 000000000..df1a977b0 --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -0,0 +1,223 @@ +# Tooltip Component + +A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click. + +## Features + +- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds +- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements +- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality +- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX +- 🌙 **Theme Support**: Built-in dark mode and theme variable support +- ⚡ **Performance**: Memoized calculations and efficient event handling +- 📜 **Scrollable**: Content area scrolls when content exceeds max height +- 📌 **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 + +## Behavior + +### Default Behavior (Controlled) +- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering +- **Click**: Click the trigger to pin the tooltip open +- **Click tooltip**: Pins the tooltip to keep it open +- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned) +- **Click outside**: Unpins and closes the tooltip +- **Visual indicator**: Pinned tooltips have a blue border and close button + +### Manual Control (Optional) +- Use `open` and `onOpenChange` props for complete external control +- Useful for complex state management or custom interaction patterns + +## Basic Usage + +```tsx +import { Tooltip } from '@/components/shared'; + +function MyComponent() { + return ( + + + + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip | +| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body | +| `children` | `ReactElement` | **required** | Element that triggers the tooltip | +| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic | +| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) | +| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip | +| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip | +| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) | +| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control | +| `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 | + +### TooltipTip Interface + +```typescript +interface TooltipTip { + title?: string; // Optional pill label + description?: string; // Optional description text (supports HTML including tags) + bullets?: string[]; // Optional bullet points (supports HTML including tags) + body?: React.ReactNode; // Optional custom JSX for this tip +} +``` + +## Usage Examples + +### Default Behavior (Recommended) + +```tsx +// Simple tooltip with hover and click-to-pin + + + + +// Structured content with tips +Auto skips pages that already contain text.", + "Force re-processes every page.", + "Strict stops if text is found.", + "Learn more" + ] + } + ]} + header={{ + title: "Basic Settings Overview", + logo: Logo + }} +> + + +``` + +### Custom JSX Content + +```tsx + +

Custom Content

+

Any JSX you want here

+ + External link +
+ } +> + + +``` + +### Mixed Content (Tips + Custom JSX) + +```tsx +Additional custom content below tips} +> + + +``` + +### Sidebar Tooltips + +```tsx +// For items in a sidebar/navigation + +
+ 📁 File Manager +
+
+``` + +### With Arrows + +```tsx + + + +``` + +### Manual Control (Advanced) + +```tsx +function ManualControlTooltip() { + const [open, setOpen] = useState(false); + + return ( + + + + ); +} +``` + +## Click-to-Pin Interaction + +### How to Use (Default Behavior) +1. **Hover** over the trigger element to show the tooltip +2. **Click** the trigger element to pin the tooltip open +3. **Click** the red X button in the top-right corner to close +4. **Click** anywhere outside the tooltip to close +5. **Click** the trigger again to toggle pin state + +### Visual States +- **Unpinned**: Normal tooltip appearance +- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner + +## Link Support + +The tooltip fully supports clickable links in all content areas: + +- **Descriptions**: Use `` in description strings +- **Bullets**: Use `` in bullet point strings +- **Body**: Use JSX `` elements in the body ReactNode +- **Content**: Use JSX `` elements in custom content + +Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`. + +## Positioning Logic + +### Regular Tooltips +- Uses the `position` prop to determine initial placement +- Automatically clamps to viewport boundaries +- Calculates optimal position based on trigger element's `getBoundingClientRect()` +- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped + +### Sidebar Tooltips +- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar +- Vertical positioning follows the trigger but clamps to viewport +- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position +- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed +- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`) +- **No arrows** - sidebar tooltips don't show arrows diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css new file mode 100644 index 000000000..209f0dcbd --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -0,0 +1,191 @@ +/* Tooltip Container */ +.tooltip-container { + position: fixed; + border: 0.0625rem solid var(--border-default); + border-radius: 0.75rem; + background-color: var(--bg-raised); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05); + font-size: 0.875rem; + line-height: 1.5; + 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); + display: flex; + flex-direction: column; +} + +/* Pinned tooltip indicator */ +.tooltip-container.pinned { + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1); +} + +/* Pinned tooltip header */ +.tooltip-container.pinned .tooltip-header { + background-color: var(--primary-color, #3b82f6); + color: white; + border-color: var(--primary-color, #3b82f6); +} + +/* Close button */ +.tooltip-pin-button { + position: absolute; + top: -0.5rem; + right: 0.5rem; + font-size: 0.875rem; + background: var(--bg-raised); + padding: 0.25rem; + border-radius: 0.25rem; + border: 0.0625rem solid var(--primary-color, #3b82f6); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + min-height: 1.5rem; +} + +.tooltip-pin-button .material-symbols-outlined { + font-size: 1rem; + line-height: 1; +} + +.tooltip-pin-button:hover { + background-color: #ef4444 !important; + border-color: #ef4444 !important; +} + +/* Tooltip Header */ +.tooltip-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: var(--tooltip-header-bg); + color: var(--tooltip-header-color); + font-size: 0.875rem; + font-weight: 500; + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + margin: -0.0625rem -0.0625rem 0 -0.0625rem; + border: 0.0625rem solid var(--tooltip-border); + flex-shrink: 0; +} + +.tooltip-logo { + width: 1rem; + height: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.tooltip-title { + flex: 1; +} + +/* Tooltip Body */ +.tooltip-body { + padding: 1rem !important; + color: var(--text-primary) !important; + font-size: 0.875rem !important; + line-height: 1.6 !important; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.tooltip-body * { + color: var(--text-primary) !important; +} + +/* Link styling within tooltips */ +.tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + transition: color 0.2s ease, text-decoration-color 0.2s ease; +} + +.tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + +.tooltip-container .tooltip-body { + color: var(--text-primary) !important; +} + +.tooltip-container .tooltip-body * { + color: var(--text-primary) !important; +} + +/* Ensure links maintain their styling */ +.tooltip-container .tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); +} + +.tooltip-container .tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + + +/* Tooltip Arrows */ +.tooltip-arrow { + position: absolute; + width: 0.5rem; + height: 0.5rem; + background: var(--bg-raised); + border: 0.0625rem solid var(--border-default); + transform: rotate(45deg); +} + + +.tooltip-arrow-sidebar { + top: 50%; + left: -0.25rem; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-top { + top: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-top: none; + border-left: none; +} + +.tooltip-arrow-bottom { + bottom: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-bottom: none; + border-right: none; +} + +.tooltip-arrow-left { + right: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-right { + left: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-right: none; + border-top: none; +} \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/TooltipContent.tsx b/frontend/src/components/shared/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..e3515e0e6 --- /dev/null +++ b/frontend/src/components/shared/tooltip/TooltipContent.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styles from './Tooltip.module.css'; + +export interface TooltipTip { + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; +} + +interface TooltipContentProps { + content?: React.ReactNode; + tips?: TooltipTip[]; +} + +export const TooltipContent: React.FC = ({ + content, + tips, +}) => { + return ( +
+
+ {tips ? ( + <> + {tips.map((tip, index) => ( +
+ {tip.title && ( +
+ {tip.title} +
+ )} + {tip.description && ( +

+ )} + {tip.bullets && tip.bullets.length > 0 && ( +

    + {tip.bullets.map((bullet, bulletIndex) => ( +
  • + ))} +
+ )} + {tip.body && ( +
+ {tip.body} +
+ )} +
+ ))} + {content && ( +
+ {content} +
+ )} + + ) : ( + content + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index c4f144bfc..5ae6aec9b 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useMemo, useRef } from 'react'; import { Paper, Text, Stack, Box, Flex } from '@mantine/core'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { Tooltip } from '../../shared/Tooltip'; +import { TooltipTip } from '../../shared/tooltip/TooltipContent'; interface ToolStepContextType { visibleStepCount: number; @@ -20,8 +22,48 @@ export interface ToolStepProps { completedMessage?: string; helpText?: string; showNumber?: boolean; + tooltip?: { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; } +const renderTooltipTitle = ( + title: string, + tooltip: ToolStepProps['tooltip'], + isCollapsed: boolean +) => { + if (tooltip && !isCollapsed) { + return ( + + e.stopPropagation()}> + + {title} + + + gpp_maybe + + + + ); + } + + return ( + + {title} + + ); +}; + const ToolStep = ({ title, isVisible = true, @@ -31,7 +73,8 @@ const ToolStep = ({ children, completedMessage, helpText, - showNumber + showNumber, + tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -70,9 +113,7 @@ const ToolStep = ({ {stepNumber} )} - - {title} - + {renderTooltipTitle(title, tooltip, isCollapsed)} {isCollapsed ? ( diff --git a/frontend/src/components/tooltips/CompressTips.ts b/frontend/src/components/tooltips/CompressTips.ts new file mode 100644 index 000000000..2fb2a0777 --- /dev/null +++ b/frontend/src/components/tooltips/CompressTips.ts @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const CompressTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("compress.tooltip.header.title", "Compress Settings Overview") + }, + tips: [ + { + title: t("compress.tooltip.description.title", "Description"), + description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.") + }, + { + title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"), + description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."), + bullets: [ + t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"), + t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size") + ] + }, + { + title: t("compress.tooltip.grayscale.title", "Grayscale"), + description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/OCRTips.ts b/frontend/src/components/tooltips/OCRTips.ts new file mode 100644 index 000000000..1002182f2 --- /dev/null +++ b/frontend/src/components/tooltips/OCRTips.ts @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const OcrTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("ocr.tooltip.header.title", "OCR Settings Overview"), + }, + tips: [ + { + title: t("ocr.tooltip.mode.title", "OCR Mode"), + description: t("ocr.tooltip.mode.text", "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight."), + bullets: [ + t("ocr.tooltip.mode.bullet1", "Auto skips pages that already contain text layers."), + t("ocr.tooltip.mode.bullet2", "Force re-OCRs every page and replaces all the text."), + t("ocr.tooltip.mode.bullet3", "Strict halts if any selectable text is found.") + ] + }, + { + title: t("ocr.tooltip.languages.title", "Languages"), + description: t("ocr.tooltip.languages.text", "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection.") + }, + { + title: t("ocr.tooltip.output.title", "Output"), + description: t("ocr.tooltip.output.text", "Decide how you want the text output formatted:"), + bullets: [ + t("ocr.tooltip.output.bullet1", "Searchable PDF embeds text behind the original image."), + t("ocr.tooltip.output.bullet2", "HOCR XML returns a structured machine-readable file."), + t("ocr.tooltip.output.bullet3", "Plain-text sidecar creates a separate .txt file with raw content.") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx new file mode 100644 index 000000000..f09815c5c --- /dev/null +++ b/frontend/src/contexts/SidebarContext.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useState, useRef } from 'react'; +import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar'; + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: SidebarProviderProps) { + // All sidebar state management + const quickAccessRef = useRef(null); + const toolPanelRef = useRef(null); + + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + + const sidebarState: SidebarState = { + sidebarsVisible, + leftPanelView, + readerMode, + }; + + const sidebarRefs: SidebarRefs = { + quickAccessRef, + toolPanelRef, + }; + + const contextValue: SidebarContextValue = { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode, + }; + + return ( + + {children} + + ); +} + +export function useSidebarContext(): SidebarContextValue { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error('useSidebarContext must be used within a SidebarProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts new file mode 100644 index 000000000..3651c1d47 --- /dev/null +++ b/frontend/src/hooks/useTooltipPosition.ts @@ -0,0 +1,177 @@ +import { useState, useEffect, useMemo } from 'react'; +import { clamp } from '../utils/genericUtils'; +import { getSidebarInfo } from '../utils/sidebarUtils'; +import { SidebarRefs, SidebarState } from '../types/sidebar'; + +type Position = 'right' | 'left' | 'top' | 'bottom'; + +interface PlacementResult { + top: number; + left: number; +} + +interface PositionState { + coords: { top: number; left: number; arrowOffset: number | null }; + positionReady: boolean; +} + +function place( + triggerRect: DOMRect, + tooltipRect: DOMRect, + position: Position, + offset: number +): PlacementResult { + let top = 0; + let left = 0; + + switch (position) { + case 'right': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.right + offset; + break; + case 'left': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.left - tooltipRect.width - offset; + break; + case 'top': + top = triggerRect.top - tooltipRect.height - offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + case 'bottom': + top = triggerRect.bottom + offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + } + + return { top, left }; +} + +export function useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs, + sidebarState +}: { + open: boolean; + sidebarTooltip: boolean; + position: Position; + gap: number; + triggerRef: React.RefObject; + tooltipRef: React.RefObject; + sidebarRefs?: SidebarRefs; + sidebarState?: SidebarState; +}): PositionState { + const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ + top: 0, + left: 0, + arrowOffset: null + }); + const [positionReady, setPositionReady] = useState(false); + + // Fallback sidebar position (only used as last resort) + const sidebarLeft = 240; + + const updatePosition = () => { + if (!triggerRef.current || !open) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + + let top: number; + let left: number; + let arrowOffset: number | null = null; + + if (sidebarTooltip) { + // Require sidebar refs and state for proper positioning + if (!sidebarRefs || !sidebarState) { + console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props'); + setPositionReady(false); + return; + } + + const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState); + const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft; + + // Only show tooltip if we have the tool panel active + if (!sidebarInfo.isToolPanelActive) { + console.log('🚫 Not showing tooltip - tool panel not active'); + setPositionReady(false); + return; + } + + // Position to the right of active sidebar with 20px gap + left = currentSidebarRight + 20; + top = triggerRect.top; // Align top of tooltip with trigger element + + // Only clamp if we have tooltip dimensions + if (tooltipRef.current) { + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const maxTop = window.innerHeight - tooltipRect.height - 4; + const originalTop = top; + top = clamp(top, 4, maxTop); + + // If tooltip was clamped, adjust arrow position to stay aligned with trigger + if (originalTop !== top) { + arrowOffset = triggerRect.top + triggerRect.height / 2 - top; + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } else { + // Regular tooltip logic + if (!tooltipRef.current) return; + + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const placement = place(triggerRect, tooltipRect, position, gap); + top = placement.top; + left = placement.left; + + // Clamp to viewport + top = clamp(top, 4, window.innerHeight - tooltipRect.height - 4); + left = clamp(left, 4, window.innerWidth - tooltipRect.width - 4); + + // Calculate arrow position to stay aligned with trigger + if (position === 'top' || position === 'bottom') { + // For top/bottom arrows, adjust horizontal position + const triggerCenter = triggerRect.left + triggerRect.width / 2; + const tooltipCenter = left + tooltipRect.width / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - left - 4; // 4px is half arrow width + } + } else { + // For left/right arrows, adjust vertical position + const triggerCenter = triggerRect.top + triggerRect.height / 2; + const tooltipCenter = top + tooltipRect.height / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - top - 4; // 4px is half arrow height + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } + }; + + useEffect(() => { + if (!open) return; + + requestAnimationFrame(updatePosition); + + const handleUpdate = () => requestAnimationFrame(updatePosition); + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [open, sidebarLeft, position, gap, sidebarTooltip]); + + return { coords, positionReady }; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cccce7667..94a81ee6d 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,11 +2,13 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { useToolManagement } from "../hooks/useToolManagement"; import { useFileHandler } from "../hooks/useFileHandler"; import { Group, Box, Button } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { PageEditorFunctions } from "../types/pageEditor"; +import { SidebarRefs, SidebarState } from "../types/sidebar"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; @@ -20,9 +22,20 @@ import QuickAccessBar from "../components/shared/QuickAccessBar"; import LandingPage from "../components/shared/LandingPage"; import FileUploadModal from "../components/shared/FileUploadModal"; + function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); + const { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode + } = useSidebarContext(); + + const { sidebarsVisible, leftPanelView, readerMode } = sidebarState; + const { quickAccessRef, toolPanelRef } = sidebarRefs; const fileContext = useFileContext(); const { activeFiles, currentView, setCurrentView } = fileContext; @@ -37,9 +50,6 @@ function HomePageContent() { clearToolSelection, } = useToolManagement(); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); @@ -92,16 +102,15 @@ function HomePageContent() { > {/* Quick Access Bar */} {/* Left: Tool Picker or Selected Tool Panel */}
- + + + ); } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 71443411f..1cf3581c4 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -103,6 +103,13 @@ --icon-config-bg: #9CA3AF; --icon-config-color: #FFFFFF; + /* Colors for tooltips */ + --tooltip-title-bg: #DBEFFF; + --tooltip-title-color: #31528E; + --tooltip-header-bg: #31528E; + --tooltip-header-color: white; + --tooltip-border: var(--border-default); + /* Inactive icon colors for light mode */ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; @@ -201,6 +208,13 @@ --icon-inactive-bg: #2A2F36; --icon-inactive-color: #6E7581; + /* Dark mode tooltip colors */ + --tooltip-title-bg: #4B525A; + --tooltip-title-color: #fff; + --tooltip-header-bg: var(--bg-raised); + --tooltip-header-color: var(--text-primary); + --tooltip-border: var(--border-default); + --accent-interactive: #ffffff; --text-instruction: #ffffff; --text-brand: var(--color-gray-800); @@ -224,6 +238,7 @@ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04)); + /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index cc0cd5cbc..f4b50b264 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -17,6 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; import { BaseToolProps } from "../types/tool"; +import { CompressTips } from "../components/tooltips/CompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); @@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); + const compressTips = CompressTips(); // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); @@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isCompleted={settingsCollapsed} onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} completedMessage={settingsCollapsed ? "Compression completed" : undefined} + tooltip={compressTips} > { const { t } = useTranslation(); @@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); + const ocrTips = OcrTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files'); @@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { setExpandedStep(expandedStep === 'settings' ? null : 'settings'); }} completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} + tooltip={ocrTips} > ; + toolPanelRef: React.RefObject; +} + +export interface SidebarInfo { + rect: DOMRect | null; + isToolPanelActive: boolean; + sidebarState: SidebarState; +} + +// Context-related interfaces +export interface SidebarContextValue { + sidebarState: SidebarState; + sidebarRefs: SidebarRefs; + setSidebarsVisible: React.Dispatch>; + setLeftPanelView: React.Dispatch>; + setReaderMode: React.Dispatch>; +} + +export interface SidebarProviderProps { + children: React.ReactNode; +} + +// QuickAccessBar related interfaces +export interface QuickAccessBarProps { + onToolsClick: () => void; + onReaderToggle: () => void; +} + +export interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; + type?: 'navigation' | 'modal' | 'action'; +} diff --git a/frontend/src/types/tips.ts b/frontend/src/types/tips.ts new file mode 100644 index 000000000..58519e114 --- /dev/null +++ b/frontend/src/types/tips.ts @@ -0,0 +1,13 @@ +export interface TooltipContent { + header?: { + title: string; + logo?: string | React.ReactNode; + }; + tips?: Array<{ + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; + }>; + content?: React.ReactNode; +} \ No newline at end of file diff --git a/frontend/src/utils/genericUtils.ts b/frontend/src/utils/genericUtils.ts new file mode 100644 index 000000000..253346292 --- /dev/null +++ b/frontend/src/utils/genericUtils.ts @@ -0,0 +1,42 @@ +/** + * DOM utility functions for common operations + */ + +/** + * Clamps a value between a minimum and maximum + * @param value - The value to clamp + * @param min - The minimum allowed value + * @param max - The maximum allowed value + * @returns The clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Safely adds an event listener with proper cleanup + * @param target - The target element or window/document + * @param event - The event type + * @param handler - The event handler function + * @param options - Event listener options + * @returns A cleanup function to remove the listener + */ +export function addEventListenerWithCleanup( + target: EventTarget, + event: string, + handler: EventListener, + options?: boolean | AddEventListenerOptions +): () => void { + target.addEventListener(event, handler, options); + return () => target.removeEventListener(event, handler, options); +} + +/** + * Checks if a click event occurred outside of a specified element + * @param event - The click event + * @param element - The element to check against + * @returns True if the click was outside the element + */ +export function isClickOutside(event: MouseEvent, element: HTMLElement | null): boolean { + return element ? !element.contains(event.target as Node) : true; +} diff --git a/frontend/src/utils/sidebarUtils.ts b/frontend/src/utils/sidebarUtils.ts new file mode 100644 index 000000000..cef144971 --- /dev/null +++ b/frontend/src/utils/sidebarUtils.ts @@ -0,0 +1,34 @@ +import { SidebarRefs, SidebarState, SidebarInfo } from '../types/sidebar'; + +/** + * Gets the All tools sidebar information using React refs and state + * @param refs - Object containing refs to sidebar elements + * @param state - Current sidebar state + * @returns Object containing the sidebar rect and whether the tool panel is active + */ +export function getSidebarInfo(refs: SidebarRefs, state: SidebarState): SidebarInfo { + const { quickAccessRef, toolPanelRef } = refs; + const { sidebarsVisible, readerMode } = state; + + // Determine if tool panel should be active based on state + const isToolPanelActive = sidebarsVisible && !readerMode; + + let rect: DOMRect | null = null; + + if (isToolPanelActive && toolPanelRef.current) { + // Tool panel is expanded: use its rect + rect = toolPanelRef.current.getBoundingClientRect(); + } else if (quickAccessRef.current) { + // Fall back to quick access bar + // This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this + rect = quickAccessRef.current.getBoundingClientRect(); + } + + return { + rect, + isToolPanelActive, + sidebarState: state + }; +} + + \ No newline at end of file From 7e3321ee16ebb74d282dcccad08fa14e2bc9a762 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:15:09 +0100 Subject: [PATCH 4/4] Feature/v2/filemanager (#4121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileManager Component Overview Purpose: Modal component for selecting and managing PDF files with preview capabilities Architecture: - Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx (3-column) - Central State: FileManagerContext handles file operations, selection, and modal state - File Storage: IndexedDB persistence with thumbnail caching Key Components: - FileSourceButtons: Switch between Recent/Local/Drive sources - FileListArea: Scrollable file grid with search functionality - FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow pages based on file count) - FileDetails: File info card with metadata - CompactFileDetails: Mobile-optimized file info layout File Flow: 1. Users select source → browse/search files → select multiple files → preview with navigation → open in tools 2. Files persist across tool switches via FileContext integration 3. Memory management handles large PDFs (up to 100GB+) ```mermaid graph TD FM[FileManager] --> ML[MobileLayout] FM --> DL[DesktopLayout] ML --> FSB[FileSourceButtons
Recent/Local/Drive] ML --> FLA[FileListArea] ML --> FD[FileDetails] DL --> FSB DL --> FLA DL --> FD FLA --> FLI[FileListItem] FD --> FP[FilePreview] FD --> CFD[CompactFileDetails] ``` --------- Co-authored-by: Connor Yoh --- .../public/locales/en-GB/translation.json | 20 +- frontend/src/components/FileManager.tsx | 168 ++++++++++++ .../fileManagement/StorageStatsCard.tsx | 92 ------- .../fileManager/CompactFileDetails.tsx | 126 +++++++++ .../components/fileManager/DesktopLayout.tsx | 89 ++++++ .../components/fileManager/DragOverlay.tsx | 44 +++ .../components/fileManager/FileDetails.tsx | 116 ++++++++ .../components/fileManager/FileInfoCard.tsx | 67 +++++ .../components/fileManager/FileListArea.tsx | 80 ++++++ .../components/fileManager/FileListItem.tsx | 84 ++++++ .../components/fileManager/FilePreview.tsx | 156 +++++++++++ .../fileManager/FileSourceButtons.tsx | 103 +++++++ .../fileManager/HiddenFileInput.tsx | 20 ++ .../components/fileManager/MobileLayout.tsx | 83 ++++++ .../components/fileManager/SearchInput.tsx | 33 +++ .../{fileManagement => shared}/FileCard.tsx | 1 + frontend/src/components/shared/FileGrid.tsx | 2 +- .../src/components/shared/FileUploadModal.tsx | 36 --- .../components/shared/FileUploadSelector.tsx | 255 ------------------ frontend/src/contexts/FileContext.tsx | 28 +- frontend/src/contexts/FileManagerContext.tsx | 218 +++++++++++++++ frontend/src/contexts/FilesModalContext.tsx | 55 +++- .../tools/convert/useConvertOperation.ts | 5 +- frontend/src/hooks/useFileManager.ts | 17 +- frontend/src/hooks/useFilesModal.ts | 57 ---- frontend/src/hooks/useIndexedDBThumbnail.ts | 81 ++++-- frontend/src/pages/HomePage.tsx | 4 +- frontend/src/services/fileStorage.ts | 26 ++ frontend/src/styles/theme.css | 6 + .../tests/convert/ConvertIntegration.test.tsx | 55 +++- .../ConvertSmartDetectionIntegration.test.tsx | 28 +- frontend/src/utils/fileUtils.ts | 4 +- frontend/src/utils/thumbnailUtils.ts | 165 +++++++++++- 33 files changed, 1818 insertions(+), 506 deletions(-) create mode 100644 frontend/src/components/FileManager.tsx delete mode 100644 frontend/src/components/fileManagement/StorageStatsCard.tsx create mode 100644 frontend/src/components/fileManager/CompactFileDetails.tsx create mode 100644 frontend/src/components/fileManager/DesktopLayout.tsx create mode 100644 frontend/src/components/fileManager/DragOverlay.tsx create mode 100644 frontend/src/components/fileManager/FileDetails.tsx create mode 100644 frontend/src/components/fileManager/FileInfoCard.tsx create mode 100644 frontend/src/components/fileManager/FileListArea.tsx create mode 100644 frontend/src/components/fileManager/FileListItem.tsx create mode 100644 frontend/src/components/fileManager/FilePreview.tsx create mode 100644 frontend/src/components/fileManager/FileSourceButtons.tsx create mode 100644 frontend/src/components/fileManager/HiddenFileInput.tsx create mode 100644 frontend/src/components/fileManager/MobileLayout.tsx create mode 100644 frontend/src/components/fileManager/SearchInput.tsx rename frontend/src/components/{fileManagement => shared}/FileCard.tsx (99%) delete mode 100644 frontend/src/components/shared/FileUploadModal.tsx delete mode 100644 frontend/src/components/shared/FileUploadSelector.tsx create mode 100644 frontend/src/contexts/FileManagerContext.tsx delete mode 100644 frontend/src/hooks/useFilesModal.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 089562ed6..ed3942172 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1733,7 +1733,25 @@ "storageError": "Storage error occurred", "storageLow": "Storage is running low. Consider removing old files.", "supportMessage": "Powered by browser database storage for unlimited capacity", - "noFileSelected": "No files selected" + "noFileSelected": "No files selected", + "searchFiles": "Search files...", + "recent": "Recent", + "localFiles": "Local Files", + "googleDrive": "Google Drive", + "googleDriveShort": "Drive", + "myFiles": "My Files", + "noRecentFiles": "No recent files found", + "dropFilesHint": "Drop files here to upload", + "googleDriveNotAvailable": "Google Drive integration not available", + "openFiles": "Open Files", + "openFile": "Open File", + "details": "File Details", + "fileName": "Name", + "fileFormat": "Format", + "fileSize": "Size", + "fileVersion": "Version", + "totalSelected": "Total Selected", + "dropFilesHere": "Drop files here" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx new file mode 100644 index 000000000..02f9af5e4 --- /dev/null +++ b/frontend/src/components/FileManager.tsx @@ -0,0 +1,168 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Modal } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { FileWithUrl } from '../types/file'; +import { useFileManager } from '../hooks/useFileManager'; +import { useFilesModalContext } from '../contexts/FilesModalContext'; +import { Tool } from '../types/tool'; +import MobileLayout from './fileManager/MobileLayout'; +import DesktopLayout from './fileManager/DesktopLayout'; +import DragOverlay from './fileManager/DragOverlay'; +import { FileManagerProvider } from '../contexts/FileManagerContext'; + +interface FileManagerProps { + selectedTool?: Tool | null; +} + +const FileManager: React.FC = ({ selectedTool }) => { + const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext(); + const [recentFiles, setRecentFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); + + // File management handlers + const isFileSupported = useCallback((fileName: string) => { + if (!selectedTool?.supportedFormats) return true; + const extension = fileName.split('.').pop()?.toLowerCase(); + return selectedTool.supportedFormats.includes(extension || ''); + }, [selectedTool?.supportedFormats]); + + const refreshRecentFiles = useCallback(async () => { + const files = await loadRecentFiles(); + setRecentFiles(files); + }, [loadRecentFiles]); + + const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => { + try { + const fileObjects = await Promise.all( + files.map(async (fileWithUrl) => { + return await convertToFile(fileWithUrl); + }) + ); + onFilesSelect(fileObjects); + } catch (error) { + console.error('Failed to process selected files:', error); + } + }, [convertToFile, onFilesSelect]); + + const handleNewFileUpload = useCallback(async (files: File[]) => { + if (files.length > 0) { + try { + // Files will get IDs assigned through onFilesSelect -> FileContext addFiles + onFilesSelect(files); + await refreshRecentFiles(); + } catch (error) { + console.error('Failed to process dropped files:', error); + } + } + }, [onFilesSelect, refreshRecentFiles]); + + const handleRemoveFileByIndex = useCallback(async (index: number) => { + await handleRemoveFile(index, recentFiles, setRecentFiles); + }, [handleRemoveFile, recentFiles]); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 1030); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + useEffect(() => { + if (isFilesModalOpen) { + refreshRecentFiles(); + } else { + // Reset state when modal is closed + setIsDragging(false); + } + }, [isFilesModalOpen, refreshRecentFiles]); + + // Cleanup any blob URLs when component unmounts + useEffect(() => { + return () => { + // Clean up blob URLs from recent files + recentFiles.forEach(file => { + if (file.url && file.url.startsWith('blob:')) { + URL.revokeObjectURL(file.url); + } + }); + }; + }, [recentFiles]); + + // Modal size constants for consistent scaling + const modalHeight = '80vh'; + const modalWidth = isMobile ? '100%' : '80vw'; + const modalMaxWidth = isMobile ? '100%' : '1200px'; + const modalMaxHeight = '1200px'; + const modalMinWidth = isMobile ? '320px' : '800px'; + + return ( + +
+ setIsDragging(true)} + onDragLeave={() => setIsDragging(false)} + accept={["*/*"]} + multiple={true} + activateOnClick={false} + style={{ + height: '100%', + width: '100%', + border: 'none', + borderRadius: '30px', + backgroundColor: 'var(--bg-file-manager)' + }} + styles={{ + inner: { pointerEvents: 'all' } + }} + > + + {isMobile ? : } + + + + +
+
+ ); +}; + +export default FileManager; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/StorageStatsCard.tsx b/frontend/src/components/fileManagement/StorageStatsCard.tsx deleted file mode 100644 index 2d2488712..000000000 --- a/frontend/src/components/fileManagement/StorageStatsCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import StorageIcon from "@mui/icons-material/Storage"; -import DeleteIcon from "@mui/icons-material/Delete"; -import WarningIcon from "@mui/icons-material/Warning"; -import { StorageStats } from "../../services/fileStorage"; -import { formatFileSize } from "../../utils/fileUtils"; -import { getStorageUsagePercent } from "../../utils/storageUtils"; -import { StorageConfig } from "../../types/file"; - -interface StorageStatsCardProps { - storageStats: StorageStats | null; - filesCount: number; - onClearAll: () => void; - onReloadFiles: () => void; - storageConfig: StorageConfig; -} - -const StorageStatsCard = ({ - storageStats, - filesCount, - onClearAll, - onReloadFiles, - storageConfig, -}: StorageStatsCardProps) => { - const { t } = useTranslation(); - - if (!storageStats) return null; - - const storageUsagePercent = getStorageUsagePercent(storageStats); - const totalUsed = storageStats.totalSize || storageStats.used; - const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100; - const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100; - - return ( - - - - -
- - {t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)} - - 60 ? "yellow" : "blue"} - size="sm" - mt={4} - /> - - - {storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")} - - - {Math.round(hardLimitPercent)}% used - - - {isNearLimit && ( - - {t("storage.storageFull", "Storage is nearly full. Consider removing some files.")} - - )} -
- - {filesCount > 0 && ( - - )} - - -
-
-
- ); -}; - -export default StorageStatsCard; diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx new file mode 100644 index 000000000..7f7c410b7 --- /dev/null +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useTranslation } from 'react-i18next'; +import { getFileSize } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface CompactFileDetailsProps { + currentFile: FileWithUrl | null; + thumbnail: string | null; + selectedFiles: FileWithUrl[]; + currentFileIndex: number; + numberOfFiles: number; + isAnimating: boolean; + onPrevious: () => void; + onNext: () => void; + onOpenFiles: () => void; +} + +const CompactFileDetails: React.FC = ({ + currentFile, + thumbnail, + selectedFiles, + currentFileIndex, + numberOfFiles, + isAnimating, + onPrevious, + onNext, + onOpenFiles +}) => { + const { t } = useTranslation(); + const hasSelection = selectedFiles.length > 0; + const hasMultipleFiles = numberOfFiles > 1; + + return ( + + {/* Compact mobile layout */} + + {/* Small preview */} + + {currentFile && thumbnail ? ( + {currentFile.name} + ) : currentFile ? ( +
+ +
+ ) : null} +
+ + {/* File info */} + + + {currentFile ? currentFile.name : 'No file selected'} + + + {currentFile ? getFileSize(currentFile) : ''} + {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} + + {hasMultipleFiles && ( + + {currentFileIndex + 1} of {selectedFiles.length} + + )} + + + {/* Navigation arrows for multiple files */} + {hasMultipleFiles && ( + + + + + + + + + )} +
+ + {/* Action Button */} + +
+ ); +}; + +export default CompactFileDetails; \ No newline at end of file diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx new file mode 100644 index 000000000..be701ff20 --- /dev/null +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Grid } from '@mantine/core'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const DesktopLayout: React.FC = () => { + const { + activeSource, + recentFiles, + modalHeight, + } = useFileManagerContext(); + + return ( + + {/* Column 1: File Sources */} + + + + + {/* Column 2: File List */} + +
+ {activeSource === 'recent' && ( +
+ +
+ )} + +
+ 0 ? modalHeight : '100%', + backgroundColor: 'transparent', + border: 'none', + borderRadius: 0 + }} + /> +
+
+
+ + {/* Column 3: File Details */} + +
+ +
+
+ + {/* Hidden file input for local file selection */} + +
+ ); +}; + +export default DesktopLayout; \ No newline at end of file diff --git a/frontend/src/components/fileManager/DragOverlay.tsx b/frontend/src/components/fileManager/DragOverlay.tsx new file mode 100644 index 000000000..976bb940e --- /dev/null +++ b/frontend/src/components/fileManager/DragOverlay.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Stack, Text, useMantineTheme, alpha } from '@mantine/core'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { useTranslation } from 'react-i18next'; + +interface DragOverlayProps { + isVisible: boolean; +} + +const DragOverlay: React.FC = ({ isVisible }) => { + const { t } = useTranslation(); + const theme = useMantineTheme(); + + if (!isVisible) return null; + + return ( +
+ + + + {t('fileManager.dropFilesHere', 'Drop files here to upload')} + + +
+ ); +}; + +export default DragOverlay; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx new file mode 100644 index 000000000..9673d06ad --- /dev/null +++ b/frontend/src/components/fileManager/FileDetails.tsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from 'react'; +import { Stack, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import FilePreview from './FilePreview'; +import FileInfoCard from './FileInfoCard'; +import CompactFileDetails from './CompactFileDetails'; + +interface FileDetailsProps { + compact?: boolean; +} + +const FileDetails: React.FC = ({ + compact = false +}) => { + const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); + const { t } = useTranslation(); + const [currentFileIndex, setCurrentFileIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + + // Get the currently displayed file + const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; + const hasSelection = selectedFiles.length > 0; + const hasMultipleFiles = selectedFiles.length > 1; + + // Use IndexedDB hook for the current file + const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile); + + // Get thumbnail for current file + const getCurrentThumbnail = () => { + return currentThumbnail; + }; + + const handlePrevious = () => { + if (isAnimating) return; + setIsAnimating(true); + setTimeout(() => { + setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1); + setIsAnimating(false); + }, 150); + }; + + const handleNext = () => { + if (isAnimating) return; + setIsAnimating(true); + setTimeout(() => { + setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0); + setIsAnimating(false); + }, 150); + }; + + // Reset index when selection changes + React.useEffect(() => { + if (currentFileIndex >= selectedFiles.length) { + setCurrentFileIndex(0); + } + }, [selectedFiles.length, currentFileIndex]); + + if (compact) { + return ( + + ); + } + + return ( + + {/* Section 1: Thumbnail Preview */} + + + {/* Section 2: File Details */} + + + + + ); +}; + +export default FileDetails; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx new file mode 100644 index 000000000..7e69dd2ed --- /dev/null +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface FileInfoCardProps { + currentFile: FileWithUrl | null; + modalHeight: string; +} + +const FileInfoCard: React.FC = ({ + currentFile, + modalHeight +}) => { + const { t } = useTranslation(); + + return ( + + + + {t('fileManager.details', 'File Details')} + + + + + + {t('fileManager.fileName', 'Name')} + + {currentFile ? currentFile.name : ''} + + + + + + {t('fileManager.fileFormat', 'Format')} + {currentFile ? ( + + {detectFileExtension(currentFile.name).toUpperCase()} + + ) : ( + + )} + + + + + {t('fileManager.fileSize', 'Size')} + + {currentFile ? getFileSize(currentFile) : ''} + + + + + + {t('fileManager.fileVersion', 'Version')} + + {currentFile ? '1.0' : ''} + + + + + + ); +}; + +export default FileInfoCard; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx new file mode 100644 index 000000000..8e1975137 --- /dev/null +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Center, ScrollArea, Text, Stack } from '@mantine/core'; +import CloudIcon from '@mui/icons-material/Cloud'; +import HistoryIcon from '@mui/icons-material/History'; +import { useTranslation } from 'react-i18next'; +import FileListItem from './FileListItem'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface FileListAreaProps { + scrollAreaHeight: string; + scrollAreaStyle?: React.CSSProperties; +} + +const FileListArea: React.FC = ({ + scrollAreaHeight, + scrollAreaStyle = {}, +}) => { + const { + activeSource, + recentFiles, + filteredFiles, + selectedFileIds, + onFileSelect, + onFileRemove, + onFileDoubleClick, + isFileSupported, + } = useFileManagerContext(); + const { t } = useTranslation(); + + if (activeSource === 'recent') { + return ( + + + {recentFiles.length === 0 ? ( +
+ + + {t('fileManager.noRecentFiles', 'No recent files')} + + {t('fileManager.dropFilesHint', 'Drop files anywhere to upload')} + + +
+ ) : ( + filteredFiles.map((file, index) => ( + onFileSelect(file)} + onRemove={() => onFileRemove(index)} + onDoubleClick={() => onFileDoubleClick(file)} + /> + )) + )} +
+
+ ); + } + + // Google Drive placeholder + return ( +
+ + + {t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')} + +
+ ); +}; + +export default FileListArea; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx new file mode 100644 index 000000000..147133009 --- /dev/null +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { getFileSize, getFileDate } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface FileListItemProps { + file: FileWithUrl; + isSelected: boolean; + isSupported: boolean; + onSelect: () => void; + onRemove: () => void; + onDoubleClick?: () => void; + isLast?: boolean; +} + +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, + onRemove, + onDoubleClick +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + {}} // Handled by parent onClick + size="sm" + pl="sm" + pr="xs" + styles={{ + input: { + cursor: 'pointer' + } + }} + /> + + + + {file.name} + {getFileSize(file)} • {getFileDate(file)} + + {/* Delete button - fades in/out on hover */} + { e.stopPropagation(); onRemove(); }} + style={{ + opacity: isHovered ? 1 : 0, + transform: isHovered ? 'scale(1)' : 'scale(0.8)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: isHovered ? 'auto' : 'none' + }} + > + + + + + { } + + ); +}; + +export default FileListItem; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FilePreview.tsx b/frontend/src/components/fileManager/FilePreview.tsx new file mode 100644 index 000000000..deb4cc67b --- /dev/null +++ b/frontend/src/components/fileManager/FilePreview.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { Box, Center, ActionIcon, Image } from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { FileWithUrl } from '../../types/file'; + +interface FilePreviewProps { + currentFile: FileWithUrl | null; + thumbnail: string | null; + numberOfFiles: number; + isAnimating: boolean; + modalHeight: string; + onPrevious: () => void; + onNext: () => void; +} + +const FilePreview: React.FC = ({ + currentFile, + thumbnail, + numberOfFiles, + isAnimating, + modalHeight, + onPrevious, + onNext +}) => { + const hasMultipleFiles = numberOfFiles > 1; + // Common style objects + const navigationArrowStyle = { + position: 'absolute' as const, + top: '50%', + transform: 'translateY(-50%)', + zIndex: 10 + }; + + const stackDocumentBaseStyle = { + position: 'absolute' as const, + width: '100%', + height: '100%' + }; + + const animationStyle = { + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)', + opacity: isAnimating ? 0.7 : 1 + }; + + const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)'; + const stackDocumentShadows = { + back: '0 2px 8px rgba(0, 0, 0, 0.1)', + middle: '0 3px 10px rgba(0, 0, 0, 0.12)' + }; + + return ( + + + {/* Left Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} + + {/* Document Stack Container */} + + {/* Background documents (stack effect) */} + {/* Show 2 shadow pages for 3+ files */} + {numberOfFiles >= 3 && ( + + )} + + {/* Show 1 shadow page for 2+ files */} + {numberOfFiles >= 2 && ( + + )} + + {/* Main document */} + {currentFile && thumbnail ? ( + {currentFile.name} + ) : currentFile ? ( +
+ +
+ ) : null} +
+ + {/* Right Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} +
+
+ ); +}; + +export default FilePreview; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx new file mode 100644 index 000000000..a6870a661 --- /dev/null +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Stack, Text, Button, Group } from '@mantine/core'; +import HistoryIcon from '@mui/icons-material/History'; +import FolderIcon from '@mui/icons-material/Folder'; +import CloudIcon from '@mui/icons-material/Cloud'; +import { useTranslation } from 'react-i18next'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface FileSourceButtonsProps { + horizontal?: boolean; +} + +const FileSourceButtons: React.FC = ({ + horizontal = false +}) => { + const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext(); + const { t } = useTranslation(); + + const buttonProps = { + variant: (source: string) => activeSource === source ? 'filled' : 'subtle', + getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined, + getStyles: (source: string) => ({ + root: { + backgroundColor: activeSource === source ? undefined : 'transparent', + color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)', + border: 'none', + '&:hover': { + backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)' + } + } + }) + }; + + const buttons = ( + <> + + + + + + + ); + + if (horizontal) { + return ( + + {buttons} + + ); + } + + return ( + + + {t('fileManager.myFiles', 'My Files')} + + {buttons} + + ); +}; + +export default FileSourceButtons; \ No newline at end of file diff --git a/frontend/src/components/fileManager/HiddenFileInput.tsx b/frontend/src/components/fileManager/HiddenFileInput.tsx new file mode 100644 index 000000000..6f2834267 --- /dev/null +++ b/frontend/src/components/fileManager/HiddenFileInput.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const HiddenFileInput: React.FC = () => { + const { fileInputRef, onFileInputChange } = useFileManagerContext(); + + return ( + + ); +}; + +export default HiddenFileInput; \ No newline at end of file diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx new file mode 100644 index 000000000..30d1ad6b9 --- /dev/null +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Stack, Box } from '@mantine/core'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const MobileLayout: React.FC = () => { + const { + activeSource, + selectedFiles, + modalHeight, + } = useFileManagerContext(); + + // Calculate the height more accurately based on actual content + const calculateFileListHeight = () => { + // Base modal height minus padding and gaps + const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding + + // Estimate heights of fixed components + const fileSourceHeight = '3rem'; // FileSourceButtons height + const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height + const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height + const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps + + return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`; + }; + + return ( + + {/* Section 1: File Sources - Fixed at top */} + + + + + + + + + {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */} + + {activeSource === 'recent' && ( + + + + )} + + + + + + + {/* Hidden file input for local file selection */} + + + ); +}; + +export default MobileLayout; \ No newline at end of file diff --git a/frontend/src/components/fileManager/SearchInput.tsx b/frontend/src/components/fileManager/SearchInput.tsx new file mode 100644 index 000000000..f47da0dca --- /dev/null +++ b/frontend/src/components/fileManager/SearchInput.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { TextInput } from '@mantine/core'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslation } from 'react-i18next'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface SearchInputProps { + style?: React.CSSProperties; +} + +const SearchInput: React.FC = ({ style }) => { + const { t } = useTranslation(); + const { searchTerm, onSearchChange } = useFileManagerContext(); + + return ( + } + value={searchTerm} + onChange={(e) => onSearchChange(e.target.value)} + + style={{ padding: '0.5rem', ...style }} + styles={{ + input: { + border: 'none', + backgroundColor: 'transparent' + } + }} + /> + ); +}; + +export default SearchInput; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx similarity index 99% rename from frontend/src/components/fileManagement/FileCard.tsx rename to frontend/src/components/shared/FileCard.tsx index d474a2f63..1b686ddaf 100644 --- a/frontend/src/components/fileManagement/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage"; import VisibilityIcon from "@mui/icons-material/Visibility"; import EditIcon from "@mui/icons-material/Edit"; +import { FileWithUrl } from "../../types/file"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { fileStorage } from "../../services/fileStorage"; diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 78b5a8f17..791a8a453 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti import { useTranslation } from "react-i18next"; import SearchIcon from "@mui/icons-material/Search"; import SortIcon from "@mui/icons-material/Sort"; -import FileCard from "../fileManagement/FileCard"; +import FileCard from "./FileCard"; import { FileWithUrl } from "../../types/file"; interface FileGridProps { diff --git a/frontend/src/components/shared/FileUploadModal.tsx b/frontend/src/components/shared/FileUploadModal.tsx deleted file mode 100644 index a83e96e62..000000000 --- a/frontend/src/components/shared/FileUploadModal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Modal } from '@mantine/core'; -import FileUploadSelector from './FileUploadSelector'; -import { useFilesModalContext } from '../../contexts/FilesModalContext'; -import { Tool } from '../../types/tool'; - -interface FileUploadModalProps { - selectedTool?: Tool | null; -} - -const FileUploadModal: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext(); - - - return ( - - - - ); -}; - -export default FileUploadModal; \ No newline at end of file diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx deleted file mode 100644 index 3f345f24b..000000000 --- a/frontend/src/components/shared/FileUploadSelector.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useTranslation } from 'react-i18next'; -import { fileStorage } from '../../services/fileStorage'; -import { FileWithUrl } from '../../types/file'; -import { detectFileExtension } from '../../utils/fileUtils'; -import FileGrid from './FileGrid'; -import MultiSelectControls from './MultiSelectControls'; -import { useFileManager } from '../../hooks/useFileManager'; - -interface FileUploadSelectorProps { - // Appearance - title?: string; - subtitle?: string; - showDropzone?: boolean; - - // File handling - sharedFiles?: any[]; - onFileSelect?: (file: File) => void; - onFilesSelect: (files: File[]) => void; - accept?: string[]; - supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png']) - - // Loading state - loading?: boolean; - disabled?: boolean; - - // Recent files - showRecentFiles?: boolean; - maxRecentFiles?: number; -} - -const FileUploadSelector = ({ - title, - subtitle, - showDropzone = true, - sharedFiles = [], - onFileSelect, - onFilesSelect, - accept = ["application/pdf", "application/zip", "application/x-zip-compressed"], - supportedExtensions = ["pdf"], // Default to PDF only for most tools - loading = false, - disabled = false, - showRecentFiles = true, - maxRecentFiles = 8, -}: FileUploadSelectorProps) => { - const { t } = useTranslation(); - const fileInputRef = useRef(null); - - const [recentFiles, setRecentFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - - const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager(); - - // Utility function to check if a file extension is supported - const isFileSupported = useCallback((fileName: string): boolean => { - const extension = detectFileExtension(fileName); - return extension ? supportedExtensions.includes(extension) : false; - }, [supportedExtensions]); - - const refreshRecentFiles = useCallback(async () => { - const files = await loadRecentFiles(); - setRecentFiles(files); - }, [loadRecentFiles]); - - const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => { - if (uploadedFiles.length === 0) return; - - if (showRecentFiles) { - try { - for (const file of uploadedFiles) { - await storeFile(file); - } - refreshRecentFiles(); - } catch (error) { - console.error('Failed to save files to recent:', error); - } - } - - if (onFilesSelect) { - onFilesSelect(uploadedFiles); - } else if (onFileSelect) { - onFileSelect(uploadedFiles[0]); - } - }, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]); - - const handleFileInputChange = useCallback((event: React.ChangeEvent) => { - const files = event.target.files; - if (files && files.length > 0) { - const fileArray = Array.from(files); - console.log('File input change:', fileArray.length, 'files'); - handleNewFileUpload(fileArray); - } - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, [handleNewFileUpload]); - - const openFileDialog = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => { - try { - const fileObj = await convertToFile(file); - if (onFilesSelect) { - onFilesSelect([fileObj]); - } else if (onFileSelect) { - onFileSelect(fileObj); - } - } catch (error) { - console.error('Failed to load file from recent:', error); - } - }, [onFileSelect, onFilesSelect, convertToFile]); - - const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles); - - const handleSelectedRecentFiles = useCallback(async () => { - if (onFilesSelect) { - await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect); - } - }, [recentFiles, onFilesSelect, selectionHandlers]); - - const handleRemoveFileByIndex = useCallback(async (index: number) => { - await handleRemoveFile(index, recentFiles, setRecentFiles); - const file = recentFiles[index]; - setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name))); - }, [handleRemoveFile, recentFiles]); - - useEffect(() => { - if (showRecentFiles) { - refreshRecentFiles(); - } - }, [showRecentFiles, refreshRecentFiles]); - - // Get default title and subtitle from translations if not provided - const displayTitle = title || t("fileUpload.selectFiles", "Select files"); - const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs"); - - return ( - <> - - {/* Title and description */} - - - - {displayTitle} - - - {displaySubtitle} - - - - {/* Action buttons */} - - - {showDropzone ? ( - -
- - - {t("fileUpload.dropFilesHere", "Drop files here or click to upload")} - - - {accept.includes('application/pdf') && accept.includes('application/zip') - ? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files") - : accept.includes('application/pdf') - ? t("fileUpload.pdfFilesOnly", "PDF files only") - : t("fileUpload.supportedFileTypes", "Supported file types") - } - - -
-
- ) : ( - - - - {/* Manual file input as backup */} - - - )} -
- - {/* Recent Files Section */} - {showRecentFiles && recentFiles.length > 0 && ( - - - - {t("fileUpload.recentFiles", "Recent Files")} - - { - await Promise.all(recentFiles.map(async (file) => { - await fileStorage.deleteFile(file.id || file.name); - })); - setRecentFiles([]); - setSelectedFiles([]); - }} - /> - - { - await Promise.all(recentFiles.map(async (file) => { - await fileStorage.deleteFile(file.id || file.name); - })); - setRecentFiles([]); - setSelectedFiles([]); - }} - /> - - )} -
- - ); -}; - -export default FileUploadSelector; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 6e8a42fab..f84d2ec8b 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): case 'REMOVE_FILES': const remainingFiles = state.activeFiles.filter(file => { const fileId = getFileId(file); - return !action.payload.includes(fileId); + return !fileId || !action.payload.includes(fileId); }); const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; return { @@ -491,26 +491,38 @@ export function FileContextProvider({ }, [cleanupFile]); // Action implementations - const addFiles = useCallback(async (files: File[]) => { + const addFiles = useCallback(async (files: File[]): Promise => { dispatch({ type: 'ADD_FILES', payload: files }); // Auto-save to IndexedDB if persistence enabled if (enablePersistence) { for (const file of files) { try { - // Check if file already has an ID (already in IndexedDB) + // Check if file already has an explicit ID property (already in IndexedDB) const fileId = getFileId(file); if (!fileId) { - // File doesn't have ID, store it and get the ID - const storedFile = await fileStorage.storeFile(file); - // Add the ID to the file object - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + // File doesn't have explicit ID, store it with thumbnail + try { + // Generate thumbnail for better recent files experience + const thumbnail = await thumbnailGenerationService.generateThumbnail(file); + const storedFile = await fileStorage.storeFile(file, thumbnail); + // Add the ID to the file object + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } catch (thumbnailError) { + // If thumbnail generation fails, store without thumbnail + console.warn('Failed to generate thumbnail, storing without:', thumbnailError); + const storedFile = await fileStorage.storeFile(file); + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } } } catch (error) { console.error('Failed to store file:', error); } } } + + // Return files with their IDs assigned + return files; }, [enablePersistence]); const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => { @@ -682,7 +694,7 @@ export function FileContextProvider({ const getFileById = useCallback((fileId: string): File | undefined => { return state.activeFiles.find(file => { const actualFileId = getFileId(file); - return actualFileId === fileId; + return actualFileId && actualFileId === fileId; }); }, [state.activeFiles]); diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx new file mode 100644 index 000000000..c7f924e8e --- /dev/null +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -0,0 +1,218 @@ +import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; +import { FileWithUrl } from '../types/file'; +import { StoredFile } from '../services/fileStorage'; + +// Type for the context value - now contains everything directly +interface FileManagerContextValue { + // State + activeSource: 'recent' | 'local' | 'drive'; + selectedFileIds: string[]; + searchTerm: string; + selectedFiles: FileWithUrl[]; + filteredFiles: FileWithUrl[]; + fileInputRef: React.RefObject; + + // Handlers + onSourceChange: (source: 'recent' | 'local' | 'drive') => void; + onLocalFileClick: () => void; + onFileSelect: (file: FileWithUrl) => void; + onFileRemove: (index: number) => void; + onFileDoubleClick: (file: FileWithUrl) => void; + onOpenFiles: () => void; + onSearchChange: (value: string) => void; + onFileInputChange: (event: React.ChangeEvent) => void; + + // External props + recentFiles: FileWithUrl[]; + isFileSupported: (fileName: string) => boolean; + modalHeight: string; +} + +// Create the context +const FileManagerContext = createContext(null); + +// Provider component props +interface FileManagerProviderProps { + children: React.ReactNode; + recentFiles: FileWithUrl[]; + onFilesSelected: (files: FileWithUrl[]) => void; + onClose: () => void; + isFileSupported: (fileName: string) => boolean; + isOpen: boolean; + onFileRemove: (index: number) => void; + modalHeight: string; + storeFile: (file: File) => Promise; + refreshRecentFiles: () => Promise; +} + +export const FileManagerProvider: React.FC = ({ + children, + recentFiles, + onFilesSelected, + onClose, + isFileSupported, + isOpen, + onFileRemove, + modalHeight, + storeFile, + refreshRecentFiles, +}) => { + const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); + const [selectedFileIds, setSelectedFileIds] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const fileInputRef = useRef(null); + + // Track blob URLs for cleanup + const createdBlobUrls = useRef>(new Set()); + + // Computed values (with null safety) + const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name)); + const filteredFiles = (recentFiles || []).filter(file => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => { + setActiveSource(source); + if (source !== 'recent') { + setSelectedFileIds([]); + setSearchTerm(''); + } + }, []); + + const handleLocalFileClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileSelect = useCallback((file: FileWithUrl) => { + setSelectedFileIds(prev => { + if (prev.includes(file.id)) { + return prev.filter(id => id !== file.id); + } else { + return [...prev, file.id]; + } + }); + }, []); + + const handleFileRemove = useCallback((index: number) => { + const fileToRemove = filteredFiles[index]; + if (fileToRemove) { + setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id)); + } + onFileRemove(index); + }, [filteredFiles, onFileRemove]); + + const handleFileDoubleClick = useCallback((file: FileWithUrl) => { + if (isFileSupported(file.name)) { + onFilesSelected([file]); + onClose(); + } + }, [isFileSupported, onFilesSelected, onClose]); + + const handleOpenFiles = useCallback(() => { + if (selectedFiles.length > 0) { + onFilesSelected(selectedFiles); + onClose(); + } + }, [selectedFiles, onFilesSelected, onClose]); + + const handleSearchChange = useCallback((value: string) => { + setSearchTerm(value); + }, []); + + const handleFileInputChange = useCallback(async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length > 0) { + try { + // Create FileWithUrl objects - FileContext will handle storage and ID assignment + const fileWithUrls = files.map(file => { + const url = URL.createObjectURL(file); + createdBlobUrls.current.add(url); + + return { + // No ID assigned here - FileContext will handle storage and ID assignment + name: file.name, + file, + url, + size: file.size, + lastModified: file.lastModified, + }; + }); + + onFilesSelected(fileWithUrls); + await refreshRecentFiles(); + onClose(); + } catch (error) { + console.error('Failed to process selected files:', error); + } + } + event.target.value = ''; + }, [storeFile, onFilesSelected, refreshRecentFiles, onClose]); + + // Cleanup blob URLs when component unmounts + useEffect(() => { + return () => { + // Clean up all created blob URLs + createdBlobUrls.current.forEach(url => { + URL.revokeObjectURL(url); + }); + createdBlobUrls.current.clear(); + }; + }, []); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setActiveSource('recent'); + setSelectedFileIds([]); + setSearchTerm(''); + } + }, [isOpen]); + + const contextValue: FileManagerContextValue = { + // State + activeSource, + selectedFileIds, + searchTerm, + selectedFiles, + filteredFiles, + fileInputRef, + + // Handlers + onSourceChange: handleSourceChange, + onLocalFileClick: handleLocalFileClick, + onFileSelect: handleFileSelect, + onFileRemove: handleFileRemove, + onFileDoubleClick: handleFileDoubleClick, + onOpenFiles: handleOpenFiles, + onSearchChange: handleSearchChange, + onFileInputChange: handleFileInputChange, + + // External props + recentFiles, + isFileSupported, + modalHeight, + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the context +export const useFileManagerContext = (): FileManagerContextValue => { + const context = useContext(FileManagerContext); + + if (!context) { + throw new Error( + 'useFileManagerContext must be used within a FileManagerProvider. ' + + 'Make sure you wrap your component with .' + ); + } + + return context; +}; + +// Export the context for advanced use cases +export { FileManagerContext }; \ No newline at end of file diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index 6940ab9e7..788db77bd 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -1,21 +1,58 @@ -import React, { createContext, useContext } from 'react'; -import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal'; +import React, { createContext, useContext, useState, useCallback } from 'react'; import { useFileHandler } from '../hooks/useFileHandler'; -interface FilesModalContextType extends UseFilesModalReturn {} +interface FilesModalContextType { + isFilesModalOpen: boolean; + openFilesModal: () => void; + closeFilesModal: () => void; + onFileSelect: (file: File) => void; + onFilesSelect: (files: File[]) => void; + onModalClose: () => void; + setOnModalClose: (callback: () => void) => void; +} const FilesModalContext = createContext(null); export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { addToActiveFiles, addMultipleFiles } = useFileHandler(); - - const filesModal = useFilesModal({ - onFileSelect: addToActiveFiles, - onFilesSelect: addMultipleFiles, - }); + const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + + const openFilesModal = useCallback(() => { + setIsFilesModalOpen(true); + }, []); + + const closeFilesModal = useCallback(() => { + setIsFilesModalOpen(false); + onModalClose?.(); + }, [onModalClose]); + + const handleFileSelect = useCallback((file: File) => { + addToActiveFiles(file); + closeFilesModal(); + }, [addToActiveFiles, closeFilesModal]); + + const handleFilesSelect = useCallback((files: File[]) => { + addMultipleFiles(files); + closeFilesModal(); + }, [addMultipleFiles, closeFilesModal]); + + const setModalCloseCallback = useCallback((callback: () => void) => { + setOnModalClose(() => callback); + }, []); + + const contextValue: FilesModalContextType = { + isFilesModalOpen, + openFilesModal, + closeFilesModal, + onFileSelect: handleFileSelect, + onFilesSelect: handleFilesSelect, + onModalClose, + setOnModalClose: setModalCloseCallback, + }; return ( - + {children} ); diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 3e12ec9e8..ada920e0b 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -358,7 +358,10 @@ export const useConvertOperation = (): ConvertOperationHook => { setDownloadFilename(convertedFile.name); setStatus(t("downloadComplete")); - await processResults(new Blob([convertedFile]), convertedFile.name); + // Update local files state for hook consumers + setFiles([convertedFile]); + + await addFiles([convertedFile]); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index d8e776f75..efb6724eb 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { fileStorage } from '../services/fileStorage'; import { FileWithUrl } from '../types/file'; +import { generateThumbnailForFile } from '../utils/thumbnailUtils'; export const useFileManager = () => { const [loading, setLoading] = useState(false); @@ -63,7 +64,12 @@ export const useFileManager = () => { const storeFile = useCallback(async (file: File) => { try { - const storedFile = await fileStorage.storeFile(file); + // Generate thumbnail for the file + const thumbnail = await generateThumbnailForFile(file); + + // Store file with thumbnail + const storedFile = await fileStorage.storeFile(file, thumbnail); + // Add the ID to the file object Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); return storedFile; @@ -111,12 +117,21 @@ export const useFileManager = () => { }; }, [convertToFile]); + const touchFile = useCallback(async (id: string) => { + try { + await fileStorage.touchFile(id); + } catch (error) { + console.error('Failed to touch file:', error); + } + }, []); + return { loading, convertToFile, loadRecentFiles, handleRemoveFile, storeFile, + touchFile, createFileSelectionHandlers }; }; \ No newline at end of file diff --git a/frontend/src/hooks/useFilesModal.ts b/frontend/src/hooks/useFilesModal.ts deleted file mode 100644 index 49e9f2c5e..000000000 --- a/frontend/src/hooks/useFilesModal.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useCallback } from 'react'; - -export interface UseFilesModalReturn { - isFilesModalOpen: boolean; - openFilesModal: () => void; - closeFilesModal: () => void; - onFileSelect?: (file: File) => void; - onFilesSelect?: (files: File[]) => void; - onModalClose?: () => void; - setOnModalClose: (callback: () => void) => void; -} - -interface UseFilesModalProps { - onFileSelect?: (file: File) => void; - onFilesSelect?: (files: File[]) => void; -} - -export const useFilesModal = ({ - onFileSelect, - onFilesSelect -}: UseFilesModalProps = {}): UseFilesModalReturn => { - const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); - const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); - - const openFilesModal = useCallback(() => { - setIsFilesModalOpen(true); - }, []); - - const closeFilesModal = useCallback(() => { - setIsFilesModalOpen(false); - onModalClose?.(); - }, [onModalClose]); - - const handleFileSelect = useCallback((file: File) => { - onFileSelect?.(file); - closeFilesModal(); - }, [onFileSelect, closeFilesModal]); - - const handleFilesSelect = useCallback((files: File[]) => { - onFilesSelect?.(files); - closeFilesModal(); - }, [onFilesSelect, closeFilesModal]); - - const setModalCloseCallback = useCallback((callback: () => void) => { - setOnModalClose(() => callback); - }, []); - - return { - isFilesModalOpen, - openFilesModal, - closeFilesModal, - onFileSelect: handleFileSelect, - onFilesSelect: handleFilesSelect, - onModalClose, - setOnModalClose: setModalCloseCallback, - }; -}; \ No newline at end of file diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index b8404e5fe..b8b2c669c 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,6 +1,22 @@ import { useState, useEffect } from "react"; -import { getDocument } from "pdfjs-dist"; import { FileWithUrl } from "../types/file"; +import { fileStorage } from "../services/fileStorage"; +import { generateThumbnailForFile } from "../utils/thumbnailUtils"; + +/** + * Calculate optimal scale for thumbnail generation + * Ensures high quality while preventing oversized renders + */ +function calculateThumbnailScale(pageViewport: { width: number; height: number }): number { + const maxWidth = 400; // Max thumbnail width + const maxHeight = 600; // Max thumbnail height + + const scaleX = maxWidth / pageViewport.width; + const scaleY = maxHeight / pageViewport.height; + + // Don't upscale, only downscale if needed + return Math.min(scaleX, scaleY, 1.0); +} /** * Hook for IndexedDB-aware thumbnail loading @@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): { return; } - // Second priority: for IndexedDB files without stored thumbnails, just use placeholder - if (file.storedInIndexedDB && file.id) { - // Don't generate thumbnails for files loaded from IndexedDB - just use placeholder - setThumb(null); - return; - } - - // Third priority: generate from blob for regular files during upload (small files only) - if (!file.storedInIndexedDB && file.size < 50 * 1024 * 1024 && !generating) { + // Second priority: generate thumbnail for any file type + if (file.size < 100 * 1024 * 1024 && !generating) { setGenerating(true); try { - const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 0.2 }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - if (context && !cancelled) { - await page.render({ canvasContext: context, viewport }).promise; - if (!cancelled) setThumb(canvas.toDataURL()); + let fileObject: File; + + // Handle IndexedDB files vs regular File objects + if (file.storedInIndexedDB && file.id) { + // For IndexedDB files, recreate File object from stored data + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + throw new Error('File not found in IndexedDB'); + } + fileObject = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + } else if (file.file) { + // For FileWithUrl objects that have a File object + fileObject = file.file; + } else if (file.id) { + // Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + throw new Error('File not found in IndexedDB and no File object available'); + } + fileObject = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + } else { + throw new Error('File object not available and no ID for IndexedDB lookup'); + } + + // Use the universal thumbnail generator + const thumbnail = await generateThumbnailForFile(fileObject); + if (!cancelled && thumbnail) { + setThumb(thumbnail); + } else if (!cancelled) { + setThumb(null); } - pdf.destroy(); // Clean up memory } catch (error) { - console.warn('Failed to generate thumbnail for regular file', file.name, error); + console.warn('Failed to generate thumbnail for file', file.name, error); if (!cancelled) setThumb(null); } finally { if (!cancelled) setGenerating(false); } } else { - // Large files or files without proper conditions - show placeholder + // Large files - generate placeholder setThumb(null); } } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 94a81ee6d..b7a352f0f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -20,7 +20,7 @@ import Viewer from "../components/viewer/Viewer"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; import LandingPage from "../components/shared/LandingPage"; -import FileUploadModal from "../components/shared/FileUploadModal"; +import FileManager from "../components/FileManager"; function HomePageContent() { @@ -279,7 +279,7 @@ function HomePageContent() {
{/* Global Modals */} - + ); } diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index 9ba2e7def..5fd5739e8 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -225,6 +225,32 @@ class FileStorageService { }); } + /** + * Update the lastModified timestamp of a file (for most recently used sorting) + */ + async touchFile(id: string): Promise { + if (!this.db) await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + + const getRequest = store.get(id); + getRequest.onsuccess = () => { + const file = getRequest.result; + if (file) { + // Update lastModified to current timestamp + file.lastModified = Date.now(); + const updateRequest = store.put(file); + updateRequest.onsuccess = () => resolve(true); + updateRequest.onerror = () => reject(updateRequest.error); + } else { + resolve(false); // File not found + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); + } + /** * Clear all stored files */ diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 1cf3581c4..9ec48bca7 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -74,6 +74,9 @@ --bg-muted: #f3f4f6; --bg-background: #f9fafb; --bg-toolbar: #ffffff; + --bg-file-manager: #F5F6F8; + --bg-file-list: #ffffff; + --btn-open-file: #0A8BFF; --text-primary: #111827; --text-secondary: #4b5563; --text-muted: #6b7280; @@ -175,6 +178,9 @@ --bg-muted: #1F2329; --bg-background: #2A2F36; --bg-toolbar: #272A2E; + --bg-file-manager: #1F2329; + --bg-file-list: #2A2F36; + --btn-open-file: #0A8BFF; --text-primary: #f9fafb; --text-secondary: #d1d5db; --text-muted: #9ca3af; diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index c9a636035..5ac978810 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -23,13 +23,31 @@ import axios from 'axios'; vi.mock('axios'); const mockedAxios = vi.mocked(axios); -// Mock utility modules -vi.mock('../../utils/thumbnailUtils', () => ({ - generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +// Mock only essential services that are actually called by the tests +vi.mock('../../services/fileStorage', () => ({ + fileStorage: { + init: vi.fn().mockResolvedValue(undefined), + storeFile: vi.fn().mockImplementation((file, thumbnail) => { + return Promise.resolve({ + id: `mock-id-${file.name}`, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + thumbnail: thumbnail + }); + }), + getAllFileMetadata: vi.fn().mockResolvedValue([]), + cleanup: vi.fn().mockResolvedValue(undefined) + } })); -vi.mock('../../utils/api', () => ({ - makeApiUrl: vi.fn((path: string) => `/api/v1${path}`) +vi.mock('../../services/thumbnailGenerationService', () => ({ + thumbnailGenerationService: { + generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'), + cleanup: vi.fn(), + destroy: vi.fn() + } })); // Create realistic test files @@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => { test('should correctly map image conversion parameters to API call', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/jpeg', + 'content-disposition': 'attachment; filename="test_converted.jpg"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => { test('should record operation in FileContext', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="test_converted.png"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => { test('should clean up blob URLs on reset', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="test_converted.png"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 3fac5b4ba..64aafc488 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils'; vi.mock('axios'); const mockedAxios = vi.mocked(axios); -// Mock utility modules -vi.mock('../../utils/thumbnailUtils', () => ({ - generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +// Mock only essential services that are actually called by the tests +vi.mock('../../services/fileStorage', () => ({ + fileStorage: { + init: vi.fn().mockResolvedValue(undefined), + storeFile: vi.fn().mockImplementation((file, thumbnail) => { + return Promise.resolve({ + id: `mock-id-${file.name}`, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + thumbnail: thumbnail + }); + }), + getAllFileMetadata: vi.fn().mockResolvedValue([]), + cleanup: vi.fn().mockResolvedValue(undefined) + } +})); + +vi.mock('../../services/thumbnailGenerationService', () => ({ + thumbnailGenerationService: { + generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'), + cleanup: vi.fn(), + destroy: vi.fn() + } })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index b42d2f646..682cd9f3c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,8 +1,8 @@ import { FileWithUrl } from "../types/file"; import { StoredFile, fileStorage } from "../services/fileStorage"; -export function getFileId(file: File): string { - return (file as File & { id?: string }).id || file.name; +export function getFileId(file: File): string | null { + return (file as File & { id?: string }).id || null; } /** diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 35444035a..f4f224044 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -15,19 +15,172 @@ export function calculateScaleFromFileSize(fileSize: number): number { } /** - * Generate thumbnail for a PDF file during upload + * Generate modern placeholder thumbnail with file extension + */ +function generatePlaceholderThumbnail(file: File): string { + const canvas = document.createElement('canvas'); + canvas.width = 120; + canvas.height = 150; + const ctx = canvas.getContext('2d')!; + + // Get file extension for color theming + const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE'; + const colorScheme = getFileTypeColorScheme(extension); + + // Create gradient background + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, colorScheme.bgTop); + gradient.addColorStop(1, colorScheme.bgBottom); + + // Rounded rectangle background + drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); + ctx.fillStyle = gradient; + ctx.fill(); + + // Subtle shadow/border + ctx.strokeStyle = colorScheme.border; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Modern document icon + drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon); + + // Extension badge + drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme); + + // File size with subtle styling + const sizeText = formatFileSize(file.size); + ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.textSecondary; + ctx.textAlign = 'center'; + ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); + + return canvas.toDataURL(); +} + +/** + * Get color scheme based on file extension + */ +function getFileTypeColorScheme(extension: string) { + const schemes: Record = { + // Documents + 'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Spreadsheets + 'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Presentations + 'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Archives + 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + '7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Default + 'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' } + }; + + return schemes[extension] || schemes['DEFAULT']; +} + +/** + * Draw rounded rectangle + */ +function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +/** + * Draw modern document icon + */ +function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) { + const size = 24; + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + // Document body + drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3); + ctx.fill(); + + // Folded corner + ctx.beginPath(); + ctx.moveTo(centerX + size/2 - 6, centerY - size/2); + ctx.lineTo(centerX + size/2, centerY - size/2 + 6); + ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6); + ctx.closePath(); + ctx.fillStyle = '#FFFFFF40'; + ctx.fill(); +} + +/** + * Draw extension badge + */ +function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) { + const badgeWidth = Math.max(extension.length * 8 + 16, 40); + const badgeHeight = 22; + + // Badge background + drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11); + ctx.fillStyle = colorScheme.badge; + ctx.fill(); + + // Badge text + ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.textPrimary; + ctx.textAlign = 'center'; + ctx.fillText(extension, centerX, centerY + 4); +} + +/** + * Format file size for display + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + + +/** + * Generate thumbnail for any file type * Returns base64 data URL or undefined if generation fails */ export async function generateThumbnailForFile(file: File): Promise { - // Skip thumbnail generation for large files to avoid memory issues - if (file.size >= 50 * 1024 * 1024) { // 50MB limit + // Skip thumbnail generation for very large files to avoid memory issues + if (file.size >= 100 * 1024 * 1024) { // 100MB limit console.log('Skipping thumbnail generation for large file:', file.name); - return undefined; + return generatePlaceholderThumbnail(file); } + // Handle image files - use original file directly + if (file.type.startsWith('image/')) { + return URL.createObjectURL(file); + } + + // Handle PDF files if (!file.type.startsWith('application/pdf')) { - console.warn('File is not a PDF, skipping thumbnail generation:', file.name); - return undefined; + console.log('File is not a PDF or image, generating placeholder:', file.name); + return generatePlaceholderThumbnail(file); } try {