Feature/v2/filemanager (#4121)

FileManager Component Overview

Purpose: Modal component for selecting and managing PDF files with
preview capabilities

  Architecture:
- Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx
(3-column)
- Central State: FileManagerContext handles file operations, selection,
and modal state
  - File Storage: IndexedDB persistence with thumbnail caching

  Key Components:
  - FileSourceButtons: Switch between Recent/Local/Drive sources
  - FileListArea: Scrollable file grid with search functionality
- FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow
pages based on file count)
  - FileDetails: File info card with metadata
  - CompactFileDetails: Mobile-optimized file info layout

  File Flow:
1. Users select source → browse/search files → select multiple files →
preview with navigation → open in
  tools
  2. Files persist across tool switches via FileContext integration
  3. Memory management handles large PDFs (up to 100GB+)

 ```mermaid
 graph TD
      FM[FileManager] --> ML[MobileLayout]
      FM --> DL[DesktopLayout]

      ML --> FSB[FileSourceButtons<br/>Recent/Local/Drive]
      ML --> FLA[FileListArea]
      ML --> FD[FileDetails]

      DL --> FSB
      DL --> FLA
      DL --> FD

      FLA --> FLI[FileListItem]
      FD --> FP[FilePreview]
      FD --> CFD[CompactFileDetails]

  ```

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-08-08 15:15:09 +01:00
committed by GitHub
parent 9861332040
commit 7e3321ee16
33 changed files with 1818 additions and 506 deletions

View File

@@ -0,0 +1,211 @@
import React, { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { fileStorage } from "../../services/fileStorage";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
isSupported?: boolean; // Whether the file format is supported by the current tool
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
data-testid="file-card"
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
position: 'relative'
}}
>
{/* Hover action buttons */}
{isHovered && (onView || onEdit) && (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 4,
padding: 2
}}
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<ActionIcon
size="sm"
variant="subtle"
color="blue"
onClick={(e) => {
e.stopPropagation();
onView();
}}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<ActionIcon
size="sm"
variant="subtle"
color="orange"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<EditIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="red" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
{!isSupported && (
<Badge color="orange" variant="filled" size="sm">
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;

View File

@@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "../fileManagement/FileCard";
import FileCard from "./FileCard";
import { FileWithUrl } from "../../types/file";
interface FileGridProps {

View File

@@ -1,36 +0,0 @@
import React from 'react';
import { Modal } from '@mantine/core';
import FileUploadSelector from './FileUploadSelector';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { Tool } from '../../types/tool';
interface FileUploadModalProps {
selectedTool?: Tool | null;
}
const FileUploadModal: React.FC<FileUploadModalProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
title="Upload Files"
size="xl"
centered
>
<FileUploadSelector
title="Upload Files"
subtitle="Choose files from storage or upload new files"
onFileSelect={onFileSelect}
onFilesSelect={onFilesSelect}
accept={["*/*"]}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
data-testid="file-upload-modal"
/>
</Modal>
);
};
export default FileUploadModal;

View File

@@ -1,255 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import { fileStorage } from '../../services/fileStorage';
import { FileWithUrl } from '../../types/file';
import { detectFileExtension } from '../../utils/fileUtils';
import FileGrid from './FileGrid';
import MultiSelectControls from './MultiSelectControls';
import { useFileManager } from '../../hooks/useFileManager';
interface FileUploadSelectorProps {
// Appearance
title?: string;
subtitle?: string;
showDropzone?: boolean;
// File handling
sharedFiles?: any[];
onFileSelect?: (file: File) => void;
onFilesSelect: (files: File[]) => void;
accept?: string[];
supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
// Loading state
loading?: boolean;
disabled?: boolean;
// Recent files
showRecentFiles?: boolean;
maxRecentFiles?: number;
}
const FileUploadSelector = ({
title,
subtitle,
showDropzone = true,
sharedFiles = [],
onFileSelect,
onFilesSelect,
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
supportedExtensions = ["pdf"], // Default to PDF only for most tools
loading = false,
disabled = false,
showRecentFiles = true,
maxRecentFiles = 8,
}: FileUploadSelectorProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
if (uploadedFiles.length === 0) return;
if (showRecentFiles) {
try {
for (const file of uploadedFiles) {
await storeFile(file);
}
refreshRecentFiles();
} catch (error) {
console.error('Failed to save files to recent:', error);
}
}
if (onFilesSelect) {
onFilesSelect(uploadedFiles);
} else if (onFileSelect) {
onFileSelect(uploadedFiles[0]);
}
}, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
console.log('File input change:', fileArray.length, 'files');
handleNewFileUpload(fileArray);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [handleNewFileUpload]);
const openFileDialog = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
try {
const fileObj = await convertToFile(file);
if (onFilesSelect) {
onFilesSelect([fileObj]);
} else if (onFileSelect) {
onFileSelect(fileObj);
}
} catch (error) {
console.error('Failed to load file from recent:', error);
}
}, [onFileSelect, onFilesSelect, convertToFile]);
const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
const handleSelectedRecentFiles = useCallback(async () => {
if (onFilesSelect) {
await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
}
}, [recentFiles, onFilesSelect, selectionHandlers]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
const file = recentFiles[index];
setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
}, [handleRemoveFile, recentFiles]);
useEffect(() => {
if (showRecentFiles) {
refreshRecentFiles();
}
}, [showRecentFiles, refreshRecentFiles]);
// Get default title and subtitle from translations if not provided
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs");
return (
<>
<Stack align="center" gap="sm">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
{displayTitle}
</Text>
<Text size="md" c="dimmed">
{displaySubtitle}
</Text>
</Stack>
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
{showDropzone ? (
<Dropzone
onDrop={handleNewFileUpload}
accept={accept}
multiple={true}
disabled={disabled || loading}
style={{ width: '100%', height: "5rem" }}
activateOnClick={true}
data-testid="file-dropzone"
>
<Center>
<Stack align="center" gap="sm">
<Text size="md" fw={500}>
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{accept.includes('application/pdf') && accept.includes('application/zip')
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
: accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types")
}
</Text>
</Stack>
</Center>
</Dropzone>
) : (
<Stack align="center" gap="sm">
<Button
variant="outline"
size="lg"
disabled={disabled}
loading={loading}
onClick={openFileDialog}
>
{t("fileUpload.uploadFiles", "Upload Files")}
</Button>
{/* Manual file input as backup */}
<input
ref={fileInputRef}
type="file"
multiple={true}
accept={accept.join(',')}
onChange={handleFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
</Stack>
)}
</Stack>
{/* Recent Files Section */}
{showRecentFiles && recentFiles.length > 0 && (
<Box w="100%" >
<Divider my="md" />
<Text size="lg" fw={500} mb="md">
{t("fileUpload.recentFiles", "Recent Files")}
</Text>
<MultiSelectControls
selectedCount={selectedFiles.length}
onClearSelection={selectionHandlers.clearSelection}
onAddToUpload={handleSelectedRecentFiles}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
<FileGrid
files={recentFiles}
onDoubleClick={handleRecentFileSelection}
onSelect={selectionHandlers.toggleSelection}
onRemove={handleRemoveFileByIndex}
selectedFiles={selectedFiles}
showSearch={true}
showSort={true}
isFileSupported={isFileSupported}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
</Box>
)}
</Stack>
</>
);
};
export default FileUploadSelector;