Working filemanager

This commit is contained in:
Connor Yoh 2025-08-05 14:39:27 +01:00
parent ddb4f0fb38
commit 2d6135c743
15 changed files with 1305 additions and 305 deletions

View File

@ -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<FileManagerProps> = ({ selectedTool }) => {
const { t } = useTranslation();
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeSource, setActiveSource] = useState<FileSource>('recent');
const [searchTerm, setSearchTerm] = useState('');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
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<HTMLInputElement>) => {
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 (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
content: {
position: 'relative',
margin: isMobile ? '1rem' : '2rem'
},
body: { padding: 0 },
header: { display: 'none' }
}}
>
<div style={{
position: 'relative',
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: '0 auto',
overflow: 'hidden'
}}>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => 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 ? (
<MobileLayout
activeSource={activeSource}
onSourceChange={setActiveSource}
onLocalFileClick={openFileDialog}
selectedFiles={selectedFiles}
onOpenFiles={handleOpenFiles}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
recentFiles={recentFiles}
filteredFiles={filteredFiles}
selectedFileIds={selectedFileIds}
onFileSelect={handleFileSelect}
onFileRemove={handleRemoveFileByIndex}
onFileDoubleClick={handleFileDoubleClick}
isFileSupported={isFileSupported}
modalHeight={modalHeight}
fileInputRef={fileInputRef}
onFileInputChange={handleFileInputChange}
/>
) : (
<DesktopLayout
activeSource={activeSource}
onSourceChange={setActiveSource}
onLocalFileClick={openFileDialog}
selectedFiles={selectedFiles}
onOpenFiles={handleOpenFiles}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
recentFiles={recentFiles}
filteredFiles={filteredFiles}
selectedFileIds={selectedFileIds}
onFileSelect={handleFileSelect}
onFileRemove={handleRemoveFileByIndex}
onFileDoubleClick={handleFileDoubleClick}
isFileSupported={isFileSupported}
fileInputRef={fileInputRef}
onFileInputChange={handleFileInputChange}
modalHeight={modalHeight}
/>
)}
</Dropzone>
<DragOverlay isVisible={isDragging} />
</div>
</Modal>
);
};
export default FileManager;

View File

@ -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<FileUploadModalProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
title="Upload Files"
size="xl"
centered
>
<FileUploadSelector
title="Upload Files"
subtitle="Choose files from storage or upload new files"
onFileSelect={onFileSelect}
onFilesSelect={onFilesSelect}
accept={["*/*"]}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
data-testid="file-upload-modal"
/>
</Modal>
);
};
export default FileUploadModal;

View File

