mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Merge branch 'V2' into feature/v2/selectedfiles
This commit is contained in:
commit
2fad7861a4
@ -1011,7 +1011,49 @@
|
|||||||
"submit": "Change"
|
"submit": "Change"
|
||||||
},
|
},
|
||||||
"removePages": {
|
"removePages": {
|
||||||
"tags": "Remove pages,delete pages"
|
"tags": "Remove pages,delete pages",
|
||||||
|
"title": "Remove Pages",
|
||||||
|
"pageNumbers": "Pages to Remove",
|
||||||
|
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
|
||||||
|
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
|
||||||
|
"filenamePrefix": "pages_removed",
|
||||||
|
"files": {
|
||||||
|
"placeholder": "Select a PDF file in the main view to get started"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Page Selection"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"failed": "An error occurred whilst removing pages."
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "Page Removal Results"
|
||||||
|
},
|
||||||
|
"submit": "Remove Pages"
|
||||||
|
},
|
||||||
|
"pageSelection": {
|
||||||
|
"tooltip": {
|
||||||
|
"header": {
|
||||||
|
"title": "Page Selection Guide"
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"title": "Basic Usage",
|
||||||
|
"text": "Select specific pages from your PDF document using simple syntax.",
|
||||||
|
"bullet1": "Individual pages: 1,3,5",
|
||||||
|
"bullet2": "Page ranges: 3-6 or 10-15",
|
||||||
|
"bullet3": "All pages: all"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Advanced Features"
|
||||||
|
},
|
||||||
|
"tips": {
|
||||||
|
"title": "Tips",
|
||||||
|
"text": "Keep these guidelines in mind:",
|
||||||
|
"bullet1": "Page numbers start from 1 (not 0)",
|
||||||
|
"bullet2": "Spaces are automatically removed",
|
||||||
|
"bullet3": "Invalid expressions are ignored"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"compressPdfs": {
|
"compressPdfs": {
|
||||||
"tags": "squish,small,tiny"
|
"tags": "squish,small,tiny"
|
||||||
@ -1989,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",
|
||||||
|
@ -738,7 +738,72 @@
|
|||||||
"submit": "Change"
|
"submit": "Change"
|
||||||
},
|
},
|
||||||
"removePages": {
|
"removePages": {
|
||||||
"tags": "Remove pages,delete pages"
|
"tags": "Remove pages,delete pages",
|
||||||
|
"title": "Remove Pages",
|
||||||
|
"pageNumbers": "Pages to Remove",
|
||||||
|
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
|
||||||
|
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
|
||||||
|
"filenamePrefix": "pages_removed",
|
||||||
|
"files": {
|
||||||
|
"placeholder": "Select a PDF file in the main view to get started"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Page Selection"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"failed": "An error occurred while removing pages."
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "Page Removal Results"
|
||||||
|
},
|
||||||
|
"submit": "Remove Pages"
|
||||||
|
},
|
||||||
|
"pageSelection": {
|
||||||
|
"tooltip": {
|
||||||
|
"header": {
|
||||||
|
"title": "Page Selection Guide"
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"title": "Basic Usage",
|
||||||
|
"text": "Select specific pages from your PDF document using simple syntax.",
|
||||||
|
"bullet1": "Individual pages: 1,3,5",
|
||||||
|
"bullet2": "Page ranges: 3-6 or 10-15",
|
||||||
|
"bullet3": "All pages: all"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Advanced Features",
|
||||||
|
"expandText": "▶ Show advanced options",
|
||||||
|
"collapseText": "▼ Hide advanced options",
|
||||||
|
"mathematical": {
|
||||||
|
"title": "Mathematical Functions",
|
||||||
|
"text": "Use mathematical expressions to select page patterns:",
|
||||||
|
"bullet1": "2n - all even pages (2, 4, 6, 8...)",
|
||||||
|
"bullet2": "2n+1 - all odd pages (1, 3, 5, 7...)",
|
||||||
|
"bullet3": "3n - every 3rd page (3, 6, 9, 12...)",
|
||||||
|
"bullet4": "4n-1 - pages 3, 7, 11, 15..."
|
||||||
|
},
|
||||||
|
"ranges": {
|
||||||
|
"title": "Open-ended Ranges",
|
||||||
|
"text": "Select from a starting point to the end:",
|
||||||
|
"bullet1": "5- selects pages 5 to end of document",
|
||||||
|
"bullet2": "10- selects pages 10 to end"
|
||||||
|
},
|
||||||
|
"combinations": {
|
||||||
|
"title": "Complex Combinations",
|
||||||
|
"text": "Combine different selection methods:",
|
||||||
|
"bullet1": "1,3-5,8,2n - pages 1, 3-5, 8, and all even pages",
|
||||||
|
"bullet2": "10-,2n+1 - pages 10 to end plus all odd pages",
|
||||||
|
"bullet3": "1-5,15-,3n - pages 1-5, 15 to end, and every 3rd page"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tips": {
|
||||||
|
"title": "Tips",
|
||||||
|
"text": "Keep these guidelines in mind:",
|
||||||
|
"bullet1": "Page numbers start from 1 (not 0)",
|
||||||
|
"bullet2": "Spaces are automatically removed",
|
||||||
|
"bullet3": "Invalid expressions are ignored"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"compressPdfs": {
|
"compressPdfs": {
|
||||||
"tags": "squish,small,tiny"
|
"tags": "squish,small,tiny"
|
||||||
|
@ -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 }}>
|
||||||
|
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal file
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TooltipContent } from '../../types/tips';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable tooltip for page selection functionality.
|
||||||
|
* Can be used by any tool that uses the GeneralUtils.parsePageList syntax.
|
||||||
|
*/
|
||||||
|
export const usePageSelectionTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("pageSelection.tooltip.header.title", "Page Selection Guide")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
description: t("pageSelection.tooltip.description", "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("pageSelection.tooltip.individual.title", "Individual Pages"),
|
||||||
|
description: t("pageSelection.tooltip.individual.description", "Enter numbers separated by commas."),
|
||||||
|
bullets: [
|
||||||
|
t("pageSelection.tooltip.individual.bullet1", "<strong>1,3,5</strong> → selects pages 1, 3, 5"),
|
||||||
|
t("pageSelection.tooltip.individual.bullet2", "<strong>2,7,12</strong> → selects pages 2, 7, 12")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("pageSelection.tooltip.ranges.title", "Page Ranges"),
|
||||||
|
description: t("pageSelection.tooltip.ranges.description", "Use - for consecutive pages."),
|
||||||
|
bullets: [
|
||||||
|
t("pageSelection.tooltip.ranges.bullet1", "<strong>3-6</strong> → selects pages 3–6"),
|
||||||
|
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 10–15"),
|
||||||
|
t("pageSelection.tooltip.ranges.bullet3", "<strong>5-</strong> → selects pages 5 to end")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("pageSelection.tooltip.mathematical.title", "Mathematical Functions"),
|
||||||
|
description: t("pageSelection.tooltip.mathematical.description", "Use n in formulas for patterns."),
|
||||||
|
bullets: [
|
||||||
|
t("pageSelection.tooltip.mathematical.bullet2", "<strong>2n-1</strong> → all odd pages (1, 3, 5…)"),
|
||||||
|
t("pageSelection.tooltip.mathematical.bullet1", "<strong>2n</strong> → all even pages (2, 4, 6…)"),
|
||||||
|
t("pageSelection.tooltip.mathematical.bullet3", "<strong>3n</strong> → every 3rd page (3, 6, 9…)"),
|
||||||
|
t("pageSelection.tooltip.mathematical.bullet4", "<strong>4n-1</strong> → pages 3, 7, 11, 15…")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("pageSelection.tooltip.special.title", "Special Keywords"),
|
||||||
|
bullets: [
|
||||||
|
t("pageSelection.tooltip.special.bullet1", "<strong>all</strong> → selects all pages"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("pageSelection.tooltip.complex.title", "Complex Combinations"),
|
||||||
|
description: t("pageSelection.tooltip.complex.description", "Mix different types."),
|
||||||
|
bullets: [
|
||||||
|
t("pageSelection.tooltip.complex.bullet1", "<strong>1,3-5,8,2n</strong> → pages 1, 3–5, 8, plus evens"),
|
||||||
|
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
@ -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