mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
Download utils, shift click selections
This commit is contained in:
parent
0ba9bb4733
commit
fc9c4fdb7f
@ -1989,7 +1989,14 @@
|
||||
"fileSize": "Size",
|
||||
"fileVersion": "Version",
|
||||
"totalSelected": "Total Selected",
|
||||
"dropFilesHere": "Drop files here"
|
||||
"dropFilesHere": "Drop files here",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"downloadSelected": "Download Selected",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"download": "Download",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
|
@ -19,7 +19,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
selectedFilesSet,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
@ -30,12 +30,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
|
||||
if (activeSource === 'recent') {
|
||||
return (
|
||||
<ScrollArea
|
||||
<ScrollArea
|
||||
h={scrollAreaHeight}
|
||||
style={{
|
||||
style={{
|
||||
...scrollAreaStyle
|
||||
}}
|
||||
type="always"
|
||||
type="always"
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
@ -54,9 +54,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
<FileListItem
|
||||
key={file.id || file.name}
|
||||
file={file}
|
||||
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||
isSelected={selectedFilesSet.has(file.id || file.name)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={() => onFileSelect(file)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
@ -79,4 +79,4 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListArea;
|
||||
export default FileListArea;
|
||||
|
@ -11,18 +11,18 @@ interface FileListItemProps {
|
||||
file: FileWithUrl;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: () => void;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDownload?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onDoubleClick
|
||||
@ -30,21 +30,25 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (shouldShowHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease'
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}
|
||||
onClick={onSelect}
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@ -64,22 +68,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>{file.name}</Text>
|
||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Three dots menu - fades in/out on hover */}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@ -93,7 +97,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onDownload && (
|
||||
<Menu.Item
|
||||
@ -124,4 +128,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListItem;
|
||||
export default FileListItem;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@ -11,11 +12,12 @@ interface FileManagerContextValue {
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
onOpenFiles: () => void;
|
||||
@ -64,22 +66,29 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
|
||||
const filteredFiles = (recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name));
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||
setActiveSource(source);
|
||||
if (source !== 'recent') {
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -87,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||
setSelectedFileIds(prev => {
|
||||
if (file.id) {
|
||||
if (prev.includes(file.id)) {
|
||||
return prev.filter(id => id !== file.id);
|
||||
} else {
|
||||
return [...prev, file.id];
|
||||
const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id || file.name;
|
||||
if (!fileId) return;
|
||||
|
||||
if (shiftKey && lastClickedIndex !== null) {
|
||||
// Range selection with shift-click
|
||||
const startIndex = Math.min(lastClickedIndex, currentIndex);
|
||||
const endIndex = Math.max(lastClickedIndex, currentIndex);
|
||||
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
// Add all files in the range to selection
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name;
|
||||
if (rangeFileId) {
|
||||
selectedSet.add(rangeFileId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
} else {
|
||||
// Normal click behavior - optimized with Set for O(1) lookup
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
if (selectedSet.has(fileId)) {
|
||||
selectedSet.delete(fileId);
|
||||
} else {
|
||||
selectedSet.add(fileId);
|
||||
}
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
|
||||
// Update last clicked index for future range selections
|
||||
setLastClickedIndex(currentIndex);
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
const handleFileRemove = useCallback((index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
@ -161,9 +197,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
if (allFilesSelected) {
|
||||
// Deselect all
|
||||
setSelectedFileIds([]);
|
||||
setLastClickedIndex(null);
|
||||
} else {
|
||||
// Select all filtered files
|
||||
setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [filteredFiles, selectedFileIds]);
|
||||
|
||||
@ -192,6 +230,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
@ -201,65 +240,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFileIds.includes(file.id || file.name)
|
||||
);
|
||||
|
||||
if (selectedFilesToDownload.length === 1) {
|
||||
// Single file download
|
||||
const fileWithUrl = selectedFilesToDownload[0];
|
||||
const lookupKey = fileWithUrl.id || fileWithUrl.name;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = storedFile.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} else if (selectedFilesToDownload.length > 1) {
|
||||
// Multiple files - create ZIP download
|
||||
const { zipFileService } = await import('../services/zipFileService');
|
||||
|
||||
// Convert stored files to File objects
|
||||
const files: File[] = [];
|
||||
for (const fileWithUrl of selectedFilesToDownload) {
|
||||
const lookupKey = fileWithUrl.id || fileWithUrl.name;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
// Create ZIP file
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
|
||||
const zipFilename = `selected-files-${timestamp}.zip`;
|
||||
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(files, zipFilename);
|
||||
|
||||
// Download the ZIP file
|
||||
const url = URL.createObjectURL(zipFile);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = zipFilename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
@ -267,23 +251,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
|
||||
try {
|
||||
const lookupKey = file.id || file.name;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = storedFile.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
}
|
||||
@ -307,6 +275,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@ -318,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
|
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