Download utils, shift click selections

This commit is contained in:
Connor Yoh 2025-08-20 13:50:15 +01:00
parent 0ba9bb4733
commit fc9c4fdb7f
5 changed files with 254 additions and 121 deletions

View File

@ -1989,7 +1989,14 @@
"fileSize": "Size", "fileSize": "Size",
"fileVersion": "Version", "fileVersion": "Version",
"totalSelected": "Total Selected", "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": { "storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",

View File

@ -19,7 +19,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
activeSource, activeSource,
recentFiles, recentFiles,
filteredFiles, filteredFiles,
selectedFileIds, selectedFilesSet,
onFileSelect, onFileSelect,
onFileRemove, onFileRemove,
onFileDoubleClick, onFileDoubleClick,
@ -30,12 +30,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
if (activeSource === 'recent') { if (activeSource === 'recent') {
return ( return (
<ScrollArea <ScrollArea
h={scrollAreaHeight} h={scrollAreaHeight}
style={{ style={{
...scrollAreaStyle ...scrollAreaStyle
}} }}
type="always" type="always"
scrollbarSize={8} scrollbarSize={8}
> >
<Stack gap={0}> <Stack gap={0}>
@ -54,9 +54,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
<FileListItem <FileListItem
key={file.id || file.name} key={file.id || file.name}
file={file} file={file}
isSelected={selectedFileIds.includes(file.id || file.name)} isSelected={selectedFilesSet.has(file.id || file.name)}
isSupported={isFileSupported(file.name)} isSupported={isFileSupported(file.name)}
onSelect={() => onFileSelect(file)} onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)} onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)} onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)} onDoubleClick={() => onFileDoubleClick(file)}
@ -79,4 +79,4 @@ const FileListArea: React.FC<FileListAreaProps> = ({
); );
}; };
export default FileListArea; export default FileListArea;

View File

@ -11,18 +11,18 @@ interface FileListItemProps {
file: FileWithUrl; file: FileWithUrl;
isSelected: boolean; isSelected: boolean;
isSupported: boolean; isSupported: boolean;
onSelect: () => void; onSelect: (shiftKey?: boolean) => void;
onRemove: () => void; onRemove: () => void;
onDownload?: () => void; onDownload?: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
isLast?: boolean; isLast?: boolean;
} }
const FileListItem: React.FC<FileListItemProps> = ({ const FileListItem: React.FC<FileListItemProps> = ({
file, file,
isSelected, isSelected,
isSupported, isSupported,
onSelect, onSelect,
onRemove, onRemove,
onDownload, onDownload,
onDoubleClick onDoubleClick
@ -30,21 +30,25 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
// Keep item in hovered state if menu is open // Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen; const shouldShowHovered = isHovered || isMenuOpen;
return ( return (
<> <>
<Box <Box
p="sm" p="sm"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (shouldShowHovered ? '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, opacity: isSupported ? 1 : 0.5,
transition: 'background-color 0.15s ease' transition: 'background-color 0.15s ease',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}} }}
onClick={onSelect} onClick={(e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
@ -64,22 +68,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
}} }}
/> />
</Box> </Box>
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text> <Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text> <Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box> </Box>
{/* Three dots menu - fades in/out on hover */} {/* Three dots menu - fades in/out on hover */}
<Menu <Menu
position="bottom-end" position="bottom-end"
withinPortal withinPortal
onOpen={() => setIsMenuOpen(true)} onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)} onClose={() => setIsMenuOpen(false)}
> >
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
c="dimmed" c="dimmed"
size="md" size="md"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -93,7 +97,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<MoreVertIcon style={{ fontSize: 20 }} /> <MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{onDownload && ( {onDownload && (
<Menu.Item <Menu.Item
@ -124,4 +128,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
); );
}; };
export default FileListItem; export default FileListItem;

