mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-11 13:48:37 +02:00
Feature/v2/filemanager (#4121)
FileManager Component Overview Purpose: Modal component for selecting and managing PDF files with preview capabilities Architecture: - Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx (3-column) - Central State: FileManagerContext handles file operations, selection, and modal state - File Storage: IndexedDB persistence with thumbnail caching Key Components: - FileSourceButtons: Switch between Recent/Local/Drive sources - FileListArea: Scrollable file grid with search functionality - FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow pages based on file count) - FileDetails: File info card with metadata - CompactFileDetails: Mobile-optimized file info layout File Flow: 1. Users select source → browse/search files → select multiple files → preview with navigation → open in tools 2. Files persist across tool switches via FileContext integration 3. Memory management handles large PDFs (up to 100GB+) ```mermaid graph TD FM[FileManager] --> ML[MobileLayout] FM --> DL[DesktopLayout] ML --> FSB[FileSourceButtons<br/>Recent/Local/Drive] ML --> FLA[FileListArea] ML --> FD[FileDetails] DL --> FSB DL --> FLA DL --> FD FLA --> FLI[FileListItem] FD --> FP[FilePreview] FD --> CFD[CompactFileDetails] ``` --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
9861332040
commit
7e3321ee16
@ -1733,7 +1733,25 @@
|
|||||||
"storageError": "Storage error occurred",
|
"storageError": "Storage error occurred",
|
||||||
"storageLow": "Storage is running low. Consider removing old files.",
|
"storageLow": "Storage is running low. Consider removing old files.",
|
||||||
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
||||||
"noFileSelected": "No files selected"
|
"noFileSelected": "No files selected",
|
||||||
|
"searchFiles": "Search files...",
|
||||||
|
"recent": "Recent",
|
||||||
|
"localFiles": "Local Files",
|
||||||
|
"googleDrive": "Google Drive",
|
||||||
|
"googleDriveShort": "Drive",
|
||||||
|
"myFiles": "My Files",
|
||||||
|
"noRecentFiles": "No recent files found",
|
||||||
|
"dropFilesHint": "Drop files here to upload",
|
||||||
|
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||||
|
"openFiles": "Open Files",
|
||||||
|
"openFile": "Open File",
|
||||||
|
"details": "File Details",
|
||||||
|
"fileName": "Name",
|
||||||
|
"fileFormat": "Format",
|
||||||
|
"fileSize": "Size",
|
||||||
|
"fileVersion": "Version",
|
||||||
|
"totalSelected": "Total Selected",
|
||||||
|
"dropFilesHere": "Drop files here"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||||
|
168
frontend/src/components/FileManager.tsx
Normal file
168
frontend/src/components/FileManager.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Modal } from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { useFileManager } from '../hooks/useFileManager';
|
||||||
|
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||||
|
import { Tool } from '../types/tool';
|
||||||
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
|
import DragOverlay from './fileManager/DragOverlay';
|
||||||
|
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
selectedTool?: Tool | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
|
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
|
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 handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
|
||||||
|
try {
|
||||||
|
const fileObjects = await Promise.all(
|
||||||
|
files.map(async (fileWithUrl) => {
|
||||||
|
return await convertToFile(fileWithUrl);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onFilesSelect(fileObjects);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process selected files:', error);
|
||||||
|
}
|
||||||
|
}, [convertToFile, onFilesSelect]);
|
||||||
|
|
||||||
|
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||||
|
onFilesSelect(files);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process dropped files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onFilesSelect, refreshRecentFiles]);
|
||||||
|
|
||||||
|
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||||
|
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||||
|
}, [handleRemoveFile, recentFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFilesModalOpen) {
|
||||||
|
refreshRecentFiles();
|
||||||
|
} else {
|
||||||
|
// Reset state when modal is closed
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, [isFilesModalOpen, refreshRecentFiles]);
|
||||||
|
|
||||||
|
// Cleanup any blob URLs when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clean up blob URLs from recent files
|
||||||
|
recentFiles.forEach(file => {
|
||||||
|
if (file.url && file.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [recentFiles]);
|
||||||
|
|
||||||
|
// Modal size constants for consistent scaling
|
||||||
|
const modalHeight = '80vh';
|
||||||
|
const modalWidth = isMobile ? '100%' : '80vw';
|
||||||
|
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
||||||
|
const modalMaxHeight = '1200px';
|
||||||
|
const modalMinWidth = isMobile ? '320px' : '800px';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '30px',
|
||||||
|
backgroundColor: 'var(--bg-file-manager)'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
inner: { pointerEvents: 'all' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileManagerProvider
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
onClose={closeFilesModal}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
isOpen={isFilesModalOpen}
|
||||||
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
storeFile={storeFile}
|
||||||
|
refreshRecentFiles={refreshRecentFiles}
|
||||||
|
>
|
||||||
|
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||||
|
</FileManagerProvider>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
<DragOverlay isVisible={isDragging} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileManager;
|
@ -1,92 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import WarningIcon from "@mui/icons-material/Warning";
|
|
||||||
import { StorageStats } from "../../services/fileStorage";
|
|
||||||
import { formatFileSize } from "../../utils/fileUtils";
|
|
||||||
import { getStorageUsagePercent } from "../../utils/storageUtils";
|
|
||||||
import { StorageConfig } from "../../types/file";
|
|
||||||
|
|
||||||
interface StorageStatsCardProps {
|
|
||||||
storageStats: StorageStats | null;
|
|
||||||
filesCount: number;
|
|
||||||
onClearAll: () => void;
|
|
||||||
onReloadFiles: () => void;
|
|
||||||
storageConfig: StorageConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageStatsCard = ({
|
|
||||||
storageStats,
|
|
||||||
filesCount,
|
|
||||||
onClearAll,
|
|
||||||
onReloadFiles,
|
|
||||||
storageConfig,
|
|
||||||
}: StorageStatsCardProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!storageStats) return null;
|
|
||||||
|
|
||||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
|
||||||
const totalUsed = storageStats.totalSize || storageStats.used;
|
|
||||||
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
|
|
||||||
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
|
|
||||||
<Card withBorder p="sm">
|
|
||||||
<Group align="center" gap="md">
|
|
||||||
<StorageIcon />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
value={hardLimitPercent}
|
|
||||||
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
|
|
||||||
size="sm"
|
|
||||||
mt={4}
|
|
||||||
/>
|
|
||||||
<Group justify="space-between" mt={2}>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
|
|
||||||
{Math.round(hardLimitPercent)}% used
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{isNearLimit && (
|
|
||||||
<Text size="xs" c="red" mt={4}>
|
|
||||||
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Group gap="xs">
|
|
||||||
{filesCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
size="xs"
|
|
||||||
onClick={onClearAll}
|
|
||||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
|
||||||
>
|
|
||||||
{t("fileManager.clearAll", "Clear All")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
size="xs"
|
|
||||||
onClick={onReloadFiles}
|
|
||||||
>
|
|
||||||
{t("fileManager.reloadFiles", "Reload Files")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StorageStatsCard;
|
|
126
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal file
126
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getFileSize } from '../../utils/fileUtils';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface CompactFileDetailsProps {
|
||||||
|
currentFile: FileWithUrl | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
currentFileIndex: number;
|
||||||
|
numberOfFiles: number;
|
||||||
|
isAnimating: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||||
|
currentFile,
|
||||||
|
thumbnail,
|
||||||
|
selectedFiles,
|
||||||
|
currentFileIndex,
|
||||||
|
numberOfFiles,
|
||||||
|
isAnimating,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onOpenFiles
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasSelection = selectedFiles.length > 0;
|
||||||
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
{/* Compact mobile layout */}
|
||||||
|
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
{/* Small preview */}
|
||||||
|
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{currentFile && thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={currentFile.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
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: '0.25rem' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompactFileDetails;
|
89
frontend/src/components/fileManager/DesktopLayout.tsx
Normal file
89
frontend/src/components/fileManager/DesktopLayout.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid } from '@mantine/core';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const DesktopLayout: React.FC = () => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
recentFiles,
|
||||||
|
modalHeight,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
||||||
|
{/* Column 1: File Sources */}
|
||||||
|
<Grid.Col span="content" p="lg" style={{
|
||||||
|
minWidth: '13.625rem',
|
||||||
|
width: '13.625rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
<FileSourceButtons />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 2: File List */}
|
||||||
|
<Grid.Col span="auto" style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: '1 1 0px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'var(--bg-file-list)',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||||
|
}}>
|
||||||
|
<SearchInput />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
scrollAreaHeight={`calc(${modalHeight} )`}
|
||||||
|
scrollAreaStyle={{
|
||||||
|
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 3: File Details */}
|
||||||
|
<Grid.Col p="xl" span="content" style={{
|
||||||
|
minWidth: '25rem',
|
||||||
|
width: '25rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '18rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||||
|
<FileDetails />
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput />
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopLayout;
|
44
frontend/src/components/fileManager/DragOverlay.tsx
Normal file
44
frontend/src/components/fileManager/DragOverlay.tsx
Normal 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: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||||
|
borderRadius: '1.875rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<UploadFileIcon style={{ fontSize: '4rem', 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;
|
116
frontend/src/components/fileManager/FileDetails.tsx
Normal file
116
frontend/src/components/fileManager/FileDetails.tsx
Normal file
@ -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<FileDetailsProps> = ({
|
||||||
|
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 (
|
||||||
|
<CompactFileDetails
|
||||||
|
currentFile={currentFile}
|
||||||
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
currentFileIndex={currentFileIndex}
|
||||||
|
numberOfFiles={selectedFiles.length}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
|
{/* Section 1: Thumbnail Preview */}
|
||||||
|
<FilePreview
|
||||||
|
currentFile={currentFile}
|
||||||
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
numberOfFiles={selectedFiles.length}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section 2: File Details */}
|
||||||
|
<FileInfoCard
|
||||||
|
currentFile={currentFile}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
mb="xl"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDetails;
|
67
frontend/src/components/fileManager/FileInfoCard.tsx
Normal file
67
frontend/src/components/fileManager/FileInfoCard.tsx
Normal file
@ -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<FileInfoCardProps> = ({
|
||||||
|
currentFile,
|
||||||
|
modalHeight
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="sm">
|
||||||
|
<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>
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileInfoCard;
|
80
frontend/src/components/fileManager/FileListArea.tsx
Normal file
80
frontend/src/components/fileManager/FileListArea.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import FileListItem from './FileListItem';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileListAreaProps {
|
||||||
|
scrollAreaHeight: string;
|
||||||
|
scrollAreaStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||||
|
scrollAreaHeight,
|
||||||
|
scrollAreaStyle = {},
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
recentFiles,
|
||||||
|
filteredFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onFileDoubleClick,
|
||||||
|
isFileSupported,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (activeSource === 'recent') {
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
h={scrollAreaHeight}
|
||||||
|
style={{
|
||||||
|
...scrollAreaStyle
|
||||||
|
}}
|
||||||
|
type="always"
|
||||||
|
scrollbarSize={8}
|
||||||
|
>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{recentFiles.length === 0 ? (
|
||||||
|
<Center style={{ height: '12.5rem' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<HistoryIcon style={{ fontSize: '3rem', 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>
|
||||||
|
) : (
|
||||||
|
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: '12.5rem' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<CloudIcon style={{ fontSize: '3rem', 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;
|
84
frontend/src/components/fileManager/FileListItem.tsx
Normal file
84
frontend/src/components/fileManager/FileListItem.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface FileListItemProps {
|
||||||
|
file: FileWithUrl;
|
||||||
|
isSelected: boolean;
|
||||||
|
isSupported: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileListItem: React.FC<FileListItemProps> = ({
|
||||||
|
file,
|
||||||
|
isSelected,
|
||||||
|
isSupported,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onDoubleClick
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
|
||||||
|
opacity: isSupported ? 1 : 0.5,
|
||||||
|
transition: 'background-color 0.15s ease'
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}} // Handled by parent onClick
|
||||||
|
size="sm"
|
||||||
|
pl="sm"
|
||||||
|
pr="xs"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</Box>
|
||||||
|
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileListItem;
|
156
frontend/src/components/fileManager/FilePreview.tsx
Normal file
156
frontend/src/components/fileManager/FilePreview.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Center, ActionIcon, Image } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
currentFile: FileWithUrl | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
numberOfFiles: number;
|
||||||
|
isAnimating: boolean;
|
||||||
|
modalHeight: string;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
|
currentFile,
|
||||||
|
thumbnail,
|
||||||
|
numberOfFiles,
|
||||||
|
isAnimating,
|
||||||
|
modalHeight,
|
||||||
|
onPrevious,
|
||||||
|
onNext
|
||||||
|
}) => {
|
||||||
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
|
// Common style objects
|
||||||
|
const navigationArrowStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackDocumentBaseStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationStyle = {
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)',
|
||||||
|
opacity: isAnimating ? 0.7 : 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)';
|
||||||
|
const stackDocumentShadows = {
|
||||||
|
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={onPrevious}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
left: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Stack Container */}
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* Background documents (stack effect) */}
|
||||||
|
{/* Show 2 shadow pages for 3+ files */}
|
||||||
|
{numberOfFiles >= 3 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||||
|
boxShadow: stackDocumentShadows.back,
|
||||||
|
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show 1 shadow page for 2+ files */}
|
||||||
|
{numberOfFiles >= 2 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
|
boxShadow: stackDocumentShadows.middle,
|
||||||
|
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main document */}
|
||||||
|
{currentFile && thumbnail ? (
|
||||||
|
<Image
|
||||||
|
src={thumbnail}
|
||||||
|
alt={currentFile.name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
boxShadow: mainDocumentShadow,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
...animationStyle
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentFile ? (
|
||||||
|
<Center style={{
|
||||||
|
width: '80%',
|
||||||
|
height: '80%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
boxShadow: mainDocumentShadow,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
...animationStyle
|
||||||
|
}}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Navigation Arrow */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
right: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
103
frontend/src/components/fileManager/FileSourceButtons.tsx
Normal file
103
frontend/src/components/fileManager/FileSourceButtons.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileSourceButtonsProps {
|
||||||
|
horizontal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
|
horizontal = false
|
||||||
|
}) => {
|
||||||
|
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||||
|
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
||||||
|
getStyles: (source: string) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'transparent',
|
||||||
|
color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
|
||||||
|
border: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
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-6)'
|
||||||
|
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.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||||
|
{buttons}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||||
|
{t('fileManager.myFiles', 'My Files')}
|
||||||
|
</Text>
|
||||||
|
{buttons}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileSourceButtons;
|
20
frontend/src/components/fileManager/HiddenFileInput.tsx
Normal file
20
frontend/src/components/fileManager/HiddenFileInput.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const HiddenFileInput: React.FC = () => {
|
||||||
|
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple={true}
|
||||||
|
accept="*/*"
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
data-testid="file-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HiddenFileInput;
|
83
frontend/src/components/fileManager/MobileLayout.tsx
Normal file
83
frontend/src/components/fileManager/MobileLayout.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Box } from '@mantine/core';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const MobileLayout: React.FC = () => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
selectedFiles,
|
||||||
|
modalHeight,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
|
// Calculate the height more accurately based on actual content
|
||||||
|
const calculateFileListHeight = () => {
|
||||||
|
// Base modal height minus padding and gaps
|
||||||
|
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||||
|
|
||||||
|
// Estimate heights of fixed components
|
||||||
|
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||||
|
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
||||||
|
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
||||||
|
const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps
|
||||||
|
|
||||||
|
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* Section 1: File Sources - Fixed at top */}
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileSourceButtons horizontal={true} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileDetails compact={true} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||||
|
<Box style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'var(--bg-file-list)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 0
|
||||||
|
}}>
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<Box style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
|
}}>
|
||||||
|
<SearchInput />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
scrollAreaHeight={calculateFileListHeight()}
|
||||||
|
scrollAreaStyle={{
|
||||||
|
height: calculateFileListHeight(),
|
||||||
|
maxHeight: '60vh',
|
||||||
|
minHeight: '9.375rem',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileLayout;
|
33
frontend/src/components/fileManager/SearchInput.tsx
Normal file
33
frontend/src/components/fileManager/SearchInput.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { searchTerm, onSearchChange } = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
||||||
|
leftSection={<SearchIcon />}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
|
||||||
|
style={{ padding: '0.5rem', ...style }}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchInput;
|
@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage";
|
|||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
|
import { FileWithUrl } from "../../types/file";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
import SortIcon from "@mui/icons-material/Sort";
|
||||||
import FileCard from "../fileManagement/FileCard";
|
import FileCard from "./FileCard";
|
||||||
import { FileWithUrl } from "../../types/file";
|
import { FileWithUrl } from "../../types/file";
|
||||||
|
|
||||||
interface FileGridProps {
|
interface FileGridProps {
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
case 'REMOVE_FILES':
|
case 'REMOVE_FILES':
|
||||||
const remainingFiles = state.activeFiles.filter(file => {
|
const remainingFiles = state.activeFiles.filter(file => {
|
||||||
const fileId = getFileId(file);
|
const fileId = getFileId(file);
|
||||||
return !action.payload.includes(fileId);
|
return !fileId || !action.payload.includes(fileId);
|
||||||
});
|
});
|
||||||
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
||||||
return {
|
return {
|
||||||
@ -491,26 +491,38 @@ export function FileContextProvider({
|
|||||||
}, [cleanupFile]);
|
}, [cleanupFile]);
|
||||||
|
|
||||||
// Action implementations
|
// Action implementations
|
||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
||||||
dispatch({ type: 'ADD_FILES', payload: files });
|
dispatch({ type: 'ADD_FILES', payload: files });
|
||||||
|
|
||||||
// Auto-save to IndexedDB if persistence enabled
|
// Auto-save to IndexedDB if persistence enabled
|
||||||
if (enablePersistence) {
|
if (enablePersistence) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
// Check if file already has an ID (already in IndexedDB)
|
// Check if file already has an explicit ID property (already in IndexedDB)
|
||||||
const fileId = getFileId(file);
|
const fileId = getFileId(file);
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
// File doesn't have ID, store it and get the ID
|
// File doesn't have explicit ID, store it with thumbnail
|
||||||
const storedFile = await fileStorage.storeFile(file);
|
try {
|
||||||
// Add the ID to the file object
|
// Generate thumbnail for better recent files experience
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
const thumbnail = await thumbnailGenerationService.generateThumbnail(file);
|
||||||
|
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||||
|
// Add the ID to the file object
|
||||||
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
|
} catch (thumbnailError) {
|
||||||
|
// If thumbnail generation fails, store without thumbnail
|
||||||
|
console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
|
||||||
|
const storedFile = await fileStorage.storeFile(file);
|
||||||
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file:', error);
|
console.error('Failed to store file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return files with their IDs assigned
|
||||||
|
return files;
|
||||||
}, [enablePersistence]);
|
}, [enablePersistence]);
|
||||||
|
|
||||||
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
||||||
@ -682,7 +694,7 @@ export function FileContextProvider({
|
|||||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||||
return state.activeFiles.find(file => {
|
return state.activeFiles.find(file => {
|
||||||
const actualFileId = getFileId(file);
|
const actualFileId = getFileId(file);
|
||||||
return actualFileId === fileId;
|
return actualFileId && actualFileId === fileId;
|
||||||
});
|
});
|
||||||
}, [state.activeFiles]);
|
}, [state.activeFiles]);
|
||||||
|
|
||||||
|
218
frontend/src/contexts/FileManagerContext.tsx
Normal file
218
frontend/src/contexts/FileManagerContext.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { StoredFile } from '../services/fileStorage';
|
||||||
|
|
||||||
|
// Type for the context value - now contains everything directly
|
||||||
|
interface FileManagerContextValue {
|
||||||
|
// State
|
||||||
|
activeSource: 'recent' | 'local' | 'drive';
|
||||||
|
selectedFileIds: string[];
|
||||||
|
searchTerm: string;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
filteredFiles: FileWithUrl[];
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||||
|
onLocalFileClick: () => void;
|
||||||
|
onFileSelect: (file: FileWithUrl) => void;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
|
||||||
|
// External props
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
modalHeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context
|
||||||
|
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||||
|
|
||||||
|
// Provider component props
|
||||||
|
interface FileManagerProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
onFilesSelected: (files: FileWithUrl[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
modalHeight: string;
|
||||||
|
storeFile: (file: File) => Promise<StoredFile>;
|
||||||
|
refreshRecentFiles: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
recentFiles,
|
||||||
|
onFilesSelected,
|
||||||
|
onClose,
|
||||||
|
isFileSupported,
|
||||||
|
isOpen,
|
||||||
|
onFileRemove,
|
||||||
|
modalHeight,
|
||||||
|
storeFile,
|
||||||
|
refreshRecentFiles,
|
||||||
|
}) => {
|
||||||
|
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||||
|
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Track blob URLs for cleanup
|
||||||
|
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Computed values (with null safety)
|
||||||
|
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
|
||||||
|
const filteredFiles = (recentFiles || []).filter(file =>
|
||||||
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||||
|
setActiveSource(source);
|
||||||
|
if (source !== 'recent') {
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLocalFileClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||||
|
setSelectedFileIds(prev => {
|
||||||
|
if (prev.includes(file.id)) {
|
||||||
|
return prev.filter(id => id !== file.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, file.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileRemove = useCallback((index: number) => {
|
||||||
|
const fileToRemove = filteredFiles[index];
|
||||||
|
if (fileToRemove) {
|
||||||
|
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
|
||||||
|
}
|
||||||
|
onFileRemove(index);
|
||||||
|
}, [filteredFiles, onFileRemove]);
|
||||||
|
|
||||||
|
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
||||||
|
if (isFileSupported(file.name)) {
|
||||||
|
onFilesSelected([file]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [isFileSupported, onFilesSelected, onClose]);
|
||||||
|
|
||||||
|
const handleOpenFiles = useCallback(() => {
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
onFilesSelected(selectedFiles);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [selectedFiles, onFilesSelected, onClose]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
||||||
|
const fileWithUrls = files.map(file => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
createdBlobUrls.current.add(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
url,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onFilesSelected(fileWithUrls);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process selected files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
||||||
|
|
||||||
|
// Cleanup blob URLs when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clean up all created blob URLs
|
||||||
|
createdBlobUrls.current.forEach(url => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
createdBlobUrls.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset state when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setActiveSource('recent');
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const contextValue: FileManagerContextValue = {
|
||||||
|
// State
|
||||||
|
activeSource,
|
||||||
|
selectedFileIds,
|
||||||
|
searchTerm,
|
||||||
|
selectedFiles,
|
||||||
|
filteredFiles,
|
||||||
|
fileInputRef,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onSourceChange: handleSourceChange,
|
||||||
|
onLocalFileClick: handleLocalFileClick,
|
||||||
|
onFileSelect: handleFileSelect,
|
||||||
|
onFileRemove: handleFileRemove,
|
||||||
|
onFileDoubleClick: handleFileDoubleClick,
|
||||||
|
onOpenFiles: handleOpenFiles,
|
||||||
|
onSearchChange: handleSearchChange,
|
||||||
|
onFileInputChange: handleFileInputChange,
|
||||||
|
|
||||||
|
// External props
|
||||||
|
recentFiles,
|
||||||
|
isFileSupported,
|
||||||
|
modalHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileManagerContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</FileManagerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook to use the context
|
||||||
|
export const useFileManagerContext = (): FileManagerContextValue => {
|
||||||
|
const context = useContext(FileManagerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
||||||
|
'Make sure you wrap your component with <FileManagerProvider>.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the context for advanced use cases
|
||||||
|
export { FileManagerContext };
|
@ -1,21 +1,58 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal';
|
|
||||||
import { useFileHandler } from '../hooks/useFileHandler';
|
import { useFileHandler } from '../hooks/useFileHandler';
|
||||||
|
|
||||||
interface FilesModalContextType extends UseFilesModalReturn {}
|
interface FilesModalContextType {
|
||||||
|
isFilesModalOpen: boolean;
|
||||||
|
openFilesModal: () => void;
|
||||||
|
closeFilesModal: () => void;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
onFilesSelect: (files: File[]) => void;
|
||||||
|
onModalClose: () => void;
|
||||||
|
setOnModalClose: (callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||||
|
|
||||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
||||||
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||||
const filesModal = useFilesModal({
|
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||||
onFileSelect: addToActiveFiles,
|
|
||||||
onFilesSelect: addMultipleFiles,
|
const openFilesModal = useCallback(() => {
|
||||||
});
|
setIsFilesModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeFilesModal = useCallback(() => {
|
||||||
|
setIsFilesModalOpen(false);
|
||||||
|
onModalClose?.();
|
||||||
|
}, [onModalClose]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
|
addToActiveFiles(file);
|
||||||
|
closeFilesModal();
|
||||||
|
}, [addToActiveFiles, closeFilesModal]);
|
||||||
|
|
||||||
|
const handleFilesSelect = useCallback((files: File[]) => {
|
||||||
|
addMultipleFiles(files);
|
||||||
|
closeFilesModal();
|
||||||
|
}, [addMultipleFiles, closeFilesModal]);
|
||||||
|
|
||||||
|
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||||
|
setOnModalClose(() => callback);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: FilesModalContextType = {
|
||||||
|
isFilesModalOpen,
|
||||||
|
openFilesModal,
|
||||||
|
closeFilesModal,
|
||||||
|
onFileSelect: handleFileSelect,
|
||||||
|
onFilesSelect: handleFilesSelect,
|
||||||
|
onModalClose,
|
||||||
|
setOnModalClose: setModalCloseCallback,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilesModalContext.Provider value={filesModal}>
|
<FilesModalContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</FilesModalContext.Provider>
|
</FilesModalContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -358,7 +358,10 @@ export const useConvertOperation = (): ConvertOperationHook => {
|
|||||||
setDownloadFilename(convertedFile.name);
|
setDownloadFilename(convertedFile.name);
|
||||||
setStatus(t("downloadComplete"));
|
setStatus(t("downloadComplete"));
|
||||||
|
|
||||||
await processResults(new Blob([convertedFile]), convertedFile.name);
|
// Update local files state for hook consumers
|
||||||
|
setFiles([convertedFile]);
|
||||||
|
|
||||||
|
await addFiles([convertedFile]);
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -63,7 +64,12 @@ export const useFileManager = () => {
|
|||||||
|
|
||||||
const storeFile = useCallback(async (file: File) => {
|
const storeFile = useCallback(async (file: File) => {
|
||||||
try {
|
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
|
// Add the ID to the file object
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
return storedFile;
|
return storedFile;
|
||||||
@ -111,12 +117,21 @@ export const useFileManager = () => {
|
|||||||
};
|
};
|
||||||
}, [convertToFile]);
|
}, [convertToFile]);
|
||||||
|
|
||||||
|
const touchFile = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fileStorage.touchFile(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to touch file:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
convertToFile,
|
convertToFile,
|
||||||
loadRecentFiles,
|
loadRecentFiles,
|
||||||
handleRemoveFile,
|
handleRemoveFile,
|
||||||
storeFile,
|
storeFile,
|
||||||
|
touchFile,
|
||||||
createFileSelectionHandlers
|
createFileSelectionHandlers
|
||||||
};
|
};
|
||||||
};
|
};
|
@ -1,57 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface UseFilesModalReturn {
|
|
||||||
isFilesModalOpen: boolean;
|
|
||||||
openFilesModal: () => void;
|
|
||||||
closeFilesModal: () => void;
|
|
||||||
onFileSelect?: (file: File) => void;
|
|
||||||
onFilesSelect?: (files: File[]) => void;
|
|
||||||
onModalClose?: () => void;
|
|
||||||
setOnModalClose: (callback: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFilesModalProps {
|
|
||||||
onFileSelect?: (file: File) => void;
|
|
||||||
onFilesSelect?: (files: File[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFilesModal = ({
|
|
||||||
onFileSelect,
|
|
||||||
onFilesSelect
|
|
||||||
}: UseFilesModalProps = {}): UseFilesModalReturn => {
|
|
||||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
|
||||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
|
||||||
|
|
||||||
const openFilesModal = useCallback(() => {
|
|
||||||
setIsFilesModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeFilesModal = useCallback(() => {
|
|
||||||
setIsFilesModalOpen(false);
|
|
||||||
onModalClose?.();
|
|
||||||
}, [onModalClose]);
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: File) => {
|
|
||||||
onFileSelect?.(file);
|
|
||||||
closeFilesModal();
|
|
||||||
}, [onFileSelect, closeFilesModal]);
|
|
||||||
|
|
||||||
const handleFilesSelect = useCallback((files: File[]) => {
|
|
||||||
onFilesSelect?.(files);
|
|
||||||
closeFilesModal();
|
|
||||||
}, [onFilesSelect, closeFilesModal]);
|
|
||||||
|
|
||||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
|
||||||
setOnModalClose(() => callback);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFilesModalOpen,
|
|
||||||
openFilesModal,
|
|
||||||
closeFilesModal,
|
|
||||||
onFileSelect: handleFileSelect,
|
|
||||||
onFilesSelect: handleFilesSelect,
|
|
||||||
onModalClose,
|
|
||||||
setOnModalClose: setModalCloseCallback,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,6 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getDocument } from "pdfjs-dist";
|
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
|
import { fileStorage } from "../services/fileStorage";
|
||||||
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal scale for thumbnail generation
|
||||||
|
* Ensures high quality while preventing oversized renders
|
||||||
|
*/
|
||||||
|
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||||
|
const maxWidth = 400; // Max thumbnail width
|
||||||
|
const maxHeight = 600; // Max thumbnail height
|
||||||
|
|
||||||
|
const scaleX = maxWidth / pageViewport.width;
|
||||||
|
const scaleY = maxHeight / pageViewport.height;
|
||||||
|
|
||||||
|
// Don't upscale, only downscale if needed
|
||||||
|
return Math.min(scaleX, scaleY, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for IndexedDB-aware thumbnail loading
|
* Hook for IndexedDB-aware thumbnail loading
|
||||||
@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: for IndexedDB files without stored thumbnails, just use placeholder
|
// Second priority: generate thumbnail for any file type
|
||||||
if (file.storedInIndexedDB && file.id) {
|
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||||
// 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) {
|
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
let fileObject: File;
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
|
||||||
const page = await pdf.getPage(1);
|
// Handle IndexedDB files vs regular File objects
|
||||||
const viewport = page.getViewport({ scale: 0.2 });
|
if (file.storedInIndexedDB && file.id) {
|
||||||
const canvas = document.createElement("canvas");
|
// For IndexedDB files, recreate File object from stored data
|
||||||
canvas.width = viewport.width;
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
canvas.height = viewport.height;
|
if (!storedFile) {
|
||||||
const context = canvas.getContext("2d");
|
throw new Error('File not found in IndexedDB');
|
||||||
if (context && !cancelled) {
|
}
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
fileObject = new File([storedFile.data], storedFile.name, {
|
||||||
if (!cancelled) setThumb(canvas.toDataURL());
|
type: storedFile.type,
|
||||||
|
lastModified: storedFile.lastModified
|
||||||
|
});
|
||||||
|
} else if (file.file) {
|
||||||
|
// For FileWithUrl objects that have a File object
|
||||||
|
fileObject = file.file;
|
||||||
|
} else if (file.id) {
|
||||||
|
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
||||||
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
|
if (!storedFile) {
|
||||||
|
throw new Error('File not found in IndexedDB and no File object available');
|
||||||
|
}
|
||||||
|
fileObject = new File([storedFile.data], storedFile.name, {
|
||||||
|
type: storedFile.type,
|
||||||
|
lastModified: storedFile.lastModified
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('File object not available and no ID for IndexedDB lookup');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the universal thumbnail generator
|
||||||
|
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||||
|
if (!cancelled && thumbnail) {
|
||||||
|
setThumb(thumbnail);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setThumb(null);
|
||||||
}
|
}
|
||||||
pdf.destroy(); // Clean up memory
|
|
||||||
} catch (error) {
|
} 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);
|
if (!cancelled) setThumb(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setGenerating(false);
|
if (!cancelled) setGenerating(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Large files or files without proper conditions - show placeholder
|
// Large files - generate placeholder
|
||||||
setThumb(null);
|
setThumb(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ import Viewer from "../components/viewer/Viewer";
|
|||||||
import ToolRenderer from "../components/tools/ToolRenderer";
|
import ToolRenderer from "../components/tools/ToolRenderer";
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
import LandingPage from "../components/shared/LandingPage";
|
import LandingPage from "../components/shared/LandingPage";
|
||||||
import FileUploadModal from "../components/shared/FileUploadModal";
|
import FileManager from "../components/FileManager";
|
||||||
|
|
||||||
|
|
||||||
function HomePageContent() {
|
function HomePageContent() {
|
||||||
@ -279,7 +279,7 @@ function HomePageContent() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Global Modals */}
|
{/* Global Modals */}
|
||||||
<FileUploadModal selectedTool={selectedTool} />
|
<FileManager selectedTool={selectedTool} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -225,6 +225,32 @@ class FileStorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the lastModified timestamp of a file (for most recently used sorting)
|
||||||
|
*/
|
||||||
|
async touchFile(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
||||||
|
const getRequest = store.get(id);
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
const file = getRequest.result;
|
||||||
|
if (file) {
|
||||||
|
// Update lastModified to current timestamp
|
||||||
|
file.lastModified = Date.now();
|
||||||
|
const updateRequest = store.put(file);
|
||||||
|
updateRequest.onsuccess = () => resolve(true);
|
||||||
|
updateRequest.onerror = () => reject(updateRequest.error);
|
||||||
|
} else {
|
||||||
|
resolve(false); // File not found
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getRequest.onerror = () => reject(getRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all stored files
|
* Clear all stored files
|
||||||
*/
|
*/
|
||||||
|
@ -74,6 +74,9 @@
|
|||||||
--bg-muted: #f3f4f6;
|
--bg-muted: #f3f4f6;
|
||||||
--bg-background: #f9fafb;
|
--bg-background: #f9fafb;
|
||||||
--bg-toolbar: #ffffff;
|
--bg-toolbar: #ffffff;
|
||||||
|
--bg-file-manager: #F5F6F8;
|
||||||
|
--bg-file-list: #ffffff;
|
||||||
|
--btn-open-file: #0A8BFF;
|
||||||
--text-primary: #111827;
|
--text-primary: #111827;
|
||||||
--text-secondary: #4b5563;
|
--text-secondary: #4b5563;
|
||||||
--text-muted: #6b7280;
|
--text-muted: #6b7280;
|
||||||
@ -175,6 +178,9 @@
|
|||||||
--bg-muted: #1F2329;
|
--bg-muted: #1F2329;
|
||||||
--bg-background: #2A2F36;
|
--bg-background: #2A2F36;
|
||||||
--bg-toolbar: #272A2E;
|
--bg-toolbar: #272A2E;
|
||||||
|
--bg-file-manager: #1F2329;
|
||||||
|
--bg-file-list: #2A2F36;
|
||||||
|
--btn-open-file: #0A8BFF;
|
||||||
--text-primary: #f9fafb;
|
--text-primary: #f9fafb;
|
||||||
--text-secondary: #d1d5db;
|
--text-secondary: #d1d5db;
|
||||||
--text-muted: #9ca3af;
|
--text-muted: #9ca3af;
|
||||||
|
@ -23,13 +23,31 @@ import axios from 'axios';
|
|||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockedAxios = vi.mocked(axios);
|
||||||
|
|
||||||
// Mock utility modules
|
// Mock only essential services that are actually called by the tests
|
||||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
vi.mock('../../services/fileStorage', () => ({
|
||||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
fileStorage: {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: `mock-id-${file.name}`,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../utils/api', () => ({
|
vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||||
makeApiUrl: vi.fn((path: string) => `/api/v1${path}`)
|
thumbnailGenerationService: {
|
||||||
|
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
destroy: vi.fn()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create realistic test files
|
// Create realistic test files
|
||||||
@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should correctly map image conversion parameters to API call', async () => {
|
test('should correctly map image conversion parameters to API call', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/jpeg',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.jpg"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should record operation in FileContext', async () => {
|
test('should record operation in FileContext', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.png"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should clean up blob URLs on reset', async () => {
|
test('should clean up blob URLs on reset', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.png"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
|
@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils';
|
|||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockedAxios = vi.mocked(axios);
|
||||||
|
|
||||||
// Mock utility modules
|
// Mock only essential services that are actually called by the tests
|
||||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
vi.mock('../../services/fileStorage', () => ({
|
||||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
fileStorage: {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: `mock-id-${file.name}`,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||||
|
thumbnailGenerationService: {
|
||||||
|
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
destroy: vi.fn()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
import { StoredFile, fileStorage } from "../services/fileStorage";
|
import { StoredFile, fileStorage } from "../services/fileStorage";
|
||||||
|
|
||||||
export function getFileId(file: File): string {
|
export function getFileId(file: File): string | null {
|
||||||
return (file as File & { id?: string }).id || file.name;
|
return (file as File & { id?: string }).id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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<string, any> = {
|
||||||
|
// 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
|
* Returns base64 data URL or undefined if generation fails
|
||||||
*/
|
*/
|
||||||
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
||||||
// Skip thumbnail generation for large files to avoid memory issues
|
// Skip thumbnail generation for very large files to avoid memory issues
|
||||||
if (file.size >= 50 * 1024 * 1024) { // 50MB limit
|
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
|
||||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
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')) {
|
if (!file.type.startsWith('application/pdf')) {
|
||||||
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
|
console.log('File is not a PDF or image, generating placeholder:', file.name);
|
||||||
return undefined;
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Loading…
Reference in New Issue
Block a user