@ -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<HTMLInputElement>(null);
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
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<HTMLInputElement>) => {
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 (
<>
<Stack align="center" gap="sm">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
{displayTitle}
</Text>
<Text size="md" c="dimmed">
{displaySubtitle}
</Text>
</Stack>
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
{showDropzone ? (
<Dropzone
onDrop={handleNewFileUpload}
accept={accept}
multiple={true}
disabled={disabled || loading}
style={{ width: '100%', height: "5rem" }}
activateOnClick={true}
data-testid="file-dropzone"
>
<Center>
<Stack align="center" gap="sm">
<Text size="md" fw={500}>
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{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")
}
</Text>
</Stack>
</Center>
</Dropzone>
) : (
<Stack align="center" gap="sm">
<Button
variant="outline"
size="lg"
disabled={disabled}
loading={loading}
onClick={openFileDialog}
>
{t("fileUpload.uploadFiles", "Upload Files")}
</Button>
{/* Manual file input as backup */}
<input
ref={fileInputRef}
type="file"
multiple={true}
accept={accept.join(',')}
onChange={handleFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
</Stack>
)}
</Stack>
{/* Recent Files Section */}
{showRecentFiles && recentFiles.length > 0 && (
<Box w="100%" >
<Divider my="md" />
<Text size="lg" fw={500} mb="md">
{t("fileUpload.recentFiles", "Recent Files")}
</Text>
<MultiSelectControls
selectedCount={selectedFiles.length}
onClearSelection={selectionHandlers.clearSelection}
onAddToUpload={handleSelectedRecentFiles}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
<FileGrid
files={recentFiles}
onDoubleClick={handleRecentFileSelection}
onSelect={selectionHandlers.toggleSelection}
onRemove={handleRemoveFileByIndex}
selectedFiles={selectedFiles}
showSearch={true}
showSort={true}
isFileSupported={isFileSupported}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
</Box>
)}
</Stack>
</>
);
};
export default FileUploadSelector;

View File

@ -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<HTMLInputElement>;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
modalHeight: string;
}
const DesktopLayout: React.FC<DesktopLayoutProps> = ({
activeSource,
onSourceChange,
onLocalFileClick,
selectedFiles,
onOpenFiles,
searchTerm,
onSearchChange,
recentFiles,
filteredFiles,
selectedFileIds,
onFileSelect,
onFileRemove,
onFileDoubleClick,
isFileSupported,
fileInputRef,
onFileInputChange,
modalHeight,
}) => {
const { t } = useTranslation();
return (
<Grid gutter="md" h="100%" grow={false} style={{ flexWrap: 'nowrap' }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" style={{
minWidth: '15.625rem',
width: '15.625rem',
flexShrink: 0,
height: '100%',
}}>
<FileSourceButtons
activeSource={activeSource}
onSourceChange={onSourceChange}
onLocalFileClick={onLocalFileClick}
/>
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
{activeSource === 'recent' && (
<SearchInput
value={searchTerm}
onChange={onSearchChange}
style={{ marginBottom: '1rem', flexShrink: 0 }}
/>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FileListArea
activeSource={activeSource}
recentFiles={recentFiles}
filteredFiles={filteredFiles}
selectedFileIds={selectedFileIds}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
onFileDoubleClick={onFileDoubleClick}
isFileSupported={isFileSupported}
scrollAreaHeight={`calc(${modalHeight} - 6rem)`}
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0 ? `calc(${modalHeight} - 6rem)` : '100%'
}}
/>
</div>
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col span="content" style={{ minWidth: '20rem', width: '20rem', flexShrink: 0, height: '100%' }}>
<div style={{ height: '100%' }}>
<FileDetails
selectedFiles={selectedFiles}
onOpenFiles={onOpenFiles}
modalHeight={modalHeight}
/>
</div>
</Grid.Col>
{/* Hidden file input for local file selection */}
<HiddenFileInput
fileInputRef={fileInputRef}
onFileInputChange={onFileInputChange}
/>
</Grid>
);
};
export default DesktopLayout;

View File

@ -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<DragOverlayProps> = ({ isVisible }) => {
const { t } = useTranslation();
const theme = useMantineTheme();
if (!isVisible) return null;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.colors.blue[6], 0.1),
border: `2px dashed ${theme.colors.blue[6]}`,
borderRadius: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
pointerEvents: 'none'
}}
>
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64, color: theme.colors.blue[6] }} />
<Text size="xl" fw={500} c="blue.6">
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
</Text>
</Stack>
</div>
);
};
export default DragOverlay;

View File

