mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 04:09:22 +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",
|
"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",
|
||||||
|
@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
|
|||||||
import FileDetails from './FileDetails';
|
import FileDetails from './FileDetails';
|
||||||
import SearchInput from './SearchInput';
|
import SearchInput from './SearchInput';
|
||||||
import FileListArea from './FileListArea';
|
import FileListArea from './FileListArea';
|
||||||
|
import FileActions from './FileActions';
|
||||||
import HiddenFileInput from './HiddenFileInput';
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
@ -45,12 +46,20 @@ const DesktopLayout: React.FC = () => {
|
|||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{activeSource === 'recent' && (
|
{activeSource === 'recent' && (
|
||||||
|
<>
|
||||||
<div style={{
|
<div style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||||
}}>
|
}}>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||||
|
}}>
|
||||||
|
<FileActions />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<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,
|
activeSource,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
selectedFileIds,
|
selectedFilesSet,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onFileRemove,
|
onFileRemove,
|
||||||
onFileDoubleClick,
|
onFileDoubleClick,
|
||||||
|
onDownloadSingle,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
} = useFileManagerContext();
|
} = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -53,10 +54,11 @@ 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)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
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 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 { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
@ -8,8 +11,9 @@ 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;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
}
|
}
|
||||||
@ -20,9 +24,15 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
isSupported,
|
isSupported,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDownload,
|
||||||
onDoubleClick
|
onDoubleClick
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -30,11 +40,15 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
p="sm"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
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,
|
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)}
|
||||||
@ -59,21 +73,54 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
<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>
|
||||||
{/* 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
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
opacity: isHovered ? 1 : 0,
|
opacity: shouldShowHovered ? 1 : 0,
|
||||||
transform: isHovered ? 'scale(1)' : 'scale(0.8)',
|
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
|
||||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
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>
|
</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>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||||
|
@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
|
|||||||
import FileDetails from './FileDetails';
|
import FileDetails from './FileDetails';
|
||||||
import SearchInput from './SearchInput';
|
import SearchInput from './SearchInput';
|
||||||
import FileListArea from './FileListArea';
|
import FileListArea from './FileListArea';
|
||||||
|
import FileActions from './FileActions';
|
||||||
import HiddenFileInput from './HiddenFileInput';
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => {
|
|||||||
// Estimate heights of fixed components
|
// Estimate heights of fixed components
|
||||||
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||||
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact 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 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 (
|
return (
|
||||||
@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => {
|
|||||||
minHeight: 0
|
minHeight: 0
|
||||||
}}>
|
}}>
|
||||||
{activeSource === 'recent' && (
|
{activeSource === 'recent' && (
|
||||||
|
<>
|
||||||
<Box style={{
|
<Box style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
}}>
|
}}>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
|
}}>
|
||||||
|
<FileActions />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
@ -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 } 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,16 +12,21 @@ 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;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onDownloadSingle: (file: FileWithUrl) => void;
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles: FileWithUrl[];
|
recentFiles: FileWithUrl[];
|
||||||
@ -60,14 +66,20 @@ 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 =>
|
|
||||||
|
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())
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -76,6 +88,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
if (source !== 'recent') {
|
if (source !== 'recent') {
|
||||||
setSelectedFileIds([]);
|
setSelectedFileIds([]);
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
|
setLastClickedIndex(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -83,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) => {
|
||||||
|
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 => {
|
setSelectedFileIds(prev => {
|
||||||
if (file.id) {
|
const selectedSet = new Set(prev);
|
||||||
if (prev.includes(file.id)) {
|
|
||||||
return prev.filter(id => id !== file.id);
|
// Add all files in the range to selection
|
||||||
} else {
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
return [...prev, file.id];
|
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];
|
||||||
@ -152,6 +192,72 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
}, [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
|
// Cleanup blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -169,6 +275,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
setActiveSource('recent');
|
setActiveSource('recent');
|
||||||
setSelectedFileIds([]);
|
setSelectedFileIds([]);
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
|
setLastClickedIndex(null);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
|
selectedFilesSet,
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onSourceChange: handleSourceChange,
|
onSourceChange: handleSourceChange,
|
||||||
@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
onOpenFiles: handleOpenFiles,
|
onOpenFiles: handleOpenFiles,
|
||||||
onSearchChange: handleSearchChange,
|
onSearchChange: handleSearchChange,
|
||||||
onFileInputChange: handleFileInputChange,
|
onFileInputChange: handleFileInputChange,
|
||||||
|
onSelectAll: handleSelectAll,
|
||||||
|
onDeleteSelected: handleDeleteSelected,
|
||||||
|
onDownloadSelected: handleDownloadSelected,
|
||||||
|
onDownloadSingle: handleDownloadSingle,
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles,
|
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: {
|
Checkbox: {
|
||||||
styles: {
|
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