mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-03 17:52:30 +02:00
Working filemanager
This commit is contained in:
parent
ddb4f0fb38
commit
2d6135c743
269
frontend/src/components/shared/FileManager.tsx
Normal file
269
frontend/src/components/shared/FileManager.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Modal } from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
import { useFileManager } from '../../hooks/useFileManager';
|
||||||
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
|
import { Tool } from '../../types/tool';
|
||||||
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
|
import DragOverlay from './fileManager/DragOverlay';
|
||||||
|
import { FileSource } from './fileManager/types';
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
selectedTool?: Tool | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [activeSource, setActiveSource] = useState<FileSource>('recent');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||||
|
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||||
|
|
||||||
|
// File management handlers
|
||||||
|
const isFileSupported = useCallback((fileName: string) => {
|
||||||
|
if (!selectedTool?.supportedFormats) return true;
|
||||||
|
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
return selectedTool.supportedFormats.includes(extension || '');
|
||||||
|
}, [selectedTool?.supportedFormats]);
|
||||||
|
|
||||||
|
const refreshRecentFiles = useCallback(async () => {
|
||||||
|
const files = await loadRecentFiles();
|
||||||
|
setRecentFiles(files);
|
||||||
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
|
const openFileDialog = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Store files in IndexedDB and get FileWithUrl objects
|
||||||
|
const storedFiles = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
await storeFile(file);
|
||||||
|
return file;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onFilesSelect(storedFiles);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process uploaded files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear the input
|
||||||
|
event.target.value = '';
|
||||||
|
}, [storeFile, onFilesSelect, refreshRecentFiles]);
|
||||||
|
|
||||||
|
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Store files and refresh recent files
|
||||||
|
await Promise.all(files.map(file => storeFile(file)));
|
||||||
|
onFilesSelect(files);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process dropped files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [storeFile, onFilesSelect, refreshRecentFiles]);
|
||||||
|
|
||||||
|
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
|
||||||
|
try {
|
||||||
|
const fileObj = await convertToFile(file);
|
||||||
|
if (onFileSelect) {
|
||||||
|
onFileSelect(fileObj);
|
||||||
|
} else {
|
||||||
|
onFilesSelect([fileObj]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select recent file:', error);
|
||||||
|
}
|
||||||
|
}, [onFileSelect, onFilesSelect, convertToFile]);
|
||||||
|
|
||||||
|
// Selection handlers
|
||||||
|
const selectionHandlers = {
|
||||||
|
toggleSelection: (fileId: string) => {
|
||||||
|
setSelectedFileIds(prev =>
|
||||||
|
prev.includes(fileId)
|
||||||
|
? prev.filter(id => id !== fileId)
|
||||||
|
: [...prev, fileId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearSelection: () => setSelectedFileIds([])
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedFiles = recentFiles.filter(file =>
|
||||||
|
selectedFileIds.includes(file.id || file.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredFiles = recentFiles.filter(file =>
|
||||||
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenFiles = useCallback(() => {
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const filesAsFileObjects = selectedFiles.map(fileWithUrl => {
|
||||||
|
const file = new File([], fileWithUrl.name, { type: fileWithUrl.type });
|
||||||
|
Object.defineProperty(file, 'size', { value: fileWithUrl.size || 0 });
|
||||||
|
Object.defineProperty(file, 'lastModified', { value: fileWithUrl.lastModified || Date.now() });
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
onFilesSelect(filesAsFileObjects);
|
||||||
|
selectionHandlers.clearSelection();
|
||||||
|
}
|
||||||
|
}, [selectedFiles, onFilesSelect, selectionHandlers]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||||
|
selectionHandlers.toggleSelection(file.id || file.name);
|
||||||
|
}, [selectionHandlers]);
|
||||||
|
|
||||||
|
const handleFileDoubleClick = useCallback(async (file: FileWithUrl) => {
|
||||||
|
try {
|
||||||
|
const fileObj = await convertToFile(file);
|
||||||
|
onFilesSelect([fileObj]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file on double-click:', error);
|
||||||
|
}
|
||||||
|
}, [convertToFile, onFilesSelect]);
|
||||||
|
|
||||||
|
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||||
|
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||||
|
const file = recentFiles[index];
|
||||||
|
setSelectedFileIds(prev => prev.filter(id => id !== (file.id || file.name)));
|
||||||
|
}, [handleRemoveFile, recentFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFilesModalOpen) {
|
||||||
|
refreshRecentFiles();
|
||||||
|
} else {
|
||||||
|
// Reset state when modal is closed
|
||||||
|
setActiveSource('recent');
|
||||||
|
setSearchTerm('');
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, [isFilesModalOpen, refreshRecentFiles]);
|
||||||
|
|
||||||
|
// Modal size constants for consistent scaling
|
||||||
|
const modalHeight = '80vh';
|
||||||
|
const modalWidth = isMobile ? '100%' : '60vw';
|
||||||
|
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
||||||
|
const modalMaxHeight = '1200px';
|
||||||
|
const modalMinWidth = isMobile ? '320px' : '1030px';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isFilesModalOpen}
|
||||||
|
onClose={closeFilesModal}
|
||||||
|
size={isMobile ? "100%" : "auto"}
|
||||||
|
centered
|
||||||
|
radius={30}
|
||||||
|
className="overflow-hidden p-0"
|
||||||
|
withCloseButton={false}
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
position: 'relative',
|
||||||
|
margin: isMobile ? '1rem' : '2rem'
|
||||||
|
},
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: modalHeight,
|
||||||
|
width: modalWidth,
|
||||||
|
maxWidth: modalMaxWidth,
|
||||||
|
maxHeight: modalMaxHeight,
|
||||||
|
minWidth: modalMinWidth,
|
||||||
|
margin: '0 auto',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleNewFileUpload}
|
||||||
|
onDragEnter={() => setIsDragging(true)}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
accept={["*/*"]}
|
||||||
|
multiple={true}
|
||||||
|
activateOnClick={false}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '30px',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
inner: { pointerEvents: 'all' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileLayout
|
||||||
|
activeSource={activeSource}
|
||||||
|
onSourceChange={setActiveSource}
|
||||||
|
onLocalFileClick={openFileDialog}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onOpenFiles={handleOpenFiles}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
|
selectedFileIds={selectedFileIds}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
|
onFileDoubleClick={handleFileDoubleClick}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
onFileInputChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DesktopLayout
|
||||||
|
activeSource={activeSource}
|
||||||
|
onSourceChange={setActiveSource}
|
||||||
|
onLocalFileClick={openFileDialog}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onOpenFiles={handleOpenFiles}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
|
selectedFileIds={selectedFileIds}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
|
onFileDoubleClick={handleFileDoubleClick}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
onFileInputChange={handleFileInputChange}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
<DragOverlay isVisible={isDragging} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileManager;
|
@ -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;
|
|
117
frontend/src/components/shared/fileManager/DesktopLayout.tsx
Normal file
117
frontend/src/components/shared/fileManager/DesktopLayout.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid, Center, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { FileSource } from './types';
|
||||||
|
|
||||||
|
interface DesktopLayoutProps {
|
||||||
|
activeSource: FileSource;
|
||||||
|
onSourceChange: (source: FileSource) => void;
|
||||||
|
onLocalFileClick: () => void;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
filteredFiles: FileWithUrl[];
|
||||||
|
selectedFileIds: string[];
|
||||||
|
onFileSelect: (file: FileWithUrl) => void;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
modalHeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopLayout: React.FC<DesktopLayoutProps> = ({
|
||||||
|
activeSource,
|
||||||
|
onSourceChange,
|
||||||
|
onLocalFileClick,
|
||||||
|
selectedFiles,
|
||||||
|
onOpenFiles,
|
||||||
|
searchTerm,
|
||||||
|
onSearchChange,
|
||||||
|
recentFiles,
|
||||||
|
filteredFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onFileDoubleClick,
|
||||||
|
isFileSupported,
|
||||||
|
fileInputRef,
|
||||||
|
onFileInputChange,
|
||||||
|
modalHeight,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gutter="md" h="100%" grow={false} style={{ flexWrap: 'nowrap' }}>
|
||||||
|
{/* Column 1: File Sources */}
|
||||||
|
<Grid.Col span="content" style={{
|
||||||
|
minWidth: '15.625rem',
|
||||||
|
width: '15.625rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
<FileSourceButtons
|
||||||
|
activeSource={activeSource}
|
||||||
|
onSourceChange={onSourceChange}
|
||||||
|
onLocalFileClick={onLocalFileClick}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 2: File List */}
|
||||||
|
<Grid.Col span="auto" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<SearchInput
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
style={{ marginBottom: '1rem', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
activeSource={activeSource}
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
|
selectedFileIds={selectedFileIds}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
onFileRemove={onFileRemove}
|
||||||
|
onFileDoubleClick={onFileDoubleClick}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
scrollAreaHeight={`calc(${modalHeight} - 6rem)`}
|
||||||
|
scrollAreaStyle={{
|
||||||
|
height: activeSource === 'recent' && recentFiles.length > 0 ? `calc(${modalHeight} - 6rem)` : '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 3: File Details */}
|
||||||
|
<Grid.Col span="content" style={{ minWidth: '20rem', width: '20rem', flexShrink: 0, height: '100%' }}>
|
||||||
|
<div style={{ height: '100%' }}>
|
||||||
|
<FileDetails
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
onFileInputChange={onFileInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopLayout;
|
44
frontend/src/components/shared/fileManager/DragOverlay.tsx
Normal file
44
frontend/src/components/shared/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: `2px dashed ${theme.colors.blue[6]}`,
|
||||||
|
borderRadius: 30,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<UploadFileIcon style={{ fontSize: 64, color: theme.colors.blue[6] }} />
|
||||||
|
<Text size="xl" fw={500} c="blue.6">
|
||||||
|
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DragOverlay;
|
392
frontend/src/components/shared/fileManager/FileDetails.tsx
Normal file
392
frontend/src/components/shared/fileManager/FileDetails.tsx
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Stack, Card, Box, Center, Text, Badge, Button, Image, Group, Divider, ActionIcon, ScrollArea } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { detectFileExtension, getFileSize } from '../../../utils/fileUtils';
|
||||||
|
import { useIndexedDBThumbnail } from '../../../hooks/useIndexedDBThumbnail';
|
||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
import { FileDetailsProps } from './types';
|
||||||
|
|
||||||
|
const FileDetails: React.FC<FileDetailsProps & { compact?: boolean; modalHeight?: string }> = ({ selectedFiles, onOpenFiles, compact = false, modalHeight = '80vh' }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||||
|
const [thumbnailCache, setThumbnailCache] = useState<Record<string, string>>({});
|
||||||
|
const [loadingFile, setLoadingFile] = useState<FileWithUrl | null>(null);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
// Get the currently displayed file
|
||||||
|
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||||
|
const hasSelection = selectedFiles.length > 0;
|
||||||
|
const hasMultipleFiles = selectedFiles.length > 1;
|
||||||
|
|
||||||
|
// Only load thumbnail for files not in cache
|
||||||
|
const shouldLoadThumbnail = loadingFile && !thumbnailCache[loadingFile.id || loadingFile.name];
|
||||||
|
const { thumbnail } = useIndexedDBThumbnail(shouldLoadThumbnail ? loadingFile : ({} as FileWithUrl));
|
||||||
|
|
||||||
|
// Load thumbnails for all selected files
|
||||||
|
useEffect(() => {
|
||||||
|
// Start loading thumbnails for uncached files
|
||||||
|
const uncachedFiles = selectedFiles.filter(file => !thumbnailCache[file.id || file.name]);
|
||||||
|
if (uncachedFiles.length > 0 && !loadingFile) {
|
||||||
|
setLoadingFile(uncachedFiles[0]);
|
||||||
|
}
|
||||||
|
}, [selectedFiles, thumbnailCache, loadingFile]);
|
||||||
|
|
||||||
|
// Cache thumbnail when it loads and move to next uncached file
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingFile && thumbnail) {
|
||||||
|
const fileId = loadingFile.id || loadingFile.name;
|
||||||
|
setThumbnailCache(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fileId]: thumbnail
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Find next uncached file to load
|
||||||
|
const uncachedFiles = selectedFiles.filter(file =>
|
||||||
|
!thumbnailCache[file.id || file.name] &&
|
||||||
|
(file.id || file.name) !== fileId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uncachedFiles.length > 0) {
|
||||||
|
setLoadingFile(uncachedFiles[0]);
|
||||||
|
} else {
|
||||||
|
setLoadingFile(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadingFile, thumbnail, selectedFiles, thumbnailCache]);
|
||||||
|
|
||||||
|
// Clear cache when selection changes completely
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedFileIds = selectedFiles.map(f => f.id || f.name);
|
||||||
|
setThumbnailCache(prev => {
|
||||||
|
const newCache: Record<string, string> = {};
|
||||||
|
selectedFileIds.forEach(id => {
|
||||||
|
if (prev[id]) {
|
||||||
|
newCache[id] = prev[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newCache;
|
||||||
|
});
|
||||||
|
setLoadingFile(null);
|
||||||
|
}, [selectedFiles]);
|
||||||
|
|
||||||
|
// Get thumbnail from cache only
|
||||||
|
const getCurrentThumbnail = () => {
|
||||||
|
if (!currentFile) return null;
|
||||||
|
const fileId = currentFile.id || currentFile.name;
|
||||||
|
return thumbnailCache[fileId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
setIsAnimating(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
setIsAnimating(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset index when selection changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentFileIndex >= selectedFiles.length) {
|
||||||
|
setCurrentFileIndex(0);
|
||||||
|
}
|
||||||
|
}, [selectedFiles.length, currentFileIndex]);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
{/* Compact mobile layout */}
|
||||||
|
<Box style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{/* Small preview */}
|
||||||
|
<Box style={{ width: '60px', height: '80px', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{currentFile && getCurrentThumbnail() ? (
|
||||||
|
<img
|
||||||
|
src={getCurrentThumbnail()}
|
||||||
|
alt={currentFile.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentFile ? (
|
||||||
|
<Center style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: 4
|
||||||
|
}}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{currentFile ? currentFile.name : 'No file selected'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentFile ? getFileSize(currentFile) : ''}
|
||||||
|
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||||
|
</Text>
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<Text size="xs" c="blue">
|
||||||
|
{currentFileIndex + 1} of {selectedFiles.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation arrows for multiple files */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<Box style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
|
{/* Section 1: Thumbnail Preview */}
|
||||||
|
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* Left Navigation Arrow */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Stack Container */}
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* Background documents (stack effect) */}
|
||||||
|
{hasMultipleFiles && selectedFiles.length > 1 && (
|
||||||
|
<>
|
||||||
|
{/* Third document (furthest back) */}
|
||||||
|
{selectedFiles.length > 2 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
transform: 'translate(12px, 12px) rotate(2deg)',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Second document */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 3px 10px rgba(0, 0, 0, 0.12)',
|
||||||
|
transform: 'translate(6px, 6px) rotate(1deg)',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main document */}
|
||||||
|
{currentFile && getCurrentThumbnail() ? (
|
||||||
|
<Image
|
||||||
|
src={getCurrentThumbnail()}
|
||||||
|
alt={currentFile.name}
|
||||||
|
fit="contain"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
|
||||||
|
opacity: isAnimating ? 0.7 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentFile ? (
|
||||||
|
<Center style={{
|
||||||
|
width: '80%',
|
||||||
|
height: '80%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isAnimating ? 'scale(0.95) translateX(20px)' : 'scale(1) translateX(0)',
|
||||||
|
opacity: isAnimating ? 0.7 : 1
|
||||||
|
}}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Navigation Arrow */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '0',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 2: File Details */}
|
||||||
|
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||||
|
<Text size="sm" fw={500} ta="center" c="white">
|
||||||
|
{t('fileManager.details', 'File Details')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<ScrollArea style={{ flex: 1 }} p="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||||
|
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||||
|
{currentFile ? currentFile.name : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
|
||||||
|
{currentFile ? (
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{detectFileExtension(currentFile.name).toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" fw={500}></Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{currentFile ? getFileSize(currentFile) : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{currentFile ? '1.0' : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{selectedFiles.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.totalSelected', 'Selected')}</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{selectedFiles.length} files
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 3: Action Button */}
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDetails;
|
87
frontend/src/components/shared/fileManager/FileListArea.tsx
Normal file
87
frontend/src/components/shared/fileManager/FileListArea.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
import FileListItem from './FileListItem';
|
||||||
|
import { FileSource } from './types';
|
||||||
|
|
||||||
|
interface FileListAreaProps {
|
||||||
|
activeSource: FileSource;
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
filteredFiles: FileWithUrl[];
|
||||||
|
selectedFileIds: string[];
|
||||||
|
onFileSelect: (file: FileWithUrl) => void;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
scrollAreaHeight: string;
|
||||||
|
scrollAreaStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||||
|
activeSource,
|
||||||
|
recentFiles,
|
||||||
|
filteredFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onFileDoubleClick,
|
||||||
|
isFileSupported,
|
||||||
|
scrollAreaHeight,
|
||||||
|
scrollAreaStyle = {},
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (activeSource === 'recent') {
|
||||||
|
if (recentFiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center style={{ height: '200px' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<HistoryIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
|
||||||
|
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||||
|
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
h={scrollAreaHeight}
|
||||||
|
style={{ ...scrollAreaStyle }}
|
||||||
|
type="always"
|
||||||
|
scrollbarSize={8}
|
||||||
|
>
|
||||||
|
<Stack gap="xs" p="xs">
|
||||||
|
{filteredFiles.map((file, index) => (
|
||||||
|
<FileListItem
|
||||||
|
key={file.id || file.name}
|
||||||
|
file={file}
|
||||||
|
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||||
|
isSupported={isFileSupported(file.name)}
|
||||||
|
onSelect={() => onFileSelect(file)}
|
||||||
|
onRemove={() => onFileRemove(index)}
|
||||||
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Drive placeholder
|
||||||
|
return (
|
||||||
|
<Center style={{ height: '200px' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<CloudIcon style={{ fontSize: 48, color: 'var(--mantine-color-gray-5)' }} />
|
||||||
|
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileListArea;
|
65
frontend/src/components/shared/fileManager/FileListItem.tsx
Normal file
65
frontend/src/components/shared/fileManager/FileListItem.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, Group, Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { getFileSize, getFileDate } from '../../../utils/fileUtils';
|
||||||
|
import { FileListItemProps } from './types';
|
||||||
|
|
||||||
|
const FileListItem: React.FC<FileListItemProps> = ({
|
||||||
|
file,
|
||||||
|
isSelected,
|
||||||
|
isSupported,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onDoubleClick
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
p="xs"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : undefined),
|
||||||
|
border: isSelected ? '1px solid var(--mantine-color-blue-3)' : undefined,
|
||||||
|
opacity: isSupported ? 1 : 0.5,
|
||||||
|
boxShadow: isHovered && !isSelected ? '0 2px 8px rgba(0, 0, 0, 0.1)' : undefined,
|
||||||
|
transition: 'background-color 0.15s ease, box-shadow 0.15s ease'
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Box style={{ width: 40, height: 40, flexShrink: 0 }}>
|
||||||
|
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: 4 }}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>{file.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||||
|
</Box>
|
||||||
|
{/* Delete button - fades in/out on hover */}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
c="dimmed"
|
||||||
|
size="md"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
|
style={{
|
||||||
|
opacity: isHovered ? 1 : 0,
|
||||||
|
transform: isHovered ? 'scale(1)' : 'scale(0.8)',
|
||||||
|
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||||
|
pointerEvents: isHovered ? 'auto' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileListItem;
|
101
frontend/src/components/shared/fileManager/FileSourceButtons.tsx
Normal file
101
frontend/src/components/shared/fileManager/FileSourceButtons.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileSourceButtonsProps } from './types';
|
||||||
|
|
||||||
|
const FileSourceButtons: React.FC<FileSourceButtonsProps & { horizontal?: boolean }> = ({
|
||||||
|
activeSource,
|
||||||
|
onSourceChange,
|
||||||
|
onLocalFileClick,
|
||||||
|
horizontal = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||||
|
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-4)' : undefined,
|
||||||
|
getStyles: (source: string) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={buttonProps.variant('recent')}
|
||||||
|
leftSection={<HistoryIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={() => onSourceChange('recent')}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
color={buttonProps.getColor('recent')}
|
||||||
|
styles={buttonProps.getStyles('recent')}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color='var(--mantine-color-gray-5)'
|
||||||
|
leftSection={<FolderIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={onLocalFileClick}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={buttonProps.variant('drive')}
|
||||||
|
leftSection={<CloudIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={() => onSourceChange('drive')}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
disabled
|
||||||
|
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||||
|
styles={buttonProps.getStyles('drive')}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.googleDrive', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
return (
|
||||||
|
<Group gap="md" justify="center" style={{ width: '100%' }}>
|
||||||
|
{buttons}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
<Text size="sm" fw={500} c="dimmed" mb="xs">
|
||||||
|
{t('fileManager.myFiles', 'My Files')}
|
||||||
|
</Text>
|
||||||
|
{buttons}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileSourceButtons;
|
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface HiddenFileInputProps {
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HiddenFileInput: React.FC<HiddenFileInputProps> = ({ fileInputRef, onFileInputChange }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple={true}
|
||||||
|
accept={["*/*"].join(',')}
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
data-testid="file-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HiddenFileInput;
|
109
frontend/src/components/shared/fileManager/MobileLayout.tsx
Normal file
109
frontend/src/components/shared/fileManager/MobileLayout.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Box } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { FileSource } from './types';
|
||||||
|
|
||||||
|
interface MobileLayoutProps {
|
||||||
|
activeSource: FileSource;
|
||||||
|
onSourceChange: (source: FileSource) => void;
|
||||||
|
onLocalFileClick: () => void;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
filteredFiles: FileWithUrl[];
|
||||||
|
selectedFileIds: string[];
|
||||||
|
onFileSelect: (file: FileWithUrl) => void;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
modalHeight: string;
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileLayout: React.FC<MobileLayoutProps> = ({
|
||||||
|
activeSource,
|
||||||
|
onSourceChange,
|
||||||
|
onLocalFileClick,
|
||||||
|
selectedFiles,
|
||||||
|
onOpenFiles,
|
||||||
|
searchTerm,
|
||||||
|
onSearchChange,
|
||||||
|
recentFiles,
|
||||||
|
filteredFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onFileDoubleClick,
|
||||||
|
isFileSupported,
|
||||||
|
modalHeight,
|
||||||
|
fileInputRef,
|
||||||
|
onFileInputChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%" gap="sm" p="sm">
|
||||||
|
{/* Section 1: File Sources - Fixed at top */}
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileSourceButtons
|
||||||
|
activeSource={activeSource}
|
||||||
|
onSourceChange={onSourceChange}
|
||||||
|
onLocalFileClick={onLocalFileClick}
|
||||||
|
horizontal={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileDetails
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
compact={true}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 3: Search Bar - Fixed above file list */}
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<SearchInput
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 4: File List - Fixed height scrollable area */}
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
activeSource={activeSource}
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
|
selectedFileIds={selectedFileIds}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
onFileRemove={onFileRemove}
|
||||||
|
onFileDoubleClick={onFileDoubleClick}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
scrollAreaHeight={`calc(${modalHeight} - ${selectedFiles.length > 0 ? '300px' : '200px'})`}
|
||||||
|
scrollAreaStyle={{ maxHeight: '400px', minHeight: '150px' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
onFileInputChange={onFileInputChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileLayout;
|
26
frontend/src/components/shared/fileManager/SearchInput.tsx
Normal file
26
frontend/src/components/shared/fileManager/SearchInput.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput: React.FC<SearchInputProps> = ({ value, onChange, style }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
||||||
|
leftSection={<SearchIcon />}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchInput;
|
23
frontend/src/components/shared/fileManager/types.ts
Normal file
23
frontend/src/components/shared/fileManager/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
|
||||||
|
export type FileSource = 'recent' | 'local' | 'drive';
|
||||||
|
|
||||||
|
export interface FileListItemProps {
|
||||||
|
file: FileWithUrl;
|
||||||
|
isSelected: boolean;
|
||||||
|
isSupported: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDetailsProps {
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSourceButtonsProps {
|
||||||
|
activeSource: FileSource;
|
||||||
|
onSourceChange: (source: FileSource) => void;
|
||||||
|
onLocalFileClick: () => void;
|
||||||
|
}
|
@ -1,6 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getDocument } from "pdfjs-dist";
|
import { getDocument } from "pdfjs-dist";
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
|
import { fileStorage } from "../services/fileStorage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal scale for thumbnail generation
|
||||||
|
* Ensures high quality while preventing oversized renders
|
||||||
|
*/
|
||||||
|
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||||
|
const maxWidth = 400; // Max thumbnail width
|
||||||
|
const maxHeight = 600; // Max thumbnail height
|
||||||
|
|
||||||
|
const scaleX = maxWidth / pageViewport.width;
|
||||||
|
const scaleY = maxHeight / pageViewport.height;
|
||||||
|
|
||||||
|
// Don't upscale, only downscale if needed
|
||||||
|
return Math.min(scaleX, scaleY, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for IndexedDB-aware thumbnail loading
|
* Hook for IndexedDB-aware thumbnail loading
|
||||||
@ -28,21 +44,41 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: for IndexedDB files without stored thumbnails, just use placeholder
|
// Second priority: generate from blob for files (both IndexedDB and regular files, small files only)
|
||||||
if (file.storedInIndexedDB && file.id) {
|
if (file.size < 50 * 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 arrayBuffer: ArrayBuffer;
|
||||||
|
|
||||||
|
// Handle IndexedDB files vs regular File objects
|
||||||
|
if (file.storedInIndexedDB && file.id) {
|
||||||
|
// For IndexedDB files, get the data from storage
|
||||||
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
|
if (!storedFile) {
|
||||||
|
throw new Error('File not found in IndexedDB');
|
||||||
|
}
|
||||||
|
arrayBuffer = storedFile.data;
|
||||||
|
} else if (typeof file.arrayBuffer === 'function') {
|
||||||
|
// For regular File objects, use arrayBuffer method
|
||||||
|
arrayBuffer = await file.arrayBuffer();
|
||||||
|
} else if (file.id) {
|
||||||
|
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
||||||
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
|
if (!storedFile) {
|
||||||
|
throw new Error('File has no arrayBuffer method and not found in IndexedDB');
|
||||||
|
}
|
||||||
|
arrayBuffer = storedFile.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('File object has no arrayBuffer method and no ID for IndexedDB lookup');
|
||||||
|
}
|
||||||
|
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||||
const page = await pdf.getPage(1);
|
const page = await pdf.getPage(1);
|
||||||
const viewport = page.getViewport({ scale: 0.2 });
|
|
||||||
|
// Calculate optimal scale and create viewport
|
||||||
|
const baseViewport = page.getViewport({ scale: 1.0 });
|
||||||
|
const scale = calculateThumbnailScale(baseViewport);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
@ -53,7 +89,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
}
|
}
|
||||||
pdf.destroy(); // Clean up memory
|
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);
|
||||||
|
@ -18,7 +18,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/shared/FileManager";
|
||||||
|
|
||||||
function HomePageContent() {
|
function HomePageContent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -270,7 +270,7 @@ function HomePageContent() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Global Modals */}
|
{/* Global Modals */}
|
||||||
<FileUploadModal selectedTool={selectedTool} />
|
<FileManager selectedTool={selectedTool} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user