mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-06 13:48:58 +02:00
Restructure files
This commit is contained in:
parent
5acb700f71
commit
68b279deac
@ -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;
|
125
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal file
125
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal 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;
|
@ -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 {
|
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}
|
||||
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;
|
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;
|
@ -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>
|
||||
);
|
@ -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> = ({
|
144
frontend/src/components/fileManager/FilePreview.tsx
Normal file
144
frontend/src/components/fileManager/FilePreview.tsx
Normal 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;
|
@ -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;
|
@ -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();
|
@ -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 {
|
@ -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;
|
@ -1,4 +1,4 @@
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
import { FileWithUrl } from '../../types/file';
|
||||
|
||||
export type FileSource = 'recent' | 'local' | 'drive';
|
||||
|
@ -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;
|
@ -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 {
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user