From cd2b82d614b57b6024a6c2ee1f8602ea7f2634b2 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:51:55 +0100 Subject: [PATCH] Feature/v2/filemanagerimprovements (#4243) - Select all/deselect all - Delete Selected - Download Selected - Recent file delete -> menu button with drop down for delete and download - Shift click selection added {330DF96D-7040-4CCB-B089-523F370E3185} {2D2F4876-7D35-45C3-B0CD-3127EEEEF7B5} --------- Co-authored-by: Connor Yoh --- .../public/locales/en-GB/translation.json | 9 +- .../components/fileManager/DesktopLayout.tsx | 63 ++++---- .../components/fileManager/FileActions.tsx | 115 +++++++++++++ .../components/fileManager/FileListArea.tsx | 16 +- .../components/fileManager/FileListItem.tsx | 107 ++++++++---- .../components/fileManager/MobileLayout.tsx | 26 ++- frontend/src/contexts/FileManagerContext.tsx | 148 ++++++++++++++--- frontend/src/theme/mantineTheme.ts | 13 ++ frontend/src/utils/downloadUtils.ts | 152 ++++++++++++++++++ 9 files changed, 558 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/fileManager/FileActions.tsx 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 64cd9408d..efcc3de0c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2031,7 +2031,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/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index be701ff20..8d1e32ffc 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -17,27 +18,27 @@ const DesktopLayout: React.FC = () => { return ( {/* Column 1: File Sources */} - - + {/* Column 2: File List */} - -
{ overflow: 'hidden' }}> {activeSource === 'recent' && ( -
- -
+ <> +
+ +
+
+ +
+ )} - +
0 ? modalHeight : '100%', backgroundColor: 'transparent', border: 'none', @@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
- + {/* Column 3: File Details */} - @@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => { - + {/* Hidden file input for local file selection */}
); }; -export default DesktopLayout; \ No newline at end of file +export default DesktopLayout; diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx new file mode 100644 index 000000000..7bc8d27bc --- /dev/null +++ b/frontend/src/components/fileManager/FileActions.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import SelectAllIcon from "@mui/icons-material/SelectAll"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "../../contexts/FileManagerContext"; + +const FileActions: React.FC = () => { + const { t } = useTranslation(); + const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = + useFileManagerContext(); + + const handleSelectAll = () => { + onSelectAll(); + }; + + const handleDeleteSelected = () => { + if (selectedFileIds.length > 0) { + onDeleteSelected(); + } + }; + + const handleDownloadSelected = () => { + if (selectedFileIds.length > 0) { + onDownloadSelected(); + } + }; + + // Only show actions if there are files + if (recentFiles.length === 0) { + return null; + } + + const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + const hasSelection = selectedFileIds.length > 0; + + return ( +
+ {/* Left: Select All */} +
+ + + + + +
+ + {/* Center: Selected count */} +
+ {hasSelection && ( + + {t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })} + + )} +
+ + {/* Right: Delete and Download */} + + + + + + + + + + + + + +
+ ); +}; + +export default FileActions; diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx index 8e1975137..fd9357a94 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -19,22 +19,23 @@ const FileListArea: React.FC = ({ activeSource, recentFiles, filteredFiles, - selectedFileIds, + selectedFilesSet, onFileSelect, onFileRemove, onFileDoubleClick, + onDownloadSingle, isFileSupported, } = useFileManagerContext(); const { t } = useTranslation(); if (activeSource === 'recent') { return ( - @@ -53,10 +54,11 @@ const FileListArea: React.FC = ({ onFileSelect(file)} + onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} + onDownload={() => onDownloadSingle(file)} onDoubleClick={() => onFileDoubleClick(file)} /> )) @@ -77,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 147133009..4b0e408d1 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; -import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; +import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; +import DownloadIcon from '@mui/icons-material/Download'; +import { useTranslation } from 'react-i18next'; import { getFileSize, getFileDate } from '../../utils/fileUtils'; import { FileWithUrl } from '../../types/file'; @@ -8,33 +11,44 @@ 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, - onRemove, +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, + onRemove, + onDownload, onDoubleClick }) => { 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)} @@ -54,26 +68,59 @@ const FileListItem: React.FC = ({ }} /> - + {file.name} {getFileSize(file)} • {getFileDate(file)} - {/* Delete button - fades in/out on hover */} - { 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' - }} + + {/* Three dots menu - fades in/out on hover */} + setIsMenuOpen(true)} + onClose={() => setIsMenuOpen(false)} > - - + + e.stopPropagation()} + style={{ + opacity: shouldShowHovered ? 1 : 0, + transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: shouldShowHovered ? 'auto' : 'none' + }} + > + + + + + + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + > + {t('fileManager.download', 'Download')} + + )} + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t('fileManager.delete', 'Delete')} + + + { } @@ -81,4 +128,4 @@ const FileListItem: React.FC = ({ ); }; -export default FileListItem; \ No newline at end of file +export default FileListItem; diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx index 30d1ad6b9..5201aafb4 100644 --- a/frontend/src/components/fileManager/MobileLayout.tsx +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => { // Estimate heights of fixed components const fileSourceHeight = '3rem'; // FileSourceButtons height const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height + const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom) const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height - const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps + const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps - return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`; + return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`; }; return ( @@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => { minHeight: 0 }}> {activeSource === 'recent' && ( - - - + <> + + + + + + + )} diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 98b93cf0b..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 } from '../services/fileStorage'; +import { StoredFile, fileStorage } from '../services/fileStorage'; +import { downloadFiles } from '../utils/downloadUtils'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -11,16 +12,21 @@ 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; onSearchChange: (value: string) => void; onFileInputChange: (event: React.ChangeEvent) => void; + onSelectAll: () => void; + onDeleteSelected: () => void; + onDownloadSelected: () => void; + onDownloadSingle: (file: FileWithUrl) => void; // External props recentFiles: FileWithUrl[]; @@ -60,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); } }, []); @@ -83,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]; @@ -152,6 +192,72 @@ export const FileManagerProvider: React.FC = ({ event.target.value = ''; }, [storeFile, onFilesSelected, refreshRecentFiles, onClose]); + const handleSelectAll = useCallback(() => { + const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + if (allFilesSelected) { + // Deselect all + setSelectedFileIds([]); + setLastClickedIndex(null); + } else { + // Select all filtered files + setSelectedFileIds(filteredFiles.map(file => file.id || file.name)); + setLastClickedIndex(null); + } + }, [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) + ); + + // 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); + } + }, [selectedFileIds, filteredFiles]); + + const handleDownloadSingle = useCallback(async (file: FileWithUrl) => { + try { + await downloadFiles([file]); + } catch (error) { + console.error('Failed to download file:', error); + } + }, []); + + // Cleanup blob URLs when component unmounts useEffect(() => { return () => { @@ -169,6 +275,7 @@ export const FileManagerProvider: React.FC = ({ setActiveSource('recent'); setSelectedFileIds([]); setSearchTerm(''); + setLastClickedIndex(null); } }, [isOpen]); @@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC = ({ selectedFiles, filteredFiles, fileInputRef, + selectedFilesSet, // Handlers onSourceChange: handleSourceChange, @@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC = ({ onOpenFiles: handleOpenFiles, onSearchChange: handleSearchChange, onFileInputChange: handleFileInputChange, + onSelectAll: handleSelectAll, + onDeleteSelected: handleDeleteSelected, + onDownloadSelected: handleDownloadSelected, + onDownloadSingle: handleDownloadSingle, // External props recentFiles, diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index 27e70d461..a8552b2bb 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -191,6 +191,19 @@ export const mantineTheme = createTheme({ }, }, }, + Tooltip: { + styles: { + tooltip: { + backgroundColor: 'var( --tooltip-title-bg)', + color: 'var( --tooltip-title-color)', + border: '1px solid var(--tooltip-borderp)', + fontSize: '0.75rem', + fontWeight: '500', + boxShadow: 'var(--shadow-md)', + borderRadius: 'var(--radius-sm)', + }, + }, + }, Checkbox: { styles: { 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