From fc9c4fdb7ff09ebb34a8505a1fa6b9b261919db0 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 20 Aug 2025 13:50:15 +0100 Subject: [PATCH] Download utils, shift click selections --- .../public/locales/en-GB/translation.json | 9 +- .../components/fileManager/FileListArea.tsx | 14 +- .../components/fileManager/FileListItem.tsx | 44 ++--- frontend/src/contexts/FileManagerContext.tsx | 156 +++++++----------- frontend/src/utils/downloadUtils.ts | 152 +++++++++++++++++ 5 files changed, 254 insertions(+), 121 deletions(-) create mode 100644 frontend/src/utils/downloadUtils.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 333f6ca8b..0f2d5e562 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1989,7 +1989,14 @@ "fileSize": "Size", "fileVersion": "Version", "totalSelected": "Total Selected", - "dropFilesHere": "Drop files here" + "dropFilesHere": "Drop files here", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "deleteSelected": "Delete Selected", + "downloadSelected": "Download Selected", + "selectedCount": "{{count}} selected", + "download": "Download", + "delete": "Delete" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx index 025c0cb60..fd9357a94 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -19,7 +19,7 @@ const FileListArea: React.FC = ({ activeSource, recentFiles, filteredFiles, - selectedFileIds, + selectedFilesSet, onFileSelect, onFileRemove, onFileDoubleClick, @@ -30,12 +30,12 @@ const FileListArea: React.FC = ({ if (activeSource === 'recent') { return ( - @@ -54,9 +54,9 @@ const FileListArea: React.FC = ({ onFileSelect(file)} + onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} onDownload={() => onDownloadSingle(file)} onDoubleClick={() => onFileDoubleClick(file)} @@ -79,4 +79,4 @@ const FileListArea: React.FC = ({ ); }; -export default FileListArea; \ No newline at end of file +export default FileListArea; diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 72dac10d0..cb050d686 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -11,18 +11,18 @@ interface FileListItemProps { file: FileWithUrl; isSelected: boolean; isSupported: boolean; - onSelect: () => void; + onSelect: (shiftKey?: boolean) => void; onRemove: () => void; onDownload?: () => void; onDoubleClick?: () => void; isLast?: boolean; } -const FileListItem: React.FC = ({ - file, - isSelected, - isSupported, - onSelect, +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, onRemove, onDownload, onDoubleClick @@ -30,21 +30,25 @@ const FileListItem: React.FC = ({ const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); - + // Keep item in hovered state if menu is open const shouldShowHovered = isHovered || isMenuOpen; return ( <> - onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -64,22 +68,22 @@ const FileListItem: React.FC = ({ }} /> - + {file.name} {getFileSize(file)} • {getFileDate(file)} - + {/* Three dots menu - fades in/out on hover */} - setIsMenuOpen(true)} onClose={() => setIsMenuOpen(false)} > - e.stopPropagation()} @@ -93,7 +97,7 @@ const FileListItem: React.FC = ({ - + {onDownload && ( = ({ ); }; -export default FileListItem; \ No newline at end of file +export default FileListItem; diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 9128d3fd5..4115e873d 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; import { FileWithUrl } from '../types/file'; import { StoredFile, fileStorage } from '../services/fileStorage'; +import { downloadFiles } from '../utils/downloadUtils'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -11,11 +12,12 @@ interface FileManagerContextValue { selectedFiles: FileWithUrl[]; filteredFiles: FileWithUrl[]; fileInputRef: React.RefObject; + selectedFilesSet: Set; // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onLocalFileClick: () => void; - onFileSelect: (file: FileWithUrl) => void; + onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void; onFileRemove: (index: number) => void; onFileDoubleClick: (file: FileWithUrl) => void; onOpenFiles: () => void; @@ -64,22 +66,29 @@ export const FileManagerProvider: React.FC = ({ const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); const [selectedFileIds, setSelectedFileIds] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const [lastClickedIndex, setLastClickedIndex] = useState(null); const fileInputRef = useRef(null); // Track blob URLs for cleanup const createdBlobUrls = useRef>(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 selectedFilesSet = new Set(selectedFileIds); + + const selectedFiles = selectedFileIds.length === 0 ? [] : + (recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name)); + + const filteredFiles = !searchTerm ? recentFiles || [] : + (recentFiles || []).filter(file => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => { setActiveSource(source); if (source !== 'recent') { setSelectedFileIds([]); setSearchTerm(''); + setLastClickedIndex(null); } }, []); @@ -87,19 +96,46 @@ export const FileManagerProvider: React.FC = ({ 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]; + const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => { + const fileId = file.id || file.name; + if (!fileId) return; + + if (shiftKey && lastClickedIndex !== null) { + // Range selection with shift-click + const startIndex = Math.min(lastClickedIndex, currentIndex); + const endIndex = Math.max(lastClickedIndex, currentIndex); + + setSelectedFileIds(prev => { + const selectedSet = new Set(prev); + + // Add all files in the range to selection + for (let i = startIndex; i <= endIndex; i++) { + const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name; + if (rangeFileId) { + selectedSet.add(rangeFileId); + } } - } else { - return prev; - } - }); - }, []); + + return Array.from(selectedSet); + }); + } else { + // Normal click behavior - optimized with Set for O(1) lookup + setSelectedFileIds(prev => { + const selectedSet = new Set(prev); + + if (selectedSet.has(fileId)) { + selectedSet.delete(fileId); + } else { + selectedSet.add(fileId); + } + + return Array.from(selectedSet); + }); + + // Update last clicked index for future range selections + setLastClickedIndex(currentIndex); + } + }, [filteredFiles, lastClickedIndex]); const handleFileRemove = useCallback((index: number) => { const fileToRemove = filteredFiles[index]; @@ -161,9 +197,11 @@ export const FileManagerProvider: React.FC = ({ if (allFilesSelected) { // Deselect all setSelectedFileIds([]); + setLastClickedIndex(null); } else { // Select all filtered files setSelectedFileIds(filteredFiles.map(file => file.id || file.name)); + setLastClickedIndex(null); } }, [filteredFiles, selectedFileIds]); @@ -192,6 +230,7 @@ export const FileManagerProvider: React.FC = ({ } }, [selectedFileIds, filteredFiles, refreshRecentFiles]); + const handleDownloadSelected = useCallback(async () => { if (selectedFileIds.length === 0) return; @@ -201,65 +240,10 @@ export const FileManagerProvider: React.FC = ({ 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); - } - } + // Use generic download utility + await downloadFiles(selectedFilesToDownload, { + zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip` + }); } catch (error) { console.error('Failed to download selected files:', error); } @@ -267,23 +251,7 @@ export const FileManagerProvider: React.FC = ({ 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); - } + await downloadFiles([file]); } catch (error) { console.error('Failed to download file:', error); } @@ -307,6 +275,7 @@ export const FileManagerProvider: React.FC = ({ setActiveSource('recent'); setSelectedFileIds([]); setSearchTerm(''); + setLastClickedIndex(null); } }, [isOpen]); @@ -318,6 +287,7 @@ export const FileManagerProvider: React.FC = ({ selectedFiles, filteredFiles, fileInputRef, + selectedFilesSet, // Handlers onSourceChange: handleSourceChange, diff --git a/frontend/src/utils/downloadUtils.ts b/frontend/src/utils/downloadUtils.ts new file mode 100644 index 000000000..1c411c87d --- /dev/null +++ b/frontend/src/utils/downloadUtils.ts @@ -0,0 +1,152 @@ +import { FileWithUrl } from '../types/file'; +import { fileStorage } from '../services/fileStorage'; +import { zipFileService } from '../services/zipFileService'; + +/** + * Downloads a blob as a file using browser download API + * @param blob - The blob to download + * @param filename - The filename for the download + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the blob URL + URL.revokeObjectURL(url); +} + +/** + * Downloads a single file from IndexedDB storage + * @param file - The file object with storage information + * @throws Error if file cannot be retrieved from storage + */ +export async function downloadFileFromStorage(file: FileWithUrl): Promise { + const lookupKey = file.id || file.name; + const storedFile = await fileStorage.getFile(lookupKey); + + if (!storedFile) { + throw new Error(`File "${file.name}" not found in storage`); + } + + const blob = new Blob([storedFile.data], { type: storedFile.type }); + downloadBlob(blob, storedFile.name); +} + +/** + * Downloads multiple files as individual downloads + * @param files - Array of files to download + */ +export async function downloadMultipleFiles(files: FileWithUrl[]): Promise { + for (const file of files) { + await downloadFileFromStorage(file); + } +} + +/** + * Downloads multiple files as a single ZIP archive + * @param files - Array of files to include in ZIP + * @param zipFilename - Optional custom ZIP filename (defaults to timestamped name) + */ +export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: string): Promise { + if (files.length === 0) { + throw new Error('No files provided for ZIP download'); + } + + // Convert stored files to File objects + const fileObjects: File[] = []; + for (const fileWithUrl of files) { + 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 + }); + fileObjects.push(file); + } + } + + if (fileObjects.length === 0) { + throw new Error('No valid files found in storage for ZIP download'); + } + + // Generate default filename if not provided + const finalZipFilename = zipFilename || + `files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`; + + // Create and download ZIP + const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename); + downloadBlob(zipFile, finalZipFilename); +} + +/** + * Smart download function that handles single or multiple files appropriately + * - Single file: Downloads directly + * - Multiple files: Downloads as ZIP + * @param files - Array of files to download + * @param options - Download options + */ +export async function downloadFiles( + files: FileWithUrl[], + options: { + forceZip?: boolean; + zipFilename?: string; + multipleAsIndividual?: boolean; + } = {} +): Promise { + if (files.length === 0) { + throw new Error('No files provided for download'); + } + + if (files.length === 1 && !options.forceZip) { + // Single file download + await downloadFileFromStorage(files[0]); + } else if (options.multipleAsIndividual) { + // Multiple individual downloads + await downloadMultipleFiles(files); + } else { + // ZIP download (default for multiple files) + await downloadFilesAsZip(files, options.zipFilename); + } +} + +/** + * Downloads a File object directly (for files already in memory) + * @param file - The File object to download + * @param filename - Optional custom filename + */ +export function downloadFileObject(file: File, filename?: string): void { + downloadBlob(file, filename || file.name); +} + +/** + * Downloads text content as a file + * @param content - Text content to download + * @param filename - Filename for the download + * @param mimeType - MIME type (defaults to text/plain) + */ +export function downloadTextAsFile( + content: string, + filename: string, + mimeType: string = 'text/plain' +): void { + const blob = new Blob([content], { type: mimeType }); + downloadBlob(blob, filename); +} + +/** + * Downloads JSON data as a file + * @param data - Data to serialize and download + * @param filename - Filename for the download + */ +export function downloadJsonAsFile(data: any, filename: string): void { + const content = JSON.stringify(data, null, 2); + downloadTextAsFile(content, filename, 'application/json'); +} \ No newline at end of file