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 */}
+
+
+ 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,