diff --git a/frontend/src/components/shared/FileManager.tsx b/frontend/src/components/shared/FileManager.tsx new file mode 100644 index 000000000..1366a39bc --- /dev/null +++ b/frontend/src/components/shared/FileManager.tsx @@ -0,0 +1,269 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Modal } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { useTranslation } from 'react-i18next'; +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 { FileSource } from './fileManager/types'; + +interface FileManagerProps { + selectedTool?: Tool | null; +} + +const FileManager: React.FC = ({ selectedTool }) => { + const { t } = useTranslation(); + const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext(); + const fileInputRef = useRef(null); + const [activeSource, setActiveSource] = useState('recent'); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedFileIds, setSelectedFileIds] = useState([]); + 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 openFileDialog = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileInputChange = useCallback(async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length > 0) { + try { + // Store files in IndexedDB and get FileWithUrl objects + const storedFiles = await Promise.all( + files.map(async (file) => { + await storeFile(file); + return file; + }) + ); + + onFilesSelect(storedFiles); + await refreshRecentFiles(); + } catch (error) { + console.error('Failed to process uploaded files:', error); + } + } + // Clear the input + event.target.value = ''; + }, [storeFile, onFilesSelect, refreshRecentFiles]); + + const handleNewFileUpload = useCallback(async (files: File[]) => { + if (files.length > 0) { + try { + // Store files and refresh recent files + await Promise.all(files.map(file => storeFile(file))); + onFilesSelect(files); + await refreshRecentFiles(); + } catch (error) { + console.error('Failed to process dropped files:', error); + } + } + }, [storeFile, onFilesSelect, refreshRecentFiles]); + + const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => { + try { + const fileObj = await convertToFile(file); + if (onFileSelect) { + onFileSelect(fileObj); + } else { + onFilesSelect([fileObj]); + } + } catch (error) { + console.error('Failed to select recent file:', error); + } + }, [onFileSelect, onFilesSelect, convertToFile]); + + // Selection handlers + const selectionHandlers = { + toggleSelection: (fileId: string) => { + setSelectedFileIds(prev => + prev.includes(fileId) + ? prev.filter(id => id !== fileId) + : [...prev, fileId] + ); + }, + clearSelection: () => setSelectedFileIds([]) + }; + + const selectedFiles = recentFiles.filter(file => + selectedFileIds.includes(file.id || file.name) + ); + + const filteredFiles = recentFiles.filter(file => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const handleOpenFiles = useCallback(() => { + if (selectedFiles.length > 0) { + const filesAsFileObjects = selectedFiles.map(fileWithUrl => { + const file = new File([], fileWithUrl.name, { type: fileWithUrl.type }); + Object.defineProperty(file, 'size', { value: fileWithUrl.size || 0 }); + Object.defineProperty(file, 'lastModified', { value: fileWithUrl.lastModified || Date.now() }); + return file; + }); + onFilesSelect(filesAsFileObjects); + selectionHandlers.clearSelection(); + } + }, [selectedFiles, onFilesSelect, selectionHandlers]); + + const handleFileSelect = useCallback((file: FileWithUrl) => { + selectionHandlers.toggleSelection(file.id || file.name); + }, [selectionHandlers]); + + const handleFileDoubleClick = useCallback(async (file: FileWithUrl) => { + try { + const fileObj = await convertToFile(file); + onFilesSelect([fileObj]); + } catch (error) { + console.error('Failed to load file on double-click:', error); + } + }, [convertToFile, onFilesSelect]); + + const handleRemoveFileByIndex = useCallback(async (index: number) => { + await handleRemoveFile(index, recentFiles, setRecentFiles); + const file = recentFiles[index]; + setSelectedFileIds(prev => prev.filter(id => id !== (file.id || file.name))); + }, [handleRemoveFile, recentFiles]); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + useEffect(() => { + if (isFilesModalOpen) { + refreshRecentFiles(); + } else { + // Reset state when modal is closed + setActiveSource('recent'); + setSearchTerm(''); + setSelectedFileIds([]); + setIsDragging(false); + } + }, [isFilesModalOpen, refreshRecentFiles]); + + // Modal size constants for consistent scaling + const modalHeight = '80vh'; + const modalWidth = isMobile ? '100%' : '60vw'; + const modalMaxWidth = isMobile ? '100%' : '1200px'; + const modalMaxHeight = '1200px'; + const modalMinWidth = isMobile ? '320px' : '1030px'; + + return ( + +
+ setIsDragging(true)} + onDragLeave={() => setIsDragging(false)} + accept={["*/*"]} + multiple={true} + activateOnClick={false} + style={{ + padding: '1rem', + height: '100%', + width: '100%', + border: 'none', + borderRadius: '30px', + backgroundColor: 'transparent' + }} + styles={{ + inner: { pointerEvents: 'all' } + }} + > + {isMobile ? ( + + ) : ( + + )} + + + +
+
+ ); +}; + +export default FileManager; \ No newline at end of file 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/components/shared/fileManager/DesktopLayout.tsx b/frontend/src/components/shared/fileManager/DesktopLayout.tsx new file mode 100644 index 000000000..004367f8a --- /dev/null +++ b/frontend/src/components/shared/fileManager/DesktopLayout.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Grid, Center, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { FileWithUrl } from '../../../types/file'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { FileSource } from './types'; + +interface DesktopLayoutProps { + activeSource: FileSource; + onSourceChange: (source: FileSource) => void; + onLocalFileClick: () => void; + selectedFiles: FileWithUrl[]; + onOpenFiles: () => void; + searchTerm: string; + onSearchChange: (value: string) => void; + recentFiles: FileWithUrl[]; + filteredFiles: FileWithUrl[]; + selectedFileIds: string[]; + onFileSelect: (file: FileWithUrl) => void; + onFileRemove: (index: number) => void; + onFileDoubleClick: (file: FileWithUrl) => void; + isFileSupported: (fileName: string) => boolean; + fileInputRef: React.RefObject; + onFileInputChange: (event: React.ChangeEvent) => void; + modalHeight: string; +} + +const DesktopLayout: React.FC = ({ + activeSource, + onSourceChange, + onLocalFileClick, + selectedFiles, + onOpenFiles, + searchTerm, + onSearchChange, + recentFiles, + filteredFiles, + selectedFileIds, + onFileSelect, + onFileRemove, + onFileDoubleClick, + isFileSupported, + fileInputRef, + onFileInputChange, + modalHeight, +}) => { + const { t } = useTranslation(); + + return ( + + {/* Column 1: File Sources */} + + + + + {/* Column 2: File List */} + + {activeSource === 'recent' && ( + + )} + +
+ 0 ? `calc(${modalHeight} - 6rem)` : '100%' + }} + /> +
+
+ + {/* 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/shared/fileManager/DragOverlay.tsx b/frontend/src/components/shared/fileManager/DragOverlay.tsx new file mode 100644 index 000000000..8b26021d6 --- /dev/null +++ b/frontend/src/components/shared/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/shared/fileManager/FileDetails.tsx b/frontend/src/components/shared/fileManager/FileDetails.tsx new file mode 100644 index 000000000..8e5b3e7b9 --- /dev/null +++ b/frontend/src/components/shared/fileManager/FileDetails.tsx @@ -0,0 +1,392 @@ +import React, { useState, useEffect } from 'react'; +import { Stack, Card, Box, Center, Text, Badge, Button, Image, Group, Divider, ActionIcon, ScrollArea } 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 { detectFileExtension, getFileSize } from '../../../utils/fileUtils'; +import { useIndexedDBThumbnail } from '../../../hooks/useIndexedDBThumbnail'; +import { FileWithUrl } from '../../../types/file'; +import { FileDetailsProps } from './types'; + +const FileDetails: React.FC = ({ selectedFiles, onOpenFiles, compact = false, modalHeight = '80vh' }) => { + const { t } = useTranslation(); + const [currentFileIndex, setCurrentFileIndex] = useState(0); + const [thumbnailCache, setThumbnailCache] = useState>({}); + const [loadingFile, setLoadingFile] = useState(null); + 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; + + // Only load thumbnail for files not in cache + const shouldLoadThumbnail = loadingFile && !thumbnailCache[loadingFile.id || loadingFile.name]; + const { thumbnail } = useIndexedDBThumbnail(shouldLoadThumbnail ? loadingFile : ({} as FileWithUrl)); + + // Load thumbnails for all selected files + useEffect(() => { + // Start loading thumbnails for uncached files + const uncachedFiles = selectedFiles.filter(file => !thumbnailCache[file.id || file.name]); + if (uncachedFiles.length > 0 && !loadingFile) { + setLoadingFile(uncachedFiles[0]); + } + }, [selectedFiles, thumbnailCache, loadingFile]); + + // Cache thumbnail when it loads and move to next uncached file + useEffect(() => { + if (loadingFile && thumbnail) { + const fileId = loadingFile.id || loadingFile.name; + setThumbnailCache(prev => ({ + ...prev, + [fileId]: thumbnail + })); + + // Find next uncached file to load + const uncachedFiles = selectedFiles.filter(file => + !thumbnailCache[file.id || file.name] && + (file.id || file.name) !== fileId + ); + + if (uncachedFiles.length > 0) { + setLoadingFile(uncachedFiles[0]); + } else { + setLoadingFile(null); + } + } + }, [loadingFile, thumbnail, selectedFiles, thumbnailCache]); + + // Clear cache when selection changes completely + useEffect(() => { + const selectedFileIds = selectedFiles.map(f => f.id || f.name); + setThumbnailCache(prev => { + const newCache: Record = {}; + selectedFileIds.forEach(id => { + if (prev[id]) { + newCache[id] = prev[id]; + } + }); + return newCache; + }); + setLoadingFile(null); + }, [selectedFiles]); + + // Get thumbnail from cache only + const getCurrentThumbnail = () => { + if (!currentFile) return null; + const fileId = currentFile.id || currentFile.name; + return thumbnailCache[fileId]; + }; + + 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 ( + + {/* Compact mobile layout */} + + {/* Small preview */} + + {currentFile && getCurrentThumbnail() ? ( + {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 */} + +
+ ); + } + + return ( + + {/* Section 1: Thumbnail Preview */} + + + {/* Left Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} + + {/* Document Stack Container */} + + {/* Background documents (stack effect) */} + {hasMultipleFiles && selectedFiles.length > 1 && ( + <> + {/* Third document (furthest back) */} + {selectedFiles.length > 2 && ( + + )} + + {/* Second document */} + + + )} + + {/* Main document */} + {currentFile && getCurrentThumbnail() ? ( + {currentFile.name} + ) : currentFile ? ( +
+ +
+ ) : null} +
+ + {/* Right Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} +
+
+ + {/* Section 2: File Details */} + + + + {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' : ''} + + + + {selectedFiles.length > 1 && ( + <> + + + {t('fileManager.totalSelected', 'Selected')} + + {selectedFiles.length} files + + + + )} + + + + + {/* Section 3: Action Button */} + +
+ ); +}; + +export default FileDetails; \ No newline at end of file diff --git a/frontend/src/components/shared/fileManager/FileListArea.tsx b/frontend/src/components/shared/fileManager/FileListArea.tsx new file mode 100644 index 000000000..a057e8cc7 --- /dev/null +++ b/frontend/src/components/shared/fileManager/FileListArea.tsx @@ -0,0 +1,87 @@ +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 { FileWithUrl } from '../../../types/file'; +import FileListItem from './FileListItem'; +import { FileSource } from './types'; + +interface FileListAreaProps { + activeSource: FileSource; + recentFiles: FileWithUrl[]; + filteredFiles: FileWithUrl[]; + selectedFileIds: string[]; + onFileSelect: (file: FileWithUrl) => void; + onFileRemove: (index: number) => void; + onFileDoubleClick: (file: FileWithUrl) => void; + isFileSupported: (fileName: string) => boolean; + scrollAreaHeight: string; + scrollAreaStyle?: React.CSSProperties; +} + +const FileListArea: React.FC = ({ + activeSource, + recentFiles, + filteredFiles, + selectedFileIds, + onFileSelect, + onFileRemove, + onFileDoubleClick, + isFileSupported, + scrollAreaHeight, + scrollAreaStyle = {}, +}) => { + const { t } = useTranslation(); + + if (activeSource === 'recent') { + if (recentFiles.length === 0) { + return ( +
+ + + {t('fileManager.noRecentFiles', 'No recent files')} + + {t('fileManager.dropFilesHint', 'Drop files anywhere to upload')} + + +
+ ); + } + + return ( + + + {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/shared/fileManager/FileListItem.tsx b/frontend/src/components/shared/fileManager/FileListItem.tsx new file mode 100644 index 000000000..c5cb6655c --- /dev/null +++ b/frontend/src/components/shared/fileManager/FileListItem.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { Card, Group, Box, Center, Text, ActionIcon } from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { getFileSize, getFileDate } from '../../../utils/fileUtils'; +import { FileListItemProps } from './types'; + +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, + onRemove, + onDoubleClick +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + +
+ +
+
+ + {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/shared/fileManager/FileSourceButtons.tsx b/frontend/src/components/shared/fileManager/FileSourceButtons.tsx new file mode 100644 index 000000000..fa43dabb3 --- /dev/null +++ b/frontend/src/components/shared/fileManager/FileSourceButtons.tsx @@ -0,0 +1,101 @@ +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 { FileSourceButtonsProps } from './types'; + +const FileSourceButtons: React.FC = ({ + activeSource, + onSourceChange, + onLocalFileClick, + horizontal = false +}) => { + const { t } = useTranslation(); + + const buttonProps = { + variant: (source: string) => activeSource === source ? 'filled' : 'subtle', + getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-4)' : undefined, + getStyles: (source: string) => ({ + root: { + backgroundColor: activeSource === source ? undefined : 'transparent', + 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/shared/fileManager/HiddenFileInput.tsx b/frontend/src/components/shared/fileManager/HiddenFileInput.tsx new file mode 100644 index 000000000..c5bd05df4 --- /dev/null +++ b/frontend/src/components/shared/fileManager/HiddenFileInput.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface HiddenFileInputProps { + fileInputRef: React.RefObject; + onFileInputChange: (event: React.ChangeEvent) => void; +} + +const HiddenFileInput: React.FC = ({ fileInputRef, onFileInputChange }) => { + return ( + + ); +}; + +export default HiddenFileInput; \ No newline at end of file diff --git a/frontend/src/components/shared/fileManager/MobileLayout.tsx b/frontend/src/components/shared/fileManager/MobileLayout.tsx new file mode 100644 index 000000000..04c749bd7 --- /dev/null +++ b/frontend/src/components/shared/fileManager/MobileLayout.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Stack, Box } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { FileWithUrl } from '../../../types/file'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { FileSource } from './types'; + +interface MobileLayoutProps { + activeSource: FileSource; + onSourceChange: (source: FileSource) => void; + onLocalFileClick: () => void; + selectedFiles: FileWithUrl[]; + onOpenFiles: () => void; + searchTerm: string; + onSearchChange: (value: string) => void; + recentFiles: FileWithUrl[]; + filteredFiles: FileWithUrl[]; + selectedFileIds: string[]; + onFileSelect: (file: FileWithUrl) => void; + onFileRemove: (index: number) => void; + onFileDoubleClick: (file: FileWithUrl) => void; + isFileSupported: (fileName: string) => boolean; + modalHeight: string; + fileInputRef: React.RefObject; + onFileInputChange: (event: React.ChangeEvent) => void; +} + +const MobileLayout: React.FC = ({ + activeSource, + onSourceChange, + onLocalFileClick, + selectedFiles, + onOpenFiles, + searchTerm, + onSearchChange, + recentFiles, + filteredFiles, + selectedFileIds, + onFileSelect, + onFileRemove, + onFileDoubleClick, + isFileSupported, + modalHeight, + fileInputRef, + onFileInputChange, +}) => { + const { t } = useTranslation(); + + return ( + + {/* Section 1: File Sources - Fixed at top */} + + + + + + + + + {/* Section 3: Search Bar - Fixed above file list */} + {activeSource === 'recent' && ( + + + + )} + + {/* Section 4: File List - Fixed height scrollable area */} + + 0 ? '300px' : '200px'})`} + scrollAreaStyle={{ maxHeight: '400px', minHeight: '150px' }} + /> + + + {/* Hidden file input for local file selection */} + + + ); +}; + +export default MobileLayout; \ No newline at end of file diff --git a/frontend/src/components/shared/fileManager/SearchInput.tsx b/frontend/src/components/shared/fileManager/SearchInput.tsx new file mode 100644 index 000000000..8f447f1a5 --- /dev/null +++ b/frontend/src/components/shared/fileManager/SearchInput.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { TextInput } from '@mantine/core'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslation } from 'react-i18next'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + style?: React.CSSProperties; +} + +const SearchInput: React.FC = ({ value, onChange, style }) => { + const { t } = useTranslation(); + + return ( + } + value={value} + onChange={(e) => onChange(e.target.value)} + style={style} + /> + ); +}; + +export default SearchInput; \ No newline at end of file diff --git a/frontend/src/components/shared/fileManager/types.ts b/frontend/src/components/shared/fileManager/types.ts new file mode 100644 index 000000000..83c678997 --- /dev/null +++ b/frontend/src/components/shared/fileManager/types.ts @@ -0,0 +1,23 @@ +import { FileWithUrl } from '../../../types/file'; + +export type FileSource = 'recent' | 'local' | 'drive'; + +export interface FileListItemProps { + file: FileWithUrl; + isSelected: boolean; + isSupported: boolean; + onSelect: () => void; + onRemove: () => void; + onDoubleClick?: () => void; +} + +export interface FileDetailsProps { + selectedFiles: FileWithUrl[]; + onOpenFiles: () => void; +} + +export interface FileSourceButtonsProps { + activeSource: FileSource; + onSourceChange: (source: FileSource) => void; + onLocalFileClick: () => void; +} \ No newline at end of file diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index b8404e5fe..20065c9d4 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"; + +/** + * 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,21 +44,41 @@ 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 from blob for files (both IndexedDB and regular files, small files only) + if (file.size < 50 * 1024 * 1024 && !generating) { setGenerating(true); try { - const arrayBuffer = await file.arrayBuffer(); + let arrayBuffer: ArrayBuffer; + + // Handle IndexedDB files vs regular File objects + if (file.storedInIndexedDB && file.id) { + // For IndexedDB files, get the data from storage + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + throw new Error('File not found in IndexedDB'); + } + arrayBuffer = storedFile.data; + } else if (typeof file.arrayBuffer === 'function') { + // For regular File objects, use arrayBuffer method + arrayBuffer = await file.arrayBuffer(); + } 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 has no arrayBuffer method and not found in IndexedDB'); + } + arrayBuffer = storedFile.data; + } else { + throw new Error('File object has no arrayBuffer method and no ID for IndexedDB lookup'); + } + const pdf = await getDocument({ data: arrayBuffer }).promise; const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 0.2 }); + + // Calculate optimal scale and create viewport + const baseViewport = page.getViewport({ scale: 1.0 }); + const scale = calculateThumbnailScale(baseViewport); + const viewport = page.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; @@ -53,7 +89,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | 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); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cccce7667..1f3d79b40 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -18,7 +18,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/shared/FileManager"; function HomePageContent() { const { t } = useTranslation(); @@ -270,7 +270,7 @@ function HomePageContent() { {/* Global Modals */} - + ); }