Merge branch 'V2' into feature/v2/selectedfiles

This commit is contained in:
ConnorYoh 2025-08-20 16:52:08 +01:00 committed by GitHub
commit 2fad7861a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 729 additions and 93 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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 }}>

View 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;

View File

@ -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)}
/> />
)) ))

View 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)" />}

View File

@ -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 }}>

View 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 36"),
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 1015"),
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, 35, 8, plus evens"),
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
]
}
]
};
};

View File

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file'; import { FileWithUrl } from '../types/file';
import { StoredFile } 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,

View File

@ -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: {

View File

@ -0,0 +1,152 @@
import { FileWithUrl } from '../types/file';
import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService';
/**
* Downloads a blob as a file using browser download API
* @param blob - The blob to download
* @param filename - The filename for the download
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
}
/**
* Downloads a single file from IndexedDB storage
* @param file - The file object with storage information
* @throws Error if file cannot be retrieved from storage
*/
export async function downloadFileFromStorage(file: FileWithUrl): Promise<void> {
const lookupKey = file.id || file.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (!storedFile) {
throw new Error(`File "${file.name}" not found in storage`);
}
const blob = new Blob([storedFile.data], { type: storedFile.type });
downloadBlob(blob, storedFile.name);
}
/**
* Downloads multiple files as individual downloads
* @param files - Array of files to download
*/
export async function downloadMultipleFiles(files: FileWithUrl[]): Promise<void> {
for (const file of files) {
await downloadFileFromStorage(file);
}
}
/**
* Downloads multiple files as a single ZIP archive
* @param files - Array of files to include in ZIP
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
*/
export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: string): Promise<void> {
if (files.length === 0) {
throw new Error('No files provided for ZIP download');
}
// Convert stored files to File objects
const fileObjects: File[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id || fileWithUrl.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
fileObjects.push(file);
}
}
if (fileObjects.length === 0) {
throw new Error('No valid files found in storage for ZIP download');
}
// Generate default filename if not provided
const finalZipFilename = zipFilename ||
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`;
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename);
downloadBlob(zipFile, finalZipFilename);
}
/**
* Smart download function that handles single or multiple files appropriately
* - Single file: Downloads directly
* - Multiple files: Downloads as ZIP
* @param files - Array of files to download
* @param options - Download options
*/
export async function downloadFiles(
files: FileWithUrl[],
options: {
forceZip?: boolean;
zipFilename?: string;
multipleAsIndividual?: boolean;
} = {}
): Promise<void> {
if (files.length === 0) {
throw new Error('No files provided for download');
}
if (files.length === 1 && !options.forceZip) {
// Single file download
await downloadFileFromStorage(files[0]);
} else if (options.multipleAsIndividual) {
// Multiple individual downloads
await downloadMultipleFiles(files);
} else {
// ZIP download (default for multiple files)
await downloadFilesAsZip(files, options.zipFilename);
}
}
/**
* Downloads a File object directly (for files already in memory)
* @param file - The File object to download
* @param filename - Optional custom filename
*/
export function downloadFileObject(file: File, filename?: string): void {
downloadBlob(file, filename || file.name);
}
/**
* Downloads text content as a file
* @param content - Text content to download
* @param filename - Filename for the download
* @param mimeType - MIME type (defaults to text/plain)
*/
export function downloadTextAsFile(
content: string,
filename: string,
mimeType: string = 'text/plain'
): void {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
/**
* Downloads JSON data as a file
* @param data - Data to serialize and download
* @param filename - Filename for the download
*/
export function downloadJsonAsFile(data: any, filename: string): void {
const content = JSON.stringify(data, null, 2);
downloadTextAsFile(content, filename, 'application/json');
}