From 68b279deac0ffc73b4a44f62e445d162a02e991b Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 6 Aug 2025 10:28:15 +0100 Subject: [PATCH] Restructure files --- .../components/{shared => }/FileManager.tsx | 10 +- .../fileManager/CompactFileDetails.tsx | 125 +++++++ .../fileManager/DesktopLayout.tsx | 2 +- .../{shared => }/fileManager/DragOverlay.tsx | 0 .../components/fileManager/FileDetails.tsx | 116 ++++++ .../components/fileManager/FileInfoCard.tsx | 67 ++++ .../{shared => }/fileManager/FileListArea.tsx | 50 ++- .../{shared => }/fileManager/FileListItem.tsx | 2 +- .../components/fileManager/FilePreview.tsx | 144 +++++++ .../fileManager/FileSourceButtons.tsx | 2 +- .../fileManager/HiddenFileInput.tsx | 2 +- .../{shared => }/fileManager/MobileLayout.tsx | 2 +- .../{shared => }/fileManager/SearchInput.tsx | 2 +- .../{shared => }/fileManager/types.ts | 2 +- .../shared/fileManager/FileDetails.tsx | 350 ------------------ .../FileManagerContext.tsx | 4 +- frontend/src/hooks/useFileManager.ts | 8 +- frontend/src/hooks/useIndexedDBThumbnail.ts | 53 ++- frontend/src/pages/HomePage.tsx | 2 +- frontend/src/utils/thumbnailUtils.ts | 165 ++++++++- 20 files changed, 682 insertions(+), 426 deletions(-) rename frontend/src/components/{shared => }/FileManager.tsx (94%) create mode 100644 frontend/src/components/fileManager/CompactFileDetails.tsx rename frontend/src/components/{shared => }/fileManager/DesktopLayout.tsx (97%) rename frontend/src/components/{shared => }/fileManager/DragOverlay.tsx (100%) create mode 100644 frontend/src/components/fileManager/FileDetails.tsx create mode 100644 frontend/src/components/fileManager/FileInfoCard.tsx rename frontend/src/components/{shared => }/fileManager/FileListArea.tsx (55%) rename frontend/src/components/{shared => }/fileManager/FileListItem.tsx (97%) create mode 100644 frontend/src/components/fileManager/FilePreview.tsx rename frontend/src/components/{shared => }/fileManager/FileSourceButtons.tsx (97%) rename frontend/src/components/{shared => }/fileManager/HiddenFileInput.tsx (84%) rename frontend/src/components/{shared => }/fileManager/MobileLayout.tsx (97%) rename frontend/src/components/{shared => }/fileManager/SearchInput.tsx (91%) rename frontend/src/components/{shared => }/fileManager/types.ts (83%) delete mode 100644 frontend/src/components/shared/fileManager/FileDetails.tsx rename frontend/src/{components/shared/fileManager => contexts}/FileManagerContext.tsx (98%) diff --git a/frontend/src/components/shared/FileManager.tsx b/frontend/src/components/FileManager.tsx similarity index 94% rename from frontend/src/components/shared/FileManager.tsx rename to frontend/src/components/FileManager.tsx index ee2a9df12..65f933ca9 100644 --- a/frontend/src/components/shared/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -2,14 +2,14 @@ import React, { useState, useCallback, 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 { 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 './fileManager/FileManagerContext'; +import { FileManagerProvider } from '../contexts/FileManagerContext'; interface FileManagerProps { selectedTool?: Tool | null; diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx new file mode 100644 index 000000000..3fbc299f5 --- /dev/null +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -0,0 +1,125 @@ +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; + hasMultipleFiles: boolean; + isAnimating: boolean; + onPrevious: () => void; + onNext: () => void; + onOpenFiles: () => void; +} + +const CompactFileDetails: React.FC = ({ + currentFile, + thumbnail, + selectedFiles, + currentFileIndex, + hasMultipleFiles, + isAnimating, + onPrevious, + onNext, + onOpenFiles +}) => { + const { t } = useTranslation(); + const hasSelection = selectedFiles.length > 0; + + 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/shared/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx similarity index 97% rename from frontend/src/components/shared/fileManager/DesktopLayout.tsx rename to frontend/src/components/fileManager/DesktopLayout.tsx index 2cc11e8f8..be701ff20 100644 --- a/frontend/src/components/shared/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -5,7 +5,7 @@ import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; import HiddenFileInput from './HiddenFileInput'; -import { useFileManagerContext } from './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; const DesktopLayout: React.FC = () => { const { diff --git a/frontend/src/components/shared/fileManager/DragOverlay.tsx b/frontend/src/components/fileManager/DragOverlay.tsx similarity index 100% rename from frontend/src/components/shared/fileManager/DragOverlay.tsx rename to frontend/src/components/fileManager/DragOverlay.tsx diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx new file mode 100644 index 000000000..bf17eeb64 --- /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/shared/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx similarity index 55% rename from frontend/src/components/shared/fileManager/FileListArea.tsx rename to frontend/src/components/fileManager/FileListArea.tsx index 2dc6b1528..7f78c1e83 100644 --- a/frontend/src/components/shared/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -4,7 +4,7 @@ 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 './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; interface FileListAreaProps { scrollAreaHeight: string; @@ -28,20 +28,6 @@ const FileListArea: React.FC = ({ 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 ( = ({ scrollbarSize={8} > - {filteredFiles.map((file, index) => ( - onFileSelect(file)} - onRemove={() => onFileRemove(index)} - onDoubleClick={() => onFileDoubleClick(file)} - /> - ))} + {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)} + /> + )) + )}
); diff --git a/frontend/src/components/shared/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx similarity index 97% rename from frontend/src/components/shared/fileManager/FileListItem.tsx rename to frontend/src/components/fileManager/FileListItem.tsx index 89bdc7dfe..bfdc30b66 100644 --- a/frontend/src/components/shared/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,7 +1,7 @@ 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 { getFileSize, getFileDate } from '../../utils/fileUtils'; import { FileListItemProps } from './types'; const FileListItem: React.FC = ({ diff --git a/frontend/src/components/fileManager/FilePreview.tsx b/frontend/src/components/fileManager/FilePreview.tsx new file mode 100644 index 000000000..b6c6e3d29 --- /dev/null +++ b/frontend/src/components/fileManager/FilePreview.tsx @@ -0,0 +1,144 @@ +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; + hasMultipleFiles: boolean; + isAnimating: boolean; + modalHeight: string; + onPrevious: () => void; + onNext: () => void; +} + +const FilePreview: React.FC = ({ + currentFile, + thumbnail, + hasMultipleFiles, + isAnimating, + modalHeight, + onPrevious, + onNext +}) => { + return ( + + + {/* Left Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} + + {/* Document Stack Container */} + + {/* Background documents (stack effect) */} + {hasMultipleFiles && ( + <> + {/* Third document (furthest back) */} + + + {/* Second document */} + + + )} + + {/* 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/shared/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx similarity index 97% rename from frontend/src/components/shared/fileManager/FileSourceButtons.tsx rename to frontend/src/components/fileManager/FileSourceButtons.tsx index 03ced14c0..a6870a661 100644 --- a/frontend/src/components/shared/fileManager/FileSourceButtons.tsx +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -4,7 +4,7 @@ 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 './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; interface FileSourceButtonsProps { horizontal?: boolean; diff --git a/frontend/src/components/shared/fileManager/HiddenFileInput.tsx b/frontend/src/components/fileManager/HiddenFileInput.tsx similarity index 84% rename from frontend/src/components/shared/fileManager/HiddenFileInput.tsx rename to frontend/src/components/fileManager/HiddenFileInput.tsx index 4fb7158e6..6f2834267 100644 --- a/frontend/src/components/shared/fileManager/HiddenFileInput.tsx +++ b/frontend/src/components/fileManager/HiddenFileInput.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useFileManagerContext } from './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; const HiddenFileInput: React.FC = () => { const { fileInputRef, onFileInputChange } = useFileManagerContext(); diff --git a/frontend/src/components/shared/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx similarity index 97% rename from frontend/src/components/shared/fileManager/MobileLayout.tsx rename to frontend/src/components/fileManager/MobileLayout.tsx index 852b61b1d..4be5d4200 100644 --- a/frontend/src/components/shared/fileManager/MobileLayout.tsx +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -5,7 +5,7 @@ import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; import HiddenFileInput from './HiddenFileInput'; -import { useFileManagerContext } from './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; const MobileLayout: React.FC = () => { const { diff --git a/frontend/src/components/shared/fileManager/SearchInput.tsx b/frontend/src/components/fileManager/SearchInput.tsx similarity index 91% rename from frontend/src/components/shared/fileManager/SearchInput.tsx rename to frontend/src/components/fileManager/SearchInput.tsx index 7a86e7300..f47da0dca 100644 --- a/frontend/src/components/shared/fileManager/SearchInput.tsx +++ b/frontend/src/components/fileManager/SearchInput.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { TextInput } from '@mantine/core'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from './FileManagerContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; interface SearchInputProps { style?: React.CSSProperties; diff --git a/frontend/src/components/shared/fileManager/types.ts b/frontend/src/components/fileManager/types.ts similarity index 83% rename from frontend/src/components/shared/fileManager/types.ts rename to frontend/src/components/fileManager/types.ts index 36b523295..9740b1f2e 100644 --- a/frontend/src/components/shared/fileManager/types.ts +++ b/frontend/src/components/fileManager/types.ts @@ -1,4 +1,4 @@ -import { FileWithUrl } from '../../../types/file'; +import { FileWithUrl } from '../../types/file'; export type FileSource = 'recent' | 'local' | 'drive'; diff --git a/frontend/src/components/shared/fileManager/FileDetails.tsx b/frontend/src/components/shared/fileManager/FileDetails.tsx deleted file mode 100644 index 0cdc0f764..000000000 --- a/frontend/src/components/shared/fileManager/FileDetails.tsx +++ /dev/null @@ -1,350 +0,0 @@ -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 { useFileManagerContext } from './FileManagerContext'; - -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 ( - - {/* 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 - - - - )} - - - - - -
- ); -}; - -export default FileDetails; \ No newline at end of file diff --git a/frontend/src/components/shared/fileManager/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx similarity index 98% rename from frontend/src/components/shared/fileManager/FileManagerContext.tsx rename to frontend/src/contexts/FileManagerContext.tsx index f3ef0e090..77cb5c518 100644 --- a/frontend/src/components/shared/fileManager/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; -import { FileWithUrl } from '../../../types/file'; -import { FileSource } from './types'; +import { FileWithUrl } from '../types/file'; +import { FileSource } from '../components/fileManager/types'; // Type for the context value - now contains everything directly interface FileManagerContextValue { diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index 5d1e15b8f..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; diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index 20065c9d4..b8b2c669c 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,7 +1,7 @@ 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 @@ -44,50 +44,47 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): { return; } - // Second priority: generate from blob for files (both IndexedDB and regular files, small files only) - if (file.size < 50 * 1024 * 1024 && !generating) { + // Second priority: generate thumbnail for any file type + if (file.size < 100 * 1024 * 1024 && !generating) { setGenerating(true); try { - let arrayBuffer: ArrayBuffer; + let fileObject: File; // Handle IndexedDB files vs regular File objects if (file.storedInIndexedDB && file.id) { - // For IndexedDB files, get the data from storage + // 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'); } - arrayBuffer = storedFile.data; - } else if (typeof file.arrayBuffer === 'function') { - // For regular File objects, use arrayBuffer method - arrayBuffer = await file.arrayBuffer(); + 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 has no arrayBuffer method and not found in IndexedDB'); + throw new Error('File not found in IndexedDB and no File object available'); } - arrayBuffer = storedFile.data; + fileObject = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); } else { - throw new Error('File object has no arrayBuffer method and no ID for IndexedDB lookup'); + throw new Error('File object not available and no ID for IndexedDB lookup'); } - const pdf = await getDocument({ data: arrayBuffer }).promise; - const page = await pdf.getPage(1); - - // 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; - const context = canvas.getContext("2d"); - if (context && !cancelled) { - await page.render({ canvasContext: context, viewport }).promise; - if (!cancelled) setThumb(canvas.toDataURL()); + // 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 file', file.name, error); if (!cancelled) setThumb(null); @@ -95,7 +92,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): { 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 1f3d79b40..6ddb2590a 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 FileManager from "../components/shared/FileManager"; +import FileManager from "../components/FileManager"; function HomePageContent() { const { t } = useTranslation(); 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 {