File management tweaks

This commit is contained in:
Reece 2025-07-16 16:35:56 +01:00
parent 55473b4f25
commit 37e2e61d6f
10 changed files with 283 additions and 599 deletions

View File

@ -5,7 +5,8 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(./gradlew:*)", "Bash(./gradlew:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(cat:*)" "Bash(cat:*)",
"Bash(find:*)"
], ],
"deny": [] "deny": []
} }

View File

@ -1,6 +1,5 @@
package stirling.software.common.service; package stirling.software.common.service;
import io.github.pixee.security.ZipSecurity;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -21,6 +20,8 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -361,7 +362,8 @@ public class TaskManager {
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId); MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn = try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) { ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry; ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) { while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) { if (!entry.isDirectory()) {

View File

@ -104,7 +104,10 @@ const FileEditor = ({
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const localSelectedFiles = files const localSelectedFiles = files
.filter(file => safeSelectedFileIds.includes(file.name)) .filter(file => {
const fileId = (file.file as any).id || file.name;
return safeSelectedFileIds.includes(fileId);
})
.map(file => file.id); .map(file => file.id);
// Convert shared files to FileEditor format // Convert shared files to FileEditor format
@ -350,7 +353,7 @@ const FileEditor = ({
}, [addFiles, recordOperation, markOperationApplied]); }, [addFiles, recordOperation, markOperationApplied]);
const selectAll = useCallback(() => { const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
}, [files, setContextSelectedFiles]); }, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
@ -382,18 +385,21 @@ const FileEditor = ({
}); });
// Remove all files from context but keep in storage // Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => f.name), false); removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
// Clear selections // Clear selections
setContextSelectedFiles([]); setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); }, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
const toggleFile = useCallback((fileId: string) => { const toggleFile = useCallback((fileId: string) => {
const fileName = files.find(f => f.id === fileId)?.name || fileId; const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
if (!multiSelect) { if (!multiSelect) {
// Single select mode for tools - toggle on/off // Single select mode for tools - toggle on/off
const isCurrentlySelected = safeSelectedFileIds.includes(fileName); const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId);
if (isCurrentlySelected) { if (isCurrentlySelected) {
// Deselect the file // Deselect the file
setContextSelectedFiles([]); setContextSelectedFiles([]);
@ -402,25 +408,27 @@ const FileEditor = ({
} }
} else { } else {
// Select the file // Select the file
setContextSelectedFiles([fileName]); setContextSelectedFiles([contextFileId]);
const selectedFile = files.find(f => f.id === fileId)?.file; if (onFileSelect) {
if (selectedFile && onFileSelect) { onFileSelect([targetFile.file]);
onFileSelect([selectedFile]);
} }
} }
} else { } else {
// Multi select mode (default) // Multi select mode (default)
setContextSelectedFiles(prev => { setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : []; const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.includes(fileName) return safePrev.includes(contextFileId)
? safePrev.filter(id => id !== fileName) ? safePrev.filter(id => id !== contextFileId)
: [...safePrev, fileName]; : [...safePrev, contextFileId];
}); });
// Notify parent with selected files // Notify parent with selected files
if (onFileSelect) { if (onFileSelect) {
const selectedFiles = files const selectedFiles = files
.filter(f => safeSelectedFileIds.includes(f.name) || f.name === fileName) .filter(f => {
const fId = (f.file as any).id || f.name;
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
})
.map(f => f.file); .map(f => f.file);
onFileSelect(selectedFiles); onFileSelect(selectedFiles);
} }
@ -557,16 +565,17 @@ const FileEditor = ({
console.log('Actual file.file.name:', file.file.name); console.log('Actual file.file.name:', file.file.name);
// Record close operation // Record close operation
const actualFileName = file.file.name; const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = { const operation: FileOperation = {
id: operationId, id: operationId,
type: 'remove', type: 'remove',
timestamp: Date.now(), timestamp: Date.now(),
fileIds: [actualFileName], fileIds: [fileName],
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: actualFileName, originalFileName: fileName,
fileSize: file.size, fileSize: file.size,
parameters: { parameters: {
action: 'close', action: 'close',
@ -575,21 +584,20 @@ const FileEditor = ({
} }
}; };
recordOperation(actualFileName, operation); recordOperation(fileName, operation);
// Remove file from context but keep in storage (close, don't delete) // Remove file from context but keep in storage (close, don't delete)
// Use the actual file name (with extension) not the display name console.log('Calling removeFiles with:', [fileId]);
console.log('Calling removeFiles with:', [actualFileName]); removeFiles([fileId], false);
removeFiles([actualFileName], false);
// Remove from context selections // Remove from context selections
setContextSelectedFiles(prev => { setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : []; const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== actualFileName); return safePrev.filter(id => id !== fileId);
}); });
// Mark operation as applied // Mark operation as applied
markOperationApplied(actualFileName, operationId); markOperationApplied(fileName, operationId);
} else { } else {
console.log('File not found for fileId:', fileId); console.log('File not found for fileId:', fileId);
} }
@ -599,7 +607,8 @@ const FileEditor = ({
const file = files.find(f => f.id === fileId); const file = files.find(f => f.id === fileId);
if (file) { if (file) {
// Set the file as selected in context and switch to page editor view // Set the file as selected in context and switch to page editor view
setContextSelectedFiles([file.name]); const contextFileId = (file.file as any).id || file.name;
setContextSelectedFiles([contextFileId]);
setCurrentView('pageEditor'); setCurrentView('pageEditor');
onOpenPageEditor?.(file.file); onOpenPageEditor?.(file.file);
} }

View File

@ -8,6 +8,7 @@ import EditIcon from "@mui/icons-material/Edit";
import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { fileStorage } from "../../services/fileStorage";
interface FileCardProps { interface FileCardProps {
file: FileWithUrl; file: FileWithUrl;

View File

@ -1,440 +0,0 @@
import React, { useState, useEffect } from "react";
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import { GlobalWorkerOptions } from "pdfjs-dist";
import { StorageStats } from "../../services/fileStorage";
import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
// Refactored imports
import { fileOperationsService } from "../../services/fileOperationsService";
import { checkStorageWarnings } from "../../utils/storageUtils";
import StorageStatsCard from "./StorageStatsCard";
import FileCard from "./FileCard";
import FileUploadSelector from "../shared/FileUploadSelector";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
interface FileManagerProps {
files: FileWithUrl[];
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
allowMultiple?: boolean;
setCurrentView?: (view: string) => void;
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
onOpenPageEditor?: (selectedFiles?: FileWithUrl[]) => void;
onLoadFileToActive?: (file: File) => void;
}
const FileManager = ({
files = [],
setFiles,
allowMultiple = true,
setCurrentView,
onOpenFileEditor,
onOpenPageEditor,
onLoadFileToActive,
}: FileManagerProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
// Extract operations from service for cleaner code
const {
loadStorageStats,
forceReloadFiles,
loadExistingFiles,
uploadFiles,
removeFile,
clearAllFiles,
createBlobUrlForFile,
checkForPurge,
updateStorageStatsIncremental
} = fileOperationsService;
// Add CSS for spinner animation
useEffect(() => {
if (!document.querySelector('#spinner-animation')) {
const style = document.createElement('style');
style.id = 'spinner-animation';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}, []);
// Load existing files from IndexedDB on mount
useEffect(() => {
if (!filesLoaded) {
handleLoadExistingFiles();
}
}, [filesLoaded]);
// Initialize storage configuration on mount
useEffect(() => {
const initStorage = async () => {
try {
const config = await initializeStorageConfig();
setStorageConfig(config);
console.log('Initialized storage config:', config);
} catch (error) {
console.warn('Failed to initialize storage config, using defaults:', error);
}
};
initStorage();
}, []);
// Load storage stats and set up periodic updates
useEffect(() => {
handleLoadStorageStats();
const interval = setInterval(async () => {
await handleLoadStorageStats();
await handleCheckForPurge();
}, 10000); // Update every 10 seconds
return () => clearInterval(interval);
}, []);
// Sync UI with IndexedDB whenever storage stats change
useEffect(() => {
const syncWithStorage = async () => {
if (storageStats && filesLoaded) {
// If file counts don't match, force reload
if (storageStats.fileCount !== files.length) {
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
}
}
};
syncWithStorage();
}, [storageStats, filesLoaded, files.length]);
// Handlers using extracted operations
const handleLoadStorageStats = async () => {
const stats = await loadStorageStats();
if (stats) {
setStorageStats(stats);
// Check for storage warnings
const warning = checkStorageWarnings(stats);
if (warning) {
setNotification(warning);
}
}
};
const handleLoadExistingFiles = async () => {
try {
const loadedFiles = await loadExistingFiles(filesLoaded, files);
setFiles(loadedFiles);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load existing files:', error);
setFilesLoaded(true);
}
};
const handleCheckForPurge = async () => {
try {
const isPurged = await checkForPurge(files);
if (isPurged) {
console.warn('IndexedDB purge detected - forcing UI reload');
setNotification(t("fileManager.storageCleared", "Browser cleared storage. Files have been removed. Please re-upload."));
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
setFilesLoaded(true);
}
} catch (error) {
console.error('Error checking for purge:', error);
}
};
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
// Check individual file sizes
for (const file of filesToUpload) {
if (file.size > storageConfig.maxFileSize) {
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
return {
valid: false,
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
};
}
}
// Check total storage capacity
if (storageStats) {
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
const projectedUsage = storageStats.totalSize + totalNewSize;
if (projectedUsage > storageConfig.maxTotalStorage) {
return {
valid: false,
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
};
}
}
return { valid: true };
};
const handleDrop = async (uploadedFiles: File[]) => {
setLoading(true);
try {
// Validate storage limits before uploading
const validation = validateStorageLimits(uploadedFiles);
if (!validation.valid) {
setNotification(validation.error);
setLoading(false);
return;
}
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
// Update files state
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
setStorageStats(updatedStats);
// Check for storage warnings
const warning = checkStorageWarnings(updatedStats);
if (warning) {
setNotification(warning);
}
}
} catch (error) {
console.error('Error handling file drop:', error);
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
} finally {
setLoading(false);
}
};
const handleRemoveFile = async (index: number) => {
const file = files[index];
try {
await removeFile(file);
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
setStorageStats(updatedStats);
}
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
}
};
const handleClearAll = async () => {
try {
await clearAllFiles(files);
// Reset storage stats
if (storageStats) {
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
setStorageStats(clearedStats);
}
setFiles([]);
} catch (error) {
console.error('Failed to clear all files:', error);
}
};
const handleReloadFiles = () => {
setFilesLoaded(false);
setFiles([]);
};
const handleFileDoubleClick = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const handleFileView = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise<File> => {
// If it's already a regular file, return it
if (fileWithUrl instanceof File) {
return fileWithUrl;
}
// Reconstruct from IndexedDB
const arrayBuffer = await createBlobUrlForFile(fileWithUrl);
if (typeof arrayBuffer === 'string') {
// createBlobUrlForFile returned a blob URL, we need the actual data
const response = await fetch(arrayBuffer);
const data = await response.arrayBuffer();
return new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
} else {
return new File([arrayBuffer], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
}
};
const handleFileEdit = (file: FileWithUrl) => {
if (onOpenFileEditor) {
onOpenFileEditor([file]);
}
};
const toggleFileSelection = (fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
const handleOpenSelectedInEditor = () => {
if (onOpenFileEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenFileEditor(selected);
}
};
const handleOpenSelectedInPageEditor = () => {
if (onOpenPageEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenPageEditor(selected);
}
};
return (
<div style={{
width: "100%",
justifyContent: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: "3rem"
}}>
{/* File upload is now handled by FileUploadSelector when no files exist */}
{/* Storage Stats Card */}
<StorageStatsCard
storageStats={storageStats}
filesCount={files.length}
onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles}
storageConfig={storageConfig}
/>
{/* Multi-selection controls */}
{selectedFiles.length > 0 && (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={() => setSelectedFiles([])}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
<Button
size="xs"
color="orange"
onClick={handleOpenSelectedInEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
<Button
size="xs"
color="blue"
onClick={handleOpenSelectedInPageEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
</Group>
</Group>
</Box>
)}
<Flex
wrap="wrap"
gap="lg"
justify="flex-start"
style={{ width: "90%", marginTop: "1rem"}}
>
{files.map((file, idx) => (
<FileCard
key={file.id || file.name + idx}
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => handleFileDoubleClick(file)}
onView={() => handleFileView(file)}
onEdit={() => handleFileEdit(file)}
isSelected={selectedFiles.includes(file.id || file.name)}
onSelect={() => toggleFileSelection(file.id || file.name)}
/>
))}
</Flex>
{/* Notifications */}
{notification && (
<Notification
color="blue"
onClose={() => setNotification(null)}
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
>
{notification}
</Notification>
)}
</div>
);
};
export default FileManager;

View File

@ -19,6 +19,7 @@ interface FileGridProps {
maxDisplay?: number; // If set, shows only this many files with "Show All" option maxDisplay?: number; // If set, shows only this many files with "Show All" option
onShowAll?: () => void; onShowAll?: () => void;
showingAll?: boolean; showingAll?: boolean;
onDeleteAll?: () => void;
} }
type SortOption = 'date' | 'name' | 'size'; type SortOption = 'date' | 'name' | 'size';
@ -35,7 +36,8 @@ const FileGrid = ({
showSort = false, showSort = false,
maxDisplay, maxDisplay,
onShowAll, onShowAll,
showingAll = false showingAll = false,
onDeleteAll
}: FileGridProps) => { }: FileGridProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -70,30 +72,42 @@ const FileGrid = ({
return ( return (
<Box > <Box >
{/* Search and Sort Controls */} {/* Search and Sort Controls */}
{(showSearch || showSort) && ( {(showSearch || showSort || onDeleteAll) && (
<Group mb="md" justify="space-between" wrap="wrap" gap="sm"> <Group mb="md" justify="space-between" wrap="wrap" gap="sm">
{showSearch && ( <Group gap="sm">
<TextInput {showSearch && (
placeholder={t("fileManager.searchFiles", "Search files...")} <TextInput
leftSection={<SearchIcon size={16} />} placeholder={t("fileManager.searchFiles", "Search files...")}
value={searchTerm} leftSection={<SearchIcon size={16} />}
onChange={(e) => setSearchTerm(e.currentTarget.value)} value={searchTerm}
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }} onChange={(e) => setSearchTerm(e.currentTarget.value)}
/> style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
)} />
)}
{showSort && ( {showSort && (
<Select <Select
data={[ data={[
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") }, { value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") }, { value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") } { value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
]} ]}
value={sortBy} value={sortBy}
onChange={(value) => setSortBy(value as SortOption)} onChange={(value) => setSortBy(value as SortOption)}
leftSection={<SortIcon size={16} />} leftSection={<SortIcon size={16} />}
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
/> />
)}
</Group>
{onDeleteAll && (
<Button
color="red"
size="sm"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)} )}
</Group> </Group>
)} )}
@ -107,17 +121,18 @@ const FileGrid = ({
style={{ overflowY: "auto", width: "100%" }} style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((file, idx) => { {displayFiles.map((file, idx) => {
const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name)); const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
return ( return (
<FileCard <FileCard
key={file.id || file.name + idx} key={fileId + idx}
file={file} file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined} onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined} onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
onView={onView ? () => onView(file) : undefined} onView={onView ? () => onView(file) : undefined}
onEdit={onEdit ? () => onEdit(file) : undefined} onEdit={onEdit ? () => onEdit(file) : undefined}
isSelected={selectedFiles.includes(file.id || file.name)} isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect ? () => onSelect(file.id || file.name) : undefined} onSelect={onSelect ? () => onSelect(fileId) : undefined}
/> />
); );
})} })}

View File

@ -7,6 +7,7 @@ import { fileStorage } from '../../services/fileStorage';
import { FileWithUrl } from '../../types/file'; import { FileWithUrl } from '../../types/file';
import FileGrid from './FileGrid'; import FileGrid from './FileGrid';
import MultiSelectControls from './MultiSelectControls'; import MultiSelectControls from './MultiSelectControls';
import { useFileManager } from '../../hooks/useFileManager';
interface FileUploadSelectorProps { interface FileUploadSelectorProps {
// Appearance // Appearance
@ -45,23 +46,25 @@ const FileUploadSelector = ({
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Recent files state
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]); const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]); const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [showingAllRecent, setShowingAllRecent] = useState(false);
const [recentFilesLoading, setRecentFilesLoading] = useState(false);
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
if (uploadedFiles.length === 0) return; if (uploadedFiles.length === 0) return;
// Auto-save uploaded files to recent files
if (showRecentFiles) { if (showRecentFiles) {
try { try {
for (const file of uploadedFiles) { for (const file of uploadedFiles) {
await fileStorage.storeFile(file); await storeFile(file);
} }
// Refresh recent files list refreshRecentFiles();
loadRecentFiles();
} catch (error) { } catch (error) {
console.error('Failed to save files to recent:', error); console.error('Failed to save files to recent:', error);
} }
@ -72,66 +75,24 @@ const FileUploadSelector = ({
} else if (onFileSelect) { } else if (onFileSelect) {
onFileSelect(uploadedFiles[0]); onFileSelect(uploadedFiles[0]);
} }
}, [onFileSelect, onFilesSelect, showRecentFiles]); }, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
const fileArray = Array.from(files); const fileArray = Array.from(files);
console.log('File input change:', fileArray.length, 'files'); console.log('File input change:', fileArray.length, 'files');
handleFileUpload(fileArray); handleNewFileUpload(fileArray);
} }
// Reset input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}, [handleFileUpload]); }, [handleNewFileUpload]);
const openFileDialog = useCallback(() => { const openFileDialog = useCallback(() => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, []);
// Load recent files from storage
const loadRecentFiles = useCallback(async () => {
if (!showRecentFiles) return;
setRecentFilesLoading(true);
try {
const files = await fileStorage.getAllFiles();
// Sort by last modified date (newest first)
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
setRecentFiles(sortedFiles);
} catch (error) {
console.error('Failed to load recent files:', error);
setRecentFiles([]);
} finally {
setRecentFilesLoading(false);
}
}, [showRecentFiles]);
// Convert FileWithUrl to File for upload
const convertToFile = async (fileWithUrl: FileWithUrl): Promise<File> => {
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
const response = await fetch(fileWithUrl.url);
const data = await response.arrayBuffer();
return new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
}
// Load from IndexedDB
const storedFile = await fileStorage.getFile(fileWithUrl.id || fileWithUrl.name);
if (storedFile) {
return new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
}
throw new Error('File not found in storage');
};
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => { const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
try { try {
const fileObj = await convertToFile(file); const fileObj = await convertToFile(file);
@ -143,38 +104,27 @@ const FileUploadSelector = ({
} catch (error) { } catch (error) {
console.error('Failed to load file from recent:', error); console.error('Failed to load file from recent:', error);
} }
}, [onFileSelect, onFilesSelect]); }, [onFileSelect, onFilesSelect, convertToFile]);
const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
const handleSelectedRecentFiles = useCallback(async () => { const handleSelectedRecentFiles = useCallback(async () => {
if (selectedFiles.length === 0) return; if (onFilesSelect) {
await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
try {
const selectedFileObjects = recentFiles.filter(f => selectedFiles.includes(f.id || f.name));
const filePromises = selectedFileObjects.map(convertToFile);
const files = await Promise.all(filePromises);
if (onFilesSelect) {
onFilesSelect(files);
}
setSelectedFiles([]);
} catch (error) {
console.error('Failed to load selected files:', error);
} }
}, [selectedFiles, recentFiles, onFilesSelect]); }, [recentFiles, onFilesSelect, selectionHandlers]);
const toggleFileSelection = useCallback((fileId: string) => { const handleRemoveFileByIndex = useCallback(async (index: number) => {
setSelectedFiles(prev => await handleRemoveFile(index, recentFiles, setRecentFiles);
prev.includes(fileId) const file = recentFiles[index];
? prev.filter(id => id !== fileId) setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
: [...prev, fileId] }, [handleRemoveFile, recentFiles]);
);
}, []);
// Load recent files on mount
useEffect(() => { useEffect(() => {
loadRecentFiles(); if (showRecentFiles) {
}, [loadRecentFiles]); refreshRecentFiles();
}
}, [showRecentFiles, refreshRecentFiles]);
// Get default title and subtitle from translations if not provided // Get default title and subtitle from translations if not provided
const displayTitle = title || t("fileUpload.selectFiles", "Select files"); const displayTitle = title || t("fileUpload.selectFiles", "Select files");
@ -199,7 +149,7 @@ const FileUploadSelector = ({
{showDropzone ? ( {showDropzone ? (
<Dropzone <Dropzone
onDrop={handleFileUpload} onDrop={handleNewFileUpload}
accept={accept} accept={accept}
multiple={true} multiple={true}
disabled={disabled || loading} disabled={disabled || loading}
@ -256,32 +206,33 @@ const FileUploadSelector = ({
</Text> </Text>
<MultiSelectControls <MultiSelectControls
selectedCount={selectedFiles.length} selectedCount={selectedFiles.length}
onClearSelection={() => setSelectedFiles([])} onClearSelection={selectionHandlers.clearSelection}
onAddToUpload={handleSelectedRecentFiles} onAddToUpload={handleSelectedRecentFiles}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/> />
<FileGrid <FileGrid
files={recentFiles} files={recentFiles}
onDoubleClick={handleRecentFileSelection} onDoubleClick={handleRecentFileSelection}
onSelect={toggleFileSelection} onSelect={selectionHandlers.toggleSelection}
onRemove={handleRemoveFileByIndex}
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
maxDisplay={showingAllRecent ? undefined : maxRecentFiles} showSearch={true}
onShowAll={() => setShowingAllRecent(true)} showSort={true}
showingAll={showingAllRecent} onDeleteAll={async () => {
showSearch={showingAllRecent} await Promise.all(recentFiles.map(async (file) => {
showSort={showingAllRecent} await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/> />
{showingAllRecent && (
<Center mt="md">
<Button
variant="light"
onClick={() => setShowingAllRecent(false)}
>
{t("fileUpload.showLess", "Show Less")}
</Button>
</Center>
)}
</Box> </Box>
)} )}
</Stack> </Stack>

View File

@ -7,7 +7,8 @@ interface MultiSelectControlsProps {
onClearSelection: () => void; onClearSelection: () => void;
onOpenInFileEditor?: () => void; onOpenInFileEditor?: () => void;
onOpenInPageEditor?: () => void; onOpenInPageEditor?: () => void;
onAddToUpload?: () => void; // New action for recent files onAddToUpload?: () => void;
onDeleteAll?: () => void;
} }
const MultiSelectControls = ({ const MultiSelectControls = ({
@ -15,7 +16,8 @@ const MultiSelectControls = ({
onClearSelection, onClearSelection,
onOpenInFileEditor, onOpenInFileEditor,
onOpenInPageEditor, onOpenInPageEditor,
onAddToUpload onAddToUpload,
onDeleteAll
}: MultiSelectControlsProps) => { }: MultiSelectControlsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -67,6 +69,16 @@ const MultiSelectControls = ({
{t("fileManager.openInPageEditor", "Open in Page Editor")} {t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button> </Button>
)} )}
{onDeleteAll && (
<Button
size="xs"
color="red"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
</Group> </Group>
</Group> </Group>
</Box> </Box>

View File

@ -97,9 +97,10 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
}; };
case 'REMOVE_FILES': case 'REMOVE_FILES':
const remainingFiles = state.activeFiles.filter(file => const remainingFiles = state.activeFiles.filter(file => {
!action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs const fileId = (file as any).id || file.name;
); return !action.payload.includes(fileId);
});
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
return { return {
...state, ...state,
@ -496,7 +497,14 @@ export function FileContextProvider({
if (enablePersistence) { if (enablePersistence) {
for (const file of files) { for (const file of files) {
try { try {
await fileStorage.storeFile(file); // Check if file already has an ID (already in IndexedDB)
const fileId = (file as any).id;
if (!fileId) {
// File doesn't have ID, store it and get the ID
const storedFile = await fileStorage.storeFile(file);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
}
} catch (error) { } catch (error) {
console.error('Failed to store file:', error); console.error('Failed to store file:', error);
} }
@ -518,7 +526,7 @@ export function FileContextProvider({
if (enablePersistence && deleteFromStorage) { if (enablePersistence && deleteFromStorage) {
fileIds.forEach(async (fileId) => { fileIds.forEach(async (fileId) => {
try { try {
await fileStorage.removeFile(fileId); await fileStorage.deleteFile(fileId);
} catch (error) { } catch (error) {
console.error('Failed to remove file from storage:', error); console.error('Failed to remove file from storage:', error);
} }
@ -671,7 +679,10 @@ export function FileContextProvider({
// Utility functions // Utility functions
const getFileById = useCallback((fileId: string): File | undefined => { const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => file.name === fileId); // Simple ID matching return state.activeFiles.find(file => {
const actualFileId = (file as any).id || file.name;
return actualFileId === fileId;
});
}, [state.activeFiles]); }, [state.activeFiles]);
const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => { const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => {

View File

@ -0,0 +1,122 @@
import { useState, useCallback } from 'react';
import { fileStorage } from '../services/fileStorage';
import { FileWithUrl } from '../types/file';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
const response = await fetch(fileWithUrl.url);
const data = await response.arrayBuffer();
const file = new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
// Preserve the ID if it exists
if (fileWithUrl.id) {
Object.defineProperty(file, 'id', { value: fileWithUrl.id, writable: false });
}
return file;
}
// Always use ID first, fallback to name only if ID doesn't exist
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
});
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return file;
}
throw new Error('File not found in storage');
}, []);
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
setLoading(true);
try {
const files = await fileStorage.getAllFiles();
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles;
} catch (error) {
console.error('Failed to load recent files:', error);
return [];
} finally {
setLoading(false);
}
}, []);
const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => {
const file = files[index];
try {
await fileStorage.deleteFile(file.id || file.name);
setFiles(files.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
throw error;
}
}, []);
const storeFile = useCallback(async (file: File) => {
try {
const storedFile = await fileStorage.storeFile(file);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return storedFile;
} catch (error) {
console.error('Failed to store file:', error);
throw error;
}
}, []);
const createFileSelectionHandlers = useCallback((
selectedFiles: string[],
setSelectedFiles: (files: string[]) => void
) => {
const toggleSelection = (fileId: string) => {
setSelectedFiles(
selectedFiles.includes(fileId)
? selectedFiles.filter(id => id !== fileId)
: [...selectedFiles, fileId]
);
};
const clearSelection = () => {
setSelectedFiles([]);
};
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
if (selectedFiles.length === 0) return;
try {
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
const filePromises = selectedFileObjects.map(convertToFile);
const convertedFiles = await Promise.all(filePromises);
onFilesSelect(convertedFiles);
clearSelection();
} catch (error) {
console.error('Failed to load selected files:', error);
throw error;
}
};
return {
toggleSelection,
clearSelection,
selectMultipleFiles
};
}, [convertToFile]);
return {
loading,
convertToFile,
loadRecentFiles,
handleRemoveFile,
storeFile,
createFileSelectionHandlers
};
};