Stirling-PDF/frontend/src/contexts/FileManagerContext.tsx

365 lines
11 KiB
TypeScript
Raw Normal View History

import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file';
2025-08-20 12:23:15 +01:00
import { StoredFile, fileStorage } 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 | null>;
// 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;
2025-08-20 12:23:15 +01:00
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: FileWithUrl) => 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 (file.id) {
if (prev.includes(file.id)) {
return prev.filter(id => id !== file.id);
} else {
return [...prev, file.id];
}
} else {
return prev;
}
});
}, []);
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 as any /* FIX ME */);
await refreshRecentFiles();
onClose();
} catch (error) {
console.error('Failed to process selected files:', error);
}
}
event.target.value = '';
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
2025-08-20 12:23:15 +01:00
const handleSelectAll = useCallback(() => {
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
if (allFilesSelected) {
// Deselect all
setSelectedFileIds([]);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
}
}, [filteredFiles, selectedFileIds]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id || file.name)
);
// Delete files from storage
for (const file of filesToDelete) {
const lookupKey = file.id || file.name;
await fileStorage.deleteFile(lookupKey);
}
// Clear selection
setSelectedFileIds([]);
// Refresh the file list
await refreshRecentFiles();
} catch (error) {
console.error('Failed to delete selected files:', error);
}
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get selected files
const selectedFilesToDownload = filteredFiles.filter(file =>
selectedFileIds.includes(file.id || file.name)
);
if (selectedFilesToDownload.length === 1) {
// Single file download
const fileWithUrl = selectedFilesToDownload[0];
const lookupKey = fileWithUrl.id || fileWithUrl.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const blob = new Blob([storedFile.data], { type: storedFile.type });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = storedFile.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
}
} else if (selectedFilesToDownload.length > 1) {
// Multiple files - create ZIP download
const { zipFileService } = await import('../services/zipFileService');
// Convert stored files to File objects
const files: File[] = [];
for (const fileWithUrl of selectedFilesToDownload) {
const lookupKey = fileWithUrl.id || fileWithUrl.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
files.push(file);
}
}
if (files.length > 0) {
// Create ZIP file
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
const zipFilename = `selected-files-${timestamp}.zip`;
const { zipFile } = await zipFileService.createZipFromFiles(files, zipFilename);
// Download the ZIP file
const url = URL.createObjectURL(zipFile);
const link = document.createElement('a');
link.href = url;
link.download = zipFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
}
}
} catch (error) {
console.error('Failed to download selected files:', error);
}
}, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
try {
const lookupKey = file.id || file.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const blob = new Blob([storedFile.data], { type: storedFile.type });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = storedFile.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
}
} catch (error) {
console.error('Failed to download file:', error);
}
}, []);
// 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,
2025-08-20 12:23:15 +01:00
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
// 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 };