@ -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<FileDetailsProps & { compact?: boolean; modalHeight?: string }> = ({ selectedFiles, onOpenFiles, compact = false, modalHeight = '80vh' }) => {
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [thumbnailCache, setThumbnailCache] = useState<Record<string, string>>({});
const [loadingFile, setLoadingFile] = useState<FileWithUrl | null>(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<string, string> = {};
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 (
<Stack gap="xs" style={{ height: '100%' }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{/* Small preview */}
<Box style={{ width: '60px', height: '80px', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && getCurrentThumbnail() ? (
<img
src={getCurrentThumbnail()}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '4px' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
)}
</Box>
{/* Action Button */}
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
</Button>
</Stack>
);
}
return (
<Stack gap="sm" h={`calc(${modalHeight} - 2rem)`}>
{/* Section 1: Thumbnail Preview */}
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Left Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={handlePrevious}
color="blue"
disabled={isAnimating}
style={{
position: 'absolute',
left: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronLeftIcon />
</ActionIcon>
)}
{/* Document Stack Container */}
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Background documents (stack effect) */}
{hasMultipleFiles && selectedFiles.length > 1 && (
<>
{/* Third document (furthest back) */}
{selectedFiles.length > 2 && (
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transform: 'translate(12px, 12px) rotate(2deg)',
zIndex: 1
}}
/>
)}
{/* Second document */}
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: '8px',
boxShadow: '0 3px 10px rgba(0, 0, 0, 0.12)',
transform: 'translate(6px, 6px) rotate(1deg)',
zIndex: 2
}}
/>
</>
)}
{/* Main document */}
{currentFile && getCurrentThumbnail() ? (
<Image
src={getCurrentThumbnail()}
alt={currentFile.name}
fit="contain"
radius="md"
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
borderRadius: '8px',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}
/>
) : currentFile ? (
<Center style={{
width: '80%',
height: '80%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 8,
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}>
<PictureAsPdfIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* Right Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={handleNext}
color="blue"
disabled={isAnimating}
style={{
position: 'absolute',
right: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronRightIcon />
</ActionIcon>
)}
</Box>
</Box>
{/* Section 2: File Details */}
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
</Text>
</Box>
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap={0}>
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
{currentFile ? (
<Badge size="sm" variant="light">
{detectFileExtension(currentFile.name).toUpperCase()}
</Badge>
) : (
<Text size="sm" fw={500}></Text>
)}
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
<Text size="sm" fw={500}>
{currentFile ? getFileSize(currentFile) : ''}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
<Text size="sm" fw={500}>
{currentFile ? '1.0' : ''}
</Text>
</Group>
{selectedFiles.length > 1 && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.totalSelected', 'Selected')}</Text>
<Text size="sm" fw={500}>
{selectedFiles.length} files
</Text>
</Group>
</>
)}
</Stack>
</ScrollArea>
</Card>
{/* Section 3: Action Button */}
<Button
size="md"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
style={{ flexShrink: 0 }}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
</Button>
</Stack>
);
};
export default FileDetails;

View File

@ -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<FileListAreaProps> = ({
activeSource,
recentFiles,
filteredFiles,
selectedFileIds,
onFileSelect,
onFileRemove,
onFileDoubleClick,
isFileSupported,
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const { t } = useTranslation();
if (activeSource === 'recent') {
if (recentFiles.length === 0) {
return (
<Center style={{ height: '200px' }}>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
);
}
return (
<ScrollArea
h={scrollAreaHeight}
style={{ ...scrollAreaStyle }}
type="always"
scrollbarSize={8}
>
<Stack gap="xs" p="xs">
{filteredFiles.map((file, index) => (
<FileListItem
key={file.id || file.name}
file={file}
isSelected={selectedFileIds.includes(file.id || file.name)}
isSupported={isFileSupported(file.name)}
onSelect={() => onFileSelect(file)}
onRemove={() => onFileRemove(index)}
onDoubleClick={() => onFileDoubleClick(file)}
/>
))}
</Stack>
</ScrollArea>
);
}
// Google Drive placeholder
return (
<Center style={{ height: '200px' }}>
<Stack align="center" gap="sm">
<CloudIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
</Stack>
</Center>
);
};
export default FileListArea;

View File

@ -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<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
onDoubleClick
}) => {
const [isHovered, setIsHovered] = useState(false);
return (
<Card
p="xs"
withBorder
style={{
cursor: 'pointer',
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : undefined),
border: isSelected ? '1px solid var(--mantine-color-blue-3)' : undefined,
opacity: isSupported ? 1 : 0.5,
boxShadow: isHovered && !isSelected ? '0 2px 8px rgba(0, 0, 0, 0.1)' : undefined,
transition: 'background-color 0.15s ease, box-shadow 0.15s ease'
}}
onClick={onSelect}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="sm">
<Box style={{ width: 40, height: 40, flexShrink: 0 }}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: 4 }}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Delete button - fades in/out on hover */}
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => { 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'
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Group>
</Card>
);
};
export default FileListItem;

