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 1/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 { From 7735ea7f48689d85e37773e756a9a0443f4c87c0 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 8 Aug 2025 15:50:59 +0100 Subject: [PATCH 2/4] Enforce 2 space indentation in editorconfig (#4149) # Description of Changes Add missing config for JS and TS files to enforce 2 space indentation in `.editorconfig` --- ## 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) - [x] 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) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] 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) - [x] 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) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] 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. Co-authored-by: James --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index d45455a7a..9faac4bf7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,7 +24,7 @@ indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false -[*.js] +[{*.js,*.jsx,*.ts,*.tsx}] indent_size = 2 [*.css] From b45d3a43d4fd63b3dc504433cd36c9f808d76aeb Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:56:20 +0100 Subject: [PATCH 3/4] V2 Restructure homepage (#4138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component Extraction & Context Refactor - Summary 🔧 What We Did - Extracted HomePage's 286-line monolithic component into focused parts - Created ToolPanel (105 lines) for tool selection UI - Created Workbench (203 lines) for view management - Created ToolWorkflowContext (220 lines) for centralized state - Reduced HomePage to 60 lines of provider setup - Eliminated all prop drilling - components use contexts directly 🏆 Why This is Good - Maintainability: Each component has single purpose, easy debugging/development - Architecture: Clean separation of concerns, future features easier to add - Code Quality: 105% more lines but organized/purposeful vs tangled spaghetti code --------- Co-authored-by: Connor Yoh Co-authored-by: James Brunton --- frontend/src/components/layout/Workbench.tsx | 160 +++++++++++ .../src/components/shared/QuickAccessBar.tsx | 25 +- frontend/src/components/tools/ToolPanel.tsx | 89 ++++++ frontend/src/components/tools/ToolPicker.tsx | 25 +- frontend/src/contexts/ToolWorkflowContext.tsx | 221 +++++++++++++++ frontend/src/pages/HomePage.tsx | 265 ++---------------- frontend/src/types/sidebar.ts | 6 - 7 files changed, 503 insertions(+), 288 deletions(-) create mode 100644 frontend/src/components/layout/Workbench.tsx create mode 100644 frontend/src/components/tools/ToolPanel.tsx create mode 100644 frontend/src/contexts/ToolWorkflowContext.tsx diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx new file mode 100644 index 000000000..b0c984ee8 --- /dev/null +++ b/frontend/src/components/layout/Workbench.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Box } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; +import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; +import { useFileHandler } from '../../hooks/useFileHandler'; +import { useFileContext } from '../../contexts/FileContext'; + +import TopControls from '../shared/TopControls'; +import FileEditor from '../fileEditor/FileEditor'; +import PageEditor from '../pageEditor/PageEditor'; +import PageEditorControls from '../pageEditor/PageEditorControls'; +import Viewer from '../viewer/Viewer'; +import ToolRenderer from '../tools/ToolRenderer'; +import LandingPage from '../shared/LandingPage'; + +// No props needed - component uses contexts directly +export default function Workbench() { + const { t } = useTranslation(); + const { isRainbowMode } = useRainbowThemeContext(); + + // Use context-based hooks to eliminate all prop drilling + const { activeFiles, currentView, setCurrentView } = useFileContext(); + const { + previewFile, + pageEditorFunctions, + sidebarsVisible, + setPreviewFile, + setPageEditorFunctions, + setSidebarsVisible + } = useWorkbenchState(); + + const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection(); + const { addToActiveFiles } = useFileHandler(); + + const handlePreviewClose = () => { + setPreviewFile(null); + const previousMode = sessionStorage.getItem('previousMode'); + if (previousMode === 'split') { + // Use context's handleToolSelect which coordinates tool selection and view changes + handleToolSelect('split'); + sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'compress') { + handleToolSelect('compress'); + sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'convert') { + handleToolSelect('convert'); + sessionStorage.removeItem('previousMode'); + } else { + setCurrentView('fileEditor' as any); + } + }; + + const renderMainContent = () => { + if (!activeFiles[0]) { + return ( + + ); + } + + switch (currentView) { + case "fileEditor": + return ( + { + setCurrentView("pageEditor" as any); + }, + onMergeFiles: (filesToMerge) => { + filesToMerge.forEach(addToActiveFiles); + setCurrentView("viewer" as any); + } + })} + /> + ); + + case "viewer": + return ( + + ); + + case "pageEditor": + return ( + <> + + {pageEditorFunctions && ( + + )} + + ); + + default: + // Check if it's a tool view + if (selectedToolKey && selectedTool) { + return ( + + ); + } + return ( + + ); + } + }; + + return ( + + {/* Top Controls */} + + + {/* Main content area */} + + {renderMainContent()} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index fb27b1c2c..7aed3632b 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -11,20 +11,18 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider"; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; -import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { ButtonConfig } from '../../types/sidebar'; import './QuickAccessBar.css'; function NavHeader({ activeButton, - setActiveButton, - onReaderToggle, - onToolsClick + setActiveButton }: { activeButton: string; setActiveButton: (id: string) => void; - onReaderToggle: () => void; - onToolsClick: () => void; }) { + const { handleReaderToggle, handleBackToTools } = useToolWorkflow(); return ( <>
@@ -60,8 +58,8 @@ function NavHeader({ variant="subtle" onClick={() => { setActiveButton('tools'); - onReaderToggle(); - onToolsClick(); + handleReaderToggle(); + handleBackToTools(); }} style={{ backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', @@ -84,12 +82,11 @@ function NavHeader({ ); } -const QuickAccessBar = forwardRef(({ - onToolsClick, - onReaderToggle, +const QuickAccessBar = forwardRef(({ }, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); + const { handleReaderToggle } = useToolWorkflow(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -110,7 +107,7 @@ const QuickAccessBar = forwardRef(({ type: 'navigation', onClick: () => { setActiveButton('read'); - onReaderToggle(); + handleReaderToggle(); } }, { @@ -218,9 +215,7 @@ const QuickAccessBar = forwardRef(({
diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx new file mode 100644 index 000000000..1551ea6c9 --- /dev/null +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; +import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext'; +import ToolPicker from './ToolPicker'; +import ToolRenderer from './ToolRenderer'; +import { useSidebarContext } from "../../contexts/SidebarContext"; +import rainbowStyles from '../../styles/rainbow.module.css'; + +// No props needed - component uses context + +export default function ToolPanel() { + const { t } = useTranslation(); + const { isRainbowMode } = useRainbowThemeContext(); + const { sidebarRefs } = useSidebarContext(); + const { toolPanelRef } = sidebarRefs; + + + // Use context-based hooks to eliminate prop drilling + const { + leftPanelView, + isPanelVisible, + searchQuery, + filteredTools, + setSearchQuery, + handleBackToTools + } = useToolPanelState(); + + const { selectedToolKey, handleToolSelect } = useToolSelection(); + const { setPreviewFile } = useWorkbenchState(); + + return ( +
+
+ {/* Search Bar - Always visible at the top */} +
+ setSearchQuery(e.currentTarget.value)} + autoComplete="off" + size="sm" + /> +
+ + {leftPanelView === 'toolPicker' ? ( + // Tool Picker View +
+ +
+ ) : ( + // Selected Tool Content View +
+ {/* Tool content */} +
+ +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 7b678de98..d392f21b6 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,32 +1,21 @@ -import React, { useState } from "react"; -import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; +import React from "react"; +import { Box, Text, Stack, Button } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ToolRegistry } from "../../types/tool"; interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - toolRegistry: ToolRegistry; + /** Pre-filtered tools to display */ + filteredTools: [string, ToolRegistry[string]][]; } -const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => { +const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => { const { t } = useTranslation(); - const [search, setSearch] = useState(""); - - const filteredTools = Object.entries(toolRegistry).filter(([_, { name }]) => - name.toLowerCase().includes(search.toLowerCase()) - ); return ( - - setSearch(e.currentTarget.value)} - mb="md" - autoComplete="off" - /> - + + {filteredTools.length === 0 ? ( {t("toolPicker.noToolsFound", "No tools found")} diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx new file mode 100644 index 000000000..47f42b011 --- /dev/null +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -0,0 +1,221 @@ +/** + * ToolWorkflowContext - Manages tool selection, UI state, and workflow coordination + * Eliminates prop drilling with a single, simple context + */ + +import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react'; +import { useToolManagement } from '../hooks/useToolManagement'; +import { ToolConfiguration } from '../types/tool'; +import { PageEditorFunctions } from '../types/pageEditor'; + +// State interface +interface ToolWorkflowState { + // UI State + sidebarsVisible: boolean; + leftPanelView: 'toolPicker' | 'toolContent'; + readerMode: boolean; + + // File/Preview State + previewFile: File | null; + pageEditorFunctions: PageEditorFunctions | null; + + // Search State + searchQuery: string; +} + +// Actions +type ToolWorkflowAction = + | { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean } + | { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' } + | { type: 'SET_READER_MODE'; payload: boolean } + | { type: 'SET_PREVIEW_FILE'; payload: File | null } + | { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null } + | { type: 'SET_SEARCH_QUERY'; payload: string } + | { type: 'RESET_UI_STATE' }; + +// Initial state +const initialState: ToolWorkflowState = { + sidebarsVisible: true, + leftPanelView: 'toolPicker', + readerMode: false, + previewFile: null, + pageEditorFunctions: null, + searchQuery: '', +}; + +// Reducer +function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState { + switch (action.type) { + case 'SET_SIDEBARS_VISIBLE': + return { ...state, sidebarsVisible: action.payload }; + case 'SET_LEFT_PANEL_VIEW': + return { ...state, leftPanelView: action.payload }; + case 'SET_READER_MODE': + return { ...state, readerMode: action.payload }; + case 'SET_PREVIEW_FILE': + return { ...state, previewFile: action.payload }; + case 'SET_PAGE_EDITOR_FUNCTIONS': + return { ...state, pageEditorFunctions: action.payload }; + case 'SET_SEARCH_QUERY': + return { ...state, searchQuery: action.payload }; + case 'RESET_UI_STATE': + return { ...initialState, searchQuery: state.searchQuery }; // Preserve search + default: + return state; + } +} + +// Context value interface +interface ToolWorkflowContextValue extends ToolWorkflowState { + // Tool management (from hook) + selectedToolKey: string | null; + selectedTool: ToolConfiguration | null; + toolRegistry: any; // From useToolManagement + + // UI Actions + setSidebarsVisible: (visible: boolean) => void; + setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void; + setReaderMode: (mode: boolean) => void; + setPreviewFile: (file: File | null) => void; + setPageEditorFunctions: (functions: PageEditorFunctions | null) => void; + setSearchQuery: (query: string) => void; + + // Tool Actions + selectTool: (toolId: string) => void; + clearToolSelection: () => void; + + // Workflow Actions (compound actions) + handleToolSelect: (toolId: string) => void; + handleBackToTools: () => void; + handleReaderToggle: () => void; + + // Computed values + filteredTools: [string, any][]; // Filtered by search + isPanelVisible: boolean; +} + +const ToolWorkflowContext = createContext(undefined); + +// Provider component +interface ToolWorkflowProviderProps { + children: React.ReactNode; + /** Handler for view changes (passed from parent) */ + onViewChange?: (view: string) => void; +} + +export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) { + const [state, dispatch] = useReducer(toolWorkflowReducer, initialState); + + // Tool management hook + const { + selectedToolKey, + selectedTool, + toolRegistry, + selectTool, + clearToolSelection, + } = useToolManagement(); + + // UI Action creators + const setSidebarsVisible = useCallback((visible: boolean) => { + dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible }); + }, []); + + const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent') => { + dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view }); + }, []); + + const setReaderMode = useCallback((mode: boolean) => { + dispatch({ type: 'SET_READER_MODE', payload: mode }); + }, []); + + const setPreviewFile = useCallback((file: File | null) => { + dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); + }, []); + + const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => { + dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions }); + }, []); + + const setSearchQuery = useCallback((query: string) => { + dispatch({ type: 'SET_SEARCH_QUERY', payload: query }); + }, []); + + // Workflow actions (compound actions that coordinate multiple state changes) + const handleToolSelect = useCallback((toolId: string) => { + selectTool(toolId); + onViewChange?.('fileEditor'); + setLeftPanelView('toolContent'); + setReaderMode(false); + }, [selectTool, onViewChange, setLeftPanelView, setReaderMode]); + + const handleBackToTools = useCallback(() => { + setLeftPanelView('toolPicker'); + setReaderMode(false); + clearToolSelection(); + }, [setLeftPanelView, setReaderMode, clearToolSelection]); + + const handleReaderToggle = useCallback(() => { + setReaderMode(true); + }, [setReaderMode]); + + // Filter tools based on search query + const filteredTools = useMemo(() => { + if (!toolRegistry) return []; + return Object.entries(toolRegistry).filter(([_, { name }]) => + name.toLowerCase().includes(state.searchQuery.toLowerCase()) + ); + }, [toolRegistry, state.searchQuery]); + + const isPanelVisible = useMemo(() => + state.sidebarsVisible && !state.readerMode, + [state.sidebarsVisible, state.readerMode] + ); + + // Simple context value with basic memoization + const contextValue = useMemo((): ToolWorkflowContextValue => ({ + // State + ...state, + selectedToolKey, + selectedTool, + toolRegistry, + + // Actions + setSidebarsVisible, + setLeftPanelView, + setReaderMode, + setPreviewFile, + setPageEditorFunctions, + setSearchQuery, + selectTool, + clearToolSelection, + + // Workflow Actions + handleToolSelect, + handleBackToTools, + handleReaderToggle, + + // Computed + filteredTools, + isPanelVisible, + }), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]); + + return ( + + {children} + + ); +} + +// Custom hook to use the context +export function useToolWorkflow(): ToolWorkflowContextValue { + const context = useContext(ToolWorkflowContext); + if (!context) { + throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); + } + return context; +} + +// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly) +export const useToolSelection = useToolWorkflow; +export const useToolPanelState = useToolWorkflow; +export const useWorkbenchState = useToolWorkflow; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index b7a352f0f..e9009282e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,57 +1,26 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useTranslation } from 'react-i18next'; +import React, { useEffect } from "react"; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; +import { Group } from "@mantine/core"; 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"; -import TopControls from "../components/shared/TopControls"; -import FileEditor from "../components/fileEditor/FileEditor"; -import PageEditor from "../components/pageEditor/PageEditor"; -import PageEditorControls from "../components/pageEditor/PageEditorControls"; -import Viewer from "../components/viewer/Viewer"; -import ToolRenderer from "../components/tools/ToolRenderer"; +import ToolPanel from "../components/tools/ToolPanel"; +import Workbench from "../components/layout/Workbench"; import QuickAccessBar from "../components/shared/QuickAccessBar"; -import LandingPage from "../components/shared/LandingPage"; import FileManager from "../components/FileManager"; 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 { quickAccessRef } = sidebarRefs; - const fileContext = useFileContext(); - const { activeFiles, currentView, setCurrentView } = fileContext; const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); - const { addToActiveFiles } = useFileHandler(); - const { - selectedToolKey, - selectedTool, - toolRegistry, - selectTool, - clearToolSelection, - } = useToolManagement(); - - const [pageEditorFunctions, setPageEditorFunctions] = useState(null); - const [previewFile, setPreviewFile] = useState(null); + const { selectedTool } = useToolSelection(); // Update file selection context when tool changes useEffect(() => { @@ -65,232 +34,30 @@ function HomePageContent() { } }, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); - - - const handleToolSelect = useCallback( - (id: string) => { - selectTool(id); - setCurrentView('fileEditor'); // Tools use fileEditor view for file selection - setLeftPanelView('toolContent'); - setReaderMode(false); - }, - [selectTool, setCurrentView] - ); - - const handleQuickAccessTools = useCallback(() => { - setLeftPanelView('toolPicker'); - setReaderMode(false); - clearToolSelection(); - }, [clearToolSelection]); - - const handleReaderToggle = useCallback(() => { - setReaderMode(true); - }, [readerMode]); - - const handleViewChange = useCallback((view: string) => { - setCurrentView(view as any); - }, [setCurrentView]); - - - - return ( - {/* Quick Access Bar */} - - {/* Left: Tool Picker or Selected Tool Panel */} -
-
- {leftPanelView === 'toolPicker' ? ( - // Tool Picker View -
- -
- ) : ( - // Selected Tool Content View -
- {/* Back button */} -
- -
- - {/* Tool title */} -
-

{selectedTool?.name}

-
- - {/* Tool content */} -
- -
-
- )} -
-
- - {/* Main View */} - - {/* Top Controls */} - - {/* Main content area */} - - {!activeFiles[0] ? ( - - ) : currentView === "fileEditor" ? ( - { - handleViewChange("pageEditor"); - }, - onMergeFiles: (filesToMerge) => { - filesToMerge.forEach(addToActiveFiles); - handleViewChange("viewer"); - } - })} - /> - ) : currentView === "viewer" ? ( - { - setPreviewFile(null); // Clear preview file - const previousMode = sessionStorage.getItem('previousMode'); - if (previousMode === 'split') { - selectTool('split'); - setCurrentView('split'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'compress') { - selectTool('compress'); - setCurrentView('compress'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'convert') { - selectTool('convert'); - setCurrentView('convert'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else { - setCurrentView('fileEditor'); - } - } - })} - /> - ) : currentView === "pageEditor" ? ( - <> - - {pageEditorFunctions && ( - - )} - - ) : selectedToolKey && selectedTool ? ( - // Fallback: if tool is selected but not in fileEditor view, show tool in main area - - ) : ( - - )} - - - - {/* Global Modals */} + ref={quickAccessRef} /> + +
); } -// Main HomePage component wrapped with FileSelectionProvider export default function HomePage() { + const { setCurrentView } = useFileContext(); return ( - - - + + + + + ); } diff --git a/frontend/src/types/sidebar.ts b/frontend/src/types/sidebar.ts index 60dcd029d..b286f0b82 100644 --- a/frontend/src/types/sidebar.ts +++ b/frontend/src/types/sidebar.ts @@ -28,12 +28,6 @@ export interface SidebarProviderProps { children: React.ReactNode; } -// QuickAccessBar related interfaces -export interface QuickAccessBarProps { - onToolsClick: () => void; - onReaderToggle: () => void; -} - export interface ButtonConfig { id: string; name: string; From 507ad1dc6197a3887c3ec9c051afc8daaf288050 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:01:56 +0100 Subject: [PATCH 4/4] Feature/v2/shared tool hooks (#4134) # 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. --------- Co-authored-by: Reece Browne --- CLAUDE.md | 80 ++- .../components/tools/split/SplitSettings.tsx | 17 +- .../tools/compress/useCompressOperation.ts | 287 ++--------- .../tools/convert/useConvertOperation.ts | 464 ++++-------------- .../src/hooks/tools/ocr/useOCROperation.ts | 443 ++++------------- .../src/hooks/tools/shared/useToolApiCalls.ts | 86 ++++ .../hooks/tools/shared/useToolOperation.ts | 264 ++++++++++ .../hooks/tools/shared/useToolResources.ts | 114 +++++ .../src/hooks/tools/shared/useToolState.ts | 137 ++++++ .../hooks/tools/split/useSplitOperation.ts | 302 +++--------- .../hooks/tools/split/useSplitParameters.ts | 15 +- .../services/enhancedPDFProcessingService.ts | 5 +- .../tests/convert/ConvertIntegration.test.tsx | 2 +- frontend/src/tools/Split.tsx | 7 +- frontend/src/types/processing.ts | 1 - frontend/src/utils/toolErrorHandler.ts | 33 ++ frontend/src/utils/toolOperationTracker.ts | 28 ++ frontend/src/utils/toolResponseProcessor.ts | 25 + 18 files changed, 1068 insertions(+), 1242 deletions(-) create mode 100644 frontend/src/hooks/tools/shared/useToolApiCalls.ts create mode 100644 frontend/src/hooks/tools/shared/useToolOperation.ts create mode 100644 frontend/src/hooks/tools/shared/useToolResources.ts create mode 100644 frontend/src/hooks/tools/shared/useToolState.ts create mode 100644 frontend/src/utils/toolErrorHandler.ts create mode 100644 frontend/src/utils/toolOperationTracker.ts create mode 100644 frontend/src/utils/toolResponseProcessor.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8bdd7c235..be4e92201 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,12 +59,73 @@ Frontend designed for **stateful document processing**: Without cleanup: browser crashes with memory leaks. #### Tool Development -- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation -- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection) -- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools -- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking -- **Parameters**: Tool parameter handling patterns still being standardized -- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview) + +**Architecture**: Modular hook-based system with clear separation of concerns: + +- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook + - Coordinates all tool operations with consistent interface + - Integrates with FileContext for operation tracking + - Handles validation, error handling, and UI state management + +- **Supporting Hooks**: + - **useToolState**: UI state management (loading, progress, error, files) + - **useToolApiCalls**: HTTP requests and file processing + - **useToolResources**: Blob URLs, thumbnails, ZIP downloads + +- **Utilities**: + - **toolErrorHandler**: Standardized error extraction and i18n support + - **toolResponseProcessor**: API response handling (single/zip/custom) + - **toolOperationTracker**: FileContext integration utilities + +**Three Tool Patterns**: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate +```typescript +return useToolOperation({ + operationType: 'compress', + endpoint: '/api/v1/misc/compress-pdf', + buildFormData: (params, file: File) => { /* single file */ }, + multiFileEndpoint: false, + filePrefix: 'compressed_' +}); +``` + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay +```typescript +return useToolOperation({ + operationType: 'split', + endpoint: '/api/v1/general/split-pages', + buildFormData: (params, files: File[]) => { /* all files */ }, + multiFileEndpoint: true, + filePrefix: 'split_' +}); +``` + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR +```typescript +return useToolOperation({ + operationType: 'convert', + customProcessor: async (params, files) => { /* custom logic */ }, + filePrefix: 'converted_' +}); +``` + +**Benefits**: +- **No Timeouts**: Operations run until completion (supports 100GB+ files) +- **Consistent**: All tools follow same pattern and interface +- **Maintainable**: Single responsibility hooks, easy to test and modify +- **i18n Ready**: Built-in internationalization support +- **Type Safe**: Full TypeScript support with generic interfaces +- **Memory Safe**: Automatic resource cleanup and blob URL management ## Architecture Overview @@ -126,7 +187,10 @@ Without cleanup: browser crashes with memory leaks. - **Core Status**: React SPA architecture complete with multi-tool workflow support - **State Management**: FileContext handles all file operations and tool navigation - **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+) -- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference +- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator + - Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources` + - Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker` + - Pattern: Each tool creates focused operation hook, UI consumes state/actions - **Preview System**: Tool results can be previewed without polluting file context (Split tool example) - **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing @@ -141,7 +205,7 @@ Without cleanup: browser crashes with memory leaks. - **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation - **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling - **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code -- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`) +- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) - **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes - **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index 50ca49f20..95e972eaf 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants'; export interface SplitParameters { + mode: SplitMode | ''; pages: string; hDiv: string; vDiv: string; @@ -15,16 +16,12 @@ export interface SplitParameters { } export interface SplitSettingsProps { - mode: SplitMode | ''; - onModeChange: (mode: SplitMode | '') => void; parameters: SplitParameters; onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void; disabled?: boolean; } const SplitSettings = ({ - mode, - onModeChange, parameters, onParameterChange, disabled = false @@ -125,8 +122,8 @@ const SplitSettings = ({