Restructure files

This commit is contained in:
Connor Yoh 2025-08-06 10:28:15 +01:00
parent 5acb700f71
commit 68b279deac
20 changed files with 682 additions and 426 deletions

View File

@ -2,14 +2,14 @@ import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import { FileWithUrl } from '../../types/file';
import { useFileManager } from '../../hooks/useFileManager';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { Tool } from '../../types/tool';
import { FileWithUrl } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from './fileManager/FileManagerContext';
import { FileManagerProvider } from '../contexts/FileManagerContext';
interface FileManagerProps {
selectedTool?: Tool | null;

View File

@ -0,0 +1,125 @@
import React from 'react';
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
interface CompactFileDetailsProps {
currentFile: FileWithUrl | null;
thumbnail: string | null;
selectedFiles: FileWithUrl[];
currentFileIndex: number;
hasMultipleFiles: boolean;
isAnimating: boolean;
onPrevious: () => void;
onNext: () => void;
onOpenFiles: () => void;
}
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
currentFile,
thumbnail,
selectedFiles,
currentFileIndex,
hasMultipleFiles,
isAnimating,
onPrevious,
onNext,
onOpenFiles
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
return (
<Stack gap="xs" style={{ height: '100%' }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
{/* Small preview */}
<Box style={{ width: '120px', height: '150px', 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: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '4px' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={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;

View File

@ -5,7 +5,7 @@ import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
const DesktopLayout: React.FC = () => {
const {

View 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}
hasMultipleFiles={hasMultipleFiles}
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()}
hasMultipleFiles={hasMultipleFiles}
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;

View 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;

View File

@ -4,7 +4,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import FileListItem from './FileListItem';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileListAreaProps {
scrollAreaHeight: string;
@ -28,20 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
const { t } = useTranslation();
if (activeSource === 'recent') {
if (recentFiles.length === 0) {
return (
<Center style={{ height: '200px' }}>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
);
}
return (
<ScrollArea
h={scrollAreaHeight}
@ -52,17 +38,29 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{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)}
/>
))}
{recentFiles.length === 0 ? (
<Center style={{ height: '200px' }}>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
) : (
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>
);

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import { getFileSize, getFileDate } from '../../../utils/fileUtils';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileListItemProps } from './types';
const FileListItem: React.FC<FileListItemProps> = ({

View File

@ -0,0 +1,144 @@
import React from 'react';
import { Box, Center, ActionIcon, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { FileWithUrl } from '../../types/file';
interface FilePreviewProps {
currentFile: FileWithUrl | null;
thumbnail: string | null;
hasMultipleFiles: boolean;
isAnimating: boolean;
modalHeight: string;
onPrevious: () => void;
onNext: () => void;
}
const FilePreview: React.FC<FilePreviewProps> = ({
currentFile,
thumbnail,
hasMultipleFiles,
isAnimating,
modalHeight,
onPrevious,
onNext
}) => {
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={{
position: 'absolute',
left: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronLeftIcon />
</ActionIcon>
)}
{/* Document Stack Container */}
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Background documents (stack effect) */}
{hasMultipleFiles && (
<>
{/* Third document (furthest back) */}
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transform: 'translate(12px, 12px) rotate(2deg)',
zIndex: 1
}}
/>
{/* Second document */}
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
boxShadow: '0 3px 10px rgba(0, 0, 0, 0.12)',
transform: 'translate(6px, 6px) 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: '0 6px 16px rgba(0, 0, 0, 0.2)',
borderRadius: '8px',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}
/>
) : currentFile ? (
<Center style={{
width: '80%',
height: '80%',
backgroundColor: 'var(--mantine-color-gray-1)',
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}>
<PictureAsPdfIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* Right Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={onNext}
color="blue"
disabled={isAnimating}
style={{
position: 'absolute',
right: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronRightIcon />
</ActionIcon>
)}
</Box>
</Box>
);
};
export default FilePreview;

View File

@ -4,7 +4,7 @@ import HistoryIcon from '@mui/icons-material/History';
import FolderIcon from '@mui/icons-material/Folder';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileSourceButtonsProps {
horizontal?: boolean;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
const HiddenFileInput: React.FC = () => {
const { fileInputRef, onFileInputChange } = useFileManagerContext();

View File

@ -5,7 +5,7 @@ import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
const MobileLayout: React.FC = () => {
const {

View File

@ -2,7 +2,7 @@ import React from 'react';
import { TextInput } from '@mantine/core';
import SearchIcon from '@mui/icons-material/Search';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from './FileManagerContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface SearchInputProps {
style?: React.CSSProperties;

View File

@ -1,4 +1,4 @@
import { FileWithUrl } from '../../../types/file';
import { FileWithUrl } from '../../types/file';
export type FileSource = 'recent' | 'local' | 'drive';

View File

@ -1,350 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Box, Center, Text, Badge, Button, Image, Group, Divider, ActionIcon, ScrollArea } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../../utils/fileUtils';
import { useIndexedDBThumbnail } from '../../../hooks/useIndexedDBThumbnail';
import { useFileManagerContext } from './FileManagerContext';
interface FileDetailsProps {
compact?: boolean;
}
const FileDetails: React.FC<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 (
<Stack gap="xs" style={{ height: '100%' }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
{/* Small preview */}
<Box style={{ width: '120px', height: '150px', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && getCurrentThumbnail() ? (
<img
src={getCurrentThumbnail()}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '4px' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
)}
</Box>
{/* Action Button */}
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
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>
);
}
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
{/* Section 1: Thumbnail Preview */}
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Left Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={handlePrevious}
color="blue"
disabled={isAnimating}
style={{
position: 'absolute',
left: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronLeftIcon />
</ActionIcon>
)}
{/* Document Stack Container */}
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Background documents (stack effect) */}
{hasMultipleFiles && selectedFiles.length > 1 && (
<>
{/* Third document (furthest back) */}
{selectedFiles.length > 2 && (
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transform: 'translate(12px, 12px) rotate(2deg)',
zIndex: 1
}}
/>
)}
{/* Second document */}
<Box
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
boxShadow: '0 3px 10px rgba(0, 0, 0, 0.12)',
transform: 'translate(6px, 6px) rotate(1deg)',
zIndex: 2
}}
/>
</>
)}
{/* Main document */}
{currentFile && getCurrentThumbnail() ? (
<Image
src={getCurrentThumbnail()}
alt={currentFile.name}
fit="contain"
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}
/>
) : currentFile ? (
<Center style={{
width: '80%',
height: '80%',
backgroundColor: 'var(--mantine-color-gray-1)',
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
position: 'relative',
zIndex: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
opacity: isAnimating ? 0.7 : 1
}}>
<PictureAsPdfIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* Right Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={handleNext}
color="blue"
disabled={isAnimating}
style={{
position: 'absolute',
right: '0',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
}}
>
<ChevronRightIcon />
</ActionIcon>
)}
</Box>
</Box>
{/* Section 2: File Details */}
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
</Text>
</Box>
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="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>
{selectedFiles.length > 1 && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.totalSelected', 'Selected')}</Text>
<Text size="sm" fw={500}>
{selectedFiles.length} files
</Text>
</Group>
</>
)}
</Stack>
</ScrollArea>
</Card>
<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;

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../../../types/file';
import { FileSource } from './types';
import { FileWithUrl } from '../types/file';
import { FileSource } from '../components/fileManager/types';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { fileStorage } from '../services/fileStorage';
import { FileWithUrl } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
@ -63,7 +64,12 @@ export const useFileManager = () => {
const storeFile = useCallback(async (file: File) => {
try {
const storedFile = await fileStorage.storeFile(file);
// Generate thumbnail for the file
const thumbnail = await generateThumbnailForFile(file);
// Store file with thumbnail
const storedFile = await fileStorage.storeFile(file, thumbnail);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return storedFile;

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { getDocument } from "pdfjs-dist";
import { FileWithUrl } from "../types/file";
import { fileStorage } from "../services/fileStorage";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
/**
* Calculate optimal scale for thumbnail generation
@ -44,50 +44,47 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
return;
}
// Second priority: generate from blob for files (both IndexedDB and regular files, small files only)
if (file.size < 50 * 1024 * 1024 && !generating) {
// Second priority: generate thumbnail for any file type
if (file.size < 100 * 1024 * 1024 && !generating) {
setGenerating(true);
try {
let arrayBuffer: ArrayBuffer;
let fileObject: File;
// Handle IndexedDB files vs regular File objects
if (file.storedInIndexedDB && file.id) {
// For IndexedDB files, get the data from storage
// For IndexedDB files, recreate File object from stored data
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
throw new Error('File not found in IndexedDB');
}
arrayBuffer = storedFile.data;
} else if (typeof file.arrayBuffer === 'function') {
// For regular File objects, use arrayBuffer method
arrayBuffer = await file.arrayBuffer();
fileObject = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
} else if (file.file) {
// For FileWithUrl objects that have a File object
fileObject = file.file;
} else if (file.id) {
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
throw new Error('File has no arrayBuffer method and not found in IndexedDB');
throw new Error('File not found in IndexedDB and no File object available');
}
arrayBuffer = storedFile.data;
fileObject = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
} else {
throw new Error('File object has no arrayBuffer method and no ID for IndexedDB lookup');
throw new Error('File object not available and no ID for IndexedDB lookup');
}
const pdf = await getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
// Calculate optimal scale and create viewport
const baseViewport = page.getViewport({ scale: 1.0 });
const scale = calculateThumbnailScale(baseViewport);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (context && !cancelled) {
await page.render({ canvasContext: context, viewport }).promise;
if (!cancelled) setThumb(canvas.toDataURL());
// Use the universal thumbnail generator
const thumbnail = await generateThumbnailForFile(fileObject);
if (!cancelled && thumbnail) {
setThumb(thumbnail);
} else if (!cancelled) {
setThumb(null);
}
pdf.destroy(); // Clean up memory
} catch (error) {
console.warn('Failed to generate thumbnail for file', file.name, error);
if (!cancelled) setThumb(null);
@ -95,7 +92,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
if (!cancelled) setGenerating(false);
}
} else {
// Large files or files without proper conditions - show placeholder
// Large files - generate placeholder
setThumb(null);
}
}

View File

@ -18,7 +18,7 @@ import Viewer from "../components/viewer/Viewer";
import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import LandingPage from "../components/shared/LandingPage";
import FileManager from "../components/shared/FileManager";
import FileManager from "../components/FileManager";
function HomePageContent() {
const { t } = useTranslation();

View File

@ -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
*/
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
// Skip thumbnail generation for large files to avoid memory issues
if (file.size >= 50 * 1024 * 1024) { // 50MB limit
// Skip thumbnail generation for very large files to avoid memory issues
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
console.log('Skipping thumbnail generation for large file:', file.name);
return undefined;
return generatePlaceholderThumbnail(file);
}
// Handle image files - use original file directly
if (file.type.startsWith('image/')) {
return URL.createObjectURL(file);
}
// Handle PDF files
if (!file.type.startsWith('application/pdf')) {
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
return undefined;
console.log('File is not a PDF or image, generating placeholder:', file.name);
return generatePlaceholderThumbnail(file);
}
try {