View File

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file'; import { FileWithUrl } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage'; import { StoredFile, fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils';
// Type for the context value - now contains everything directly // Type for the context value - now contains everything directly
interface FileManagerContextValue { interface FileManagerContextValue {
@ -11,11 +12,12 @@ interface FileManagerContextValue {
selectedFiles: FileWithUrl[]; selectedFiles: FileWithUrl[];
filteredFiles: FileWithUrl[]; filteredFiles: FileWithUrl[];
fileInputRef: React.RefObject<HTMLInputElement | null>; fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>;
// Handlers // Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void; onLocalFileClick: () => void;
onFileSelect: (file: FileWithUrl) => void; onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void; onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void; onFileDoubleClick: (file: FileWithUrl) => void;
onOpenFiles: () => void; onOpenFiles: () => void;
@ -64,22 +66,29 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]); const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup // Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set()); const createdBlobUrls = useRef<Set<string>>(new Set());
// Computed values (with null safety) // Computed values (with null safety)
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name)); const selectedFilesSet = new Set(selectedFileIds);
const filteredFiles = (recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) 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') => { const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
setActiveSource(source); setActiveSource(source);
if (source !== 'recent') { if (source !== 'recent') {
setSelectedFileIds([]); setSelectedFileIds([]);
setSearchTerm(''); setSearchTerm('');
setLastClickedIndex(null);
} }
}, []); }, []);
@ -87,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, []);
const handleFileSelect = useCallback((file: FileWithUrl) => { const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
setSelectedFileIds(prev => { const fileId = file.id || file.name;
if (file.id) { if (!fileId) return;
if (prev.includes(file.id)) {
return prev.filter(id => id !== file.id); if (shiftKey && lastClickedIndex !== null) {
} else { // Range selection with shift-click
return [...prev, file.id]; 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 handleFileRemove = useCallback((index: number) => {
const fileToRemove = filteredFiles[index]; const fileToRemove = filteredFiles[index];
@ -161,9 +197,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (allFilesSelected) { if (allFilesSelected) {
// Deselect all // Deselect all
setSelectedFileIds([]); setSelectedFileIds([]);
setLastClickedIndex(null);
} else { } else {
// Select all filtered files // Select all filtered files
setSelectedFileIds(filteredFiles.map(file => file.id || file.name)); setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
setLastClickedIndex(null);
} }
}, [filteredFiles, selectedFileIds]); }, [filteredFiles, selectedFileIds]);
@ -192,6 +230,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} }
}, [selectedFileIds, filteredFiles, refreshRecentFiles]); }, [selectedFileIds, filteredFiles, refreshRecentFiles]);
const handleDownloadSelected = useCallback(async () => { const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return; if (selectedFileIds.length === 0) return;
@ -201,65 +240,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFileIds.includes(file.id || file.name) selectedFileIds.includes(file.id || file.name)
); );
if (selectedFilesToDownload.length === 1) { // Use generic download utility
// Single file download await downloadFiles(selectedFilesToDownload, {
const fileWithUrl = selectedFilesToDownload[0]; zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
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) { } catch (error) {
console.error('Failed to download selected files:', error); console.error('Failed to download selected files:', error);
} }
@ -267,23 +251,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => { const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
try { try {
const lookupKey = file.id || file.name; await downloadFiles([file]);
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) { } catch (error) {
console.error('Failed to download file:', error); console.error('Failed to download file:', error);
} }
@ -307,6 +275,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setActiveSource('recent'); setActiveSource('recent');
setSelectedFileIds([]); setSelectedFileIds([]);
setSearchTerm(''); setSearchTerm('');
setLastClickedIndex(null);
} }
}, [isOpen]); }, [isOpen]);
@ -318,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFiles, selectedFiles,
filteredFiles, filteredFiles,
fileInputRef, fileInputRef,
selectedFilesSet,
// Handlers // Handlers
onSourceChange: handleSourceChange, onSourceChange: handleSourceChange,

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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');
}