Select all, delete, download

This commit is contained in:
Connor Yoh 2025-08-20 12:23:15 +01:00
parent d1cb3f0b30
commit 0ba9bb4733
6 changed files with 358 additions and 32 deletions

View File

@ -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' && (
<>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0 }}>

View File

@ -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 (
<div style={{
padding: '0.75rem 1rem',
backgroundColor: 'var(--mantine-color-gray-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '3rem',
position: 'relative',
}}>
{/* Left: Select All */}
<div>
<Tooltip
label={allFilesSelected
? t('fileManager.deselectAll', 'Deselect All')
: t('fileManager.selectAll', 'Select All')
}
>
<ActionIcon
variant="light"
size="sm"
color='dimmed'
onClick={handleSelectAll}
disabled={filteredFiles.length === 0}
radius="sm"
>
<SelectAllIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Tooltip>
</div>
{/* Center: Selected count */}
<div style={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
}}>
{hasSelection && (
<Text size="sm" c="dimmed" fw={500}>
{t('fileManager.selectedCount', '{{count}} selected', { count: selectedFileIds.length })}
</Text>
)}
</div>
{/* Right: Delete and Download */}
<Group gap="xs">
<Tooltip label={t('fileManager.deleteSelected', 'Delete Selected')}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDeleteSelected}
disabled={!hasSelection}
radius="sm"
>
<DeleteIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Tooltip>
<Tooltip label={t('fileManager.downloadSelected', 'Download Selected')}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection}
radius="sm"
>
<DownloadIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Tooltip>
</Group>
</div>
);
};
export default FileActions;

View File

@ -23,6 +23,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileSelect,
onFileRemove,
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
} = useFileManagerContext();
const { t } = useTranslation();
@ -57,6 +58,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
isSupported={isFileSupported(file.name)}
onSelect={() => onFileSelect(file)}
onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)}
/>
))

View File

@ -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;
}
@ -20,9 +24,15 @@ const FileListItem: React.FC<FileListItemProps> = ({
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 (
<>
@ -30,7 +40,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
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<FileListItemProps> = ({
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Delete button - fades in/out on hover */}
{/* Three dots menu - fades in/out on hover */}
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
onClick={(e) => e.stopPropagation()}
style={{
opacity: isHovered ? 1 : 0,
transform: isHovered ? 'scale(1)' : 'scale(0.8)',
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: isHovered ? 'auto' : 'none'
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onDownload && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
>
{t('fileManager.download', 'Download')}
</Menu.Item>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t('fileManager.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}

View File

@ -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' && (
<>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<FileActions />
</Box>
</>
)}
<Box style={{ flex: 1, minHeight: 0 }}>

View File

@ -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<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: FileWithUrl) => void;
// External props
recentFiles: FileWithUrl[];
@ -152,6 +156,140 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
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<FileManagerProviderProps> = ({
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
// External props
recentFiles,