mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/filemanager (#4121)
FileManager Component Overview
Purpose: Modal component for selecting and managing PDF files with
preview capabilities
Architecture:
- Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx
(3-column)
- Central State: FileManagerContext handles file operations, selection,
and modal state
- File Storage: IndexedDB persistence with thumbnail caching
Key Components:
- FileSourceButtons: Switch between Recent/Local/Drive sources
- FileListArea: Scrollable file grid with search functionality
- FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow
pages based on file count)
- FileDetails: File info card with metadata
- CompactFileDetails: Mobile-optimized file info layout
File Flow:
1. Users select source → browse/search files → select multiple files →
preview with navigation → open in
tools
2. Files persist across tool switches via FileContext integration
3. Memory management handles large PDFs (up to 100GB+)
```mermaid
graph TD
FM[FileManager] --> ML[MobileLayout]
FM --> DL[DesktopLayout]
ML --> FSB[FileSourceButtons<br/>Recent/Local/Drive]
ML --> FLA[FileListArea]
ML --> FD[FileDetails]
DL --> FSB
DL --> FLA
DL --> FD
FLA --> FLI[FileListItem]
FD --> FP[FilePreview]
FD --> CFD[CompactFileDetails]
```
---------
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
case 'REMOVE_FILES':
|
||||
const remainingFiles = state.activeFiles.filter(file => {
|
||||
const fileId = getFileId(file);
|
||||
return !action.payload.includes(fileId);
|
||||
return !fileId || !action.payload.includes(fileId);
|
||||
});
|
||||
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
||||
return {
|
||||
@@ -491,26 +491,38 @@ export function FileContextProvider({
|
||||
}, [cleanupFile]);
|
||||
|
||||
// Action implementations
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
||||
dispatch({ type: 'ADD_FILES', payload: files });
|
||||
|
||||
// Auto-save to IndexedDB if persistence enabled
|
||||
if (enablePersistence) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Check if file already has an ID (already in IndexedDB)
|
||||
// Check if file already has an explicit ID property (already in IndexedDB)
|
||||
const fileId = getFileId(file);
|
||||
if (!fileId) {
|
||||
// File doesn't have ID, store it and get the ID
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
// File doesn't have explicit ID, store it with thumbnail
|
||||
try {
|
||||
// Generate thumbnail for better recent files experience
|
||||
const thumbnail = await thumbnailGenerationService.generateThumbnail(file);
|
||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
} catch (thumbnailError) {
|
||||
// If thumbnail generation fails, store without thumbnail
|
||||
console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return files with their IDs assigned
|
||||
return files;
|
||||
}, [enablePersistence]);
|
||||
|
||||
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
||||
@@ -682,7 +694,7 @@ export function FileContextProvider({
|
||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||
return state.activeFiles.find(file => {
|
||||
const actualFileId = getFileId(file);
|
||||
return actualFileId === fileId;
|
||||
return actualFileId && actualFileId === fileId;
|
||||
});
|
||||
}, [state.activeFiles]);
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { StoredFile } from '../services/fileStorage';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
// State
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: string[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileWithUrl[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileWithUrl[];
|
||||
onFilesSelected: (files: FileWithUrl[]) => void;
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
isOpen: boolean;
|
||||
onFileRemove: (index: number) => void;
|
||||
modalHeight: string;
|
||||
storeFile: (file: File) => Promise<StoredFile>;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
storeFile,
|
||||
refreshRecentFiles,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
|
||||
const filteredFiles = (recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||
setActiveSource(source);
|
||||
if (source !== 'recent') {
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocalFileClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||
setSelectedFileIds(prev => {
|
||||
if (prev.includes(file.id)) {
|
||||
return prev.filter(id => id !== file.id);
|
||||
} else {
|
||||
return [...prev, file.id];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileRemove = useCallback((index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
if (fileToRemove) {
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
|
||||
}
|
||||
onFileRemove(index);
|
||||
}, [filteredFiles, onFileRemove]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onClose();
|
||||
}
|
||||
}, [isFileSupported, onFilesSelected, onClose]);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
onFilesSelected(selectedFiles);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFiles, onFilesSelected, onClose]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, []);
|
||||
|
||||
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
||||
const fileWithUrls = files.map(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
createdBlobUrls.current.add(url);
|
||||
|
||||
return {
|
||||
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||
name: file.name,
|
||||
file,
|
||||
url,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesSelected(fileWithUrls);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}
|
||||
event.target.value = '';
|
||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up all created blob URLs
|
||||
createdBlobUrls.current.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
createdBlobUrls.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue: FileManagerContextValue = {
|
||||
// State
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
onLocalFileClick: handleLocalFileClick,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFileRemove: handleFileRemove,
|
||||
onFileDoubleClick: handleFileDoubleClick,
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
onFileInputChange: handleFileInputChange,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
};
|
||||
|
||||
return (
|
||||
<FileManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use the context
|
||||
export const useFileManagerContext = (): FileManagerContextValue => {
|
||||
const context = useContext(FileManagerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
||||
'Make sure you wrap your component with <FileManagerProvider>.'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
// Export the context for advanced use cases
|
||||
export { FileManagerContext };
|
||||
@@ -1,21 +1,58 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal';
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
|
||||
interface FilesModalContextType extends UseFilesModalReturn {}
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: () => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onModalClose: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
||||
|
||||
const filesModal = useFilesModal({
|
||||
onFileSelect: addToActiveFiles,
|
||||
onFilesSelect: addMultipleFiles,
|
||||
});
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
|
||||
const openFilesModal = useCallback(() => {
|
||||
setIsFilesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeFilesModal = useCallback(() => {
|
||||
setIsFilesModalOpen(false);
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
addToActiveFiles(file);
|
||||
closeFilesModal();
|
||||
}, [addToActiveFiles, closeFilesModal]);
|
||||
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
addMultipleFiles(files);
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
const contextValue: FilesModalContextType = {
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
};
|
||||
|
||||
return (
|
||||
<FilesModalContext.Provider value={filesModal}>
|
||||
<FilesModalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FilesModalContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user