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 {