From 0ba9bb47338797ac854caefe816d2ba7c270fecc Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 20 Aug 2025 12:23:15 +0100 Subject: [PATCH] Select all, delete, download --- .../components/fileManager/DesktopLayout.tsx | 21 ++- .../components/fileManager/FileActions.tsx | 120 +++++++++++++++ .../components/fileManager/FileListArea.tsx | 2 + .../components/fileManager/FileListItem.tsx | 77 +++++++--- .../components/fileManager/MobileLayout.tsx | 26 +++- frontend/src/contexts/FileManagerContext.tsx | 144 +++++++++++++++++- 6 files changed, 358 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/fileManager/FileActions.tsx diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index be701ff20..d2b87de70 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'; @@ -45,12 +46,20 @@ const DesktopLayout: React.FC = () => { overflow: 'hidden' }}> {activeSource === 'recent' && ( -
- -
+ <> +
+ +
+
+ +
+ )}
diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx new file mode 100644 index 000000000..ec065fa08 --- /dev/null +++ b/frontend/src/components/fileManager/FileActions.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Group, Button, Text, Tooltip, ActionIcon } 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..025c0cb60 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -23,6 +23,7 @@ const FileListArea: React.FC = ({ onFileSelect, onFileRemove, onFileDoubleClick, + onDownloadSingle, isFileSupported, } = useFileManagerContext(); const { t } = useTranslation(); @@ -57,6 +58,7 @@ const FileListArea: React.FC = ({ isSupported={isFileSupported(file.name)} onSelect={() => onFileSelect(file)} onRemove={() => onFileRemove(index)} + onDownload={() => onDownloadSingle(file)} onDoubleClick={() => onFileDoubleClick(file)} /> )) diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 147133009..72dac10d0 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'; @@ -10,6 +13,7 @@ interface FileListItemProps { isSupported: boolean; onSelect: () => void; onRemove: () => void; + onDownload?: () => void; onDoubleClick?: () => void; isLast?: boolean; } @@ -19,10 +23,16 @@ const FileListItem: React.FC = ({ isSelected, isSupported, onSelect, - onRemove, + 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 ( <> @@ -30,7 +40,7 @@ const FileListItem: React.FC = ({ p="sm" style={{ cursor: 'pointer', - backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'), + backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (shouldShowHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'), opacity: isSupported ? 1 : 0.5, transition: 'background-color 0.15s ease' }} @@ -59,21 +69,54 @@ 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')} + + + { } 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..9128d3fd5 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,6 +1,6 @@ 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'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -21,6 +21,10 @@ interface FileManagerContextValue { 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[]; @@ -152,6 +156,140 @@ 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([]); + } 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 () => { @@ -190,6 +328,10 @@ export const FileManagerProvider: React.FC = ({ onOpenFiles: handleOpenFiles, onSearchChange: handleSearchChange, onFileInputChange: handleFileInputChange, + onSelectAll: handleSelectAll, + onDeleteSelected: handleDeleteSelected, + onDownloadSelected: handleDownloadSelected, + onDownloadSingle: handleDownloadSingle, // External props recentFiles,