View File

@ -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<FileSourceButtonsProps & { horizontal?: boolean }> = ({
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 = (
<>
<Button
variant={buttonProps.variant('recent')}
leftSection={<HistoryIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={() => onSourceChange('recent')}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
color={buttonProps.getColor('recent')}
styles={buttonProps.getStyles('recent')}
>
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-5)'
leftSection={<FolderIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={onLocalFileClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
}}
>
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
</Button>
<Button
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={() => onSourceChange('drive')}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled
color={activeSource === 'drive' ? 'gray' : undefined}
styles={buttonProps.getStyles('drive')}
>
{horizontal ? t('fileManager.googleDrive', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="md" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" fw={500} c="dimmed" mb="xs">
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
);
};
export default FileSourceButtons;

View File

@ -0,0 +1,22 @@
import React from 'react';
interface HiddenFileInputProps {
fileInputRef: React.RefObject<HTMLInputElement>;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const HiddenFileInput: React.FC<HiddenFileInputProps> = ({ fileInputRef, onFileInputChange }) => {
return (
<input
ref={fileInputRef}
type="file"
multiple={true}
accept={["*/*"].join(',')}
onChange={onFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
);
};
export default HiddenFileInput;

View File

@ -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<HTMLInputElement>;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const MobileLayout: React.FC<MobileLayoutProps> = ({
activeSource,
onSourceChange,
onLocalFileClick,
selectedFiles,
onOpenFiles,
searchTerm,
onSearchChange,
recentFiles,
filteredFiles,
selectedFileIds,
onFileSelect,
onFileRemove,
onFileDoubleClick,
isFileSupported,
modalHeight,
fileInputRef,
onFileInputChange,
}) => {
const { t } = useTranslation();
return (
<Stack h="100%" gap="sm" p="sm">
{/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons
activeSource={activeSource}
onSourceChange={onSourceChange}
onLocalFileClick={onLocalFileClick}
horizontal={true}
/>
</Box>
<Box style={{ flexShrink: 0 }}>
<FileDetails
selectedFiles={selectedFiles}
onOpenFiles={onOpenFiles}
compact={true}
modalHeight={modalHeight}
/>
</Box>
{/* Section 3: Search Bar - Fixed above file list */}
{activeSource === 'recent' && (
<Box style={{ flexShrink: 0 }}>
<SearchInput
value={searchTerm}
onChange={onSearchChange}
/>
</Box>
)}
{/* Section 4: File List - Fixed height scrollable area */}
<Box style={{ flexShrink: 0 }}>
<FileListArea
activeSource={activeSource}
recentFiles={recentFiles}
filteredFiles={filteredFiles}
selectedFileIds={selectedFileIds}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
onFileDoubleClick={onFileDoubleClick}
isFileSupported={isFileSupported}
scrollAreaHeight={`calc(${modalHeight} - ${selectedFiles.length > 0 ? '300px' : '200px'})`}
scrollAreaStyle={{ maxHeight: '400px', minHeight: '150px' }}
/>
</Box>
{/* Hidden file input for local file selection */}
<HiddenFileInput
fileInputRef={fileInputRef}
onFileInputChange={onFileInputChange}
/>
</Stack>
);
};
export default MobileLayout;

View File

@ -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<SearchInputProps> = ({ value, onChange, style }) => {
const { t } = useTranslation();
return (
<TextInput
placeholder={t('fileManager.searchFiles', 'Search files...')}
leftSection={<SearchIcon />}
value={value}
onChange={(e) => onChange(e.target.value)}
style={style}
/>
);
};
export default SearchInput;

View File

@ -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;
}

View File

@ -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);

View File

@ -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() {
</Box>
{/* Global Modals */}
<FileUploadModal selectedTool={selectedTool} />
<FileManager selectedTool={selectedTool} />
</Group>
);
}