mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-21 19:59:24 +00:00
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 <img width="1220" height="751" alt="{330DF96D-7040-4CCB-B089-523F370E3185}" src="https://github.com/user-attachments/assets/976e42cc-2124-4e62-83a8-25f184e8da3b" /> <img width="1160" height="749" alt="{2D2F4876-7D35-45C3-B0CD-3127EEEEF7B5}" src="https://github.com/user-attachments/assets/6879a174-a135-41f4-a876-984e7c2f96e2" /> --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
eada9e43ec
commit
cd2b82d614
@ -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",
|
||||
|
@ -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)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
|
115
frontend/src/components/fileManager/FileActions.tsx
Normal file
115
frontend/src/components/fileManager/FileActions.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
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;
|
@ -19,10 +19,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
selectedFilesSet,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
@ -53,10 +54,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
<FileListItem
|
||||
key={file.id || file.name}
|
||||
file={file}
|
||||
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||
isSelected={selectedFilesSet.has(file.id || file.name)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={() => onFileSelect(file)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(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';
|
||||
|
||||
@ -8,8 +11,9 @@ interface FileListItemProps {
|
||||
file: FileWithUrl;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: () => void;
|
||||
onSelect: (shiftKey?: boolean) => 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,11 +40,15 @@ 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-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
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}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@ -59,21 +73,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 */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => { 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 */}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => 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'
|
||||
}}
|
||||
>
|
||||
<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)" />}
|
||||
|
@ -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)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
|
@ -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<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
|
||||
// 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<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileWithUrl) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileWithUrl[];
|
||||
@ -60,22 +66,29 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(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<FileManagerProviderProps> = ({
|
||||
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<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([]);
|
||||
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<FileManagerProviderProps> = ({
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
onFileInputChange: handleFileInputChange,
|
||||
onSelectAll: handleSelectAll,
|
||||
onDeleteSelected: handleDeleteSelected,
|
||||
onDownloadSelected: handleDownloadSelected,
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
|
@ -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: {
|
||||
|
152
frontend/src/utils/downloadUtils.ts
Normal file
152
frontend/src/utils/downloadUtils.ts
Normal 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');
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user