diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e006423a..9afb72a4d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(mkdir:*)", "Bash(./gradlew:*)", "Bash(grep:*)", - "Bash(cat:*)" + "Bash(cat:*)", + "Bash(find:*)" ], "deny": [] } diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java index 219ae4ac4..902b2bfd1 100644 --- a/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -1,6 +1,5 @@ package stirling.software.common.service; -import io.github.pixee.security.ZipSecurity; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -21,6 +20,8 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import io.github.pixee.security.ZipSecurity; + import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @@ -361,7 +362,8 @@ public class TaskManager { MultipartFile zipFile = fileStorage.retrieveFile(zipFileId); try (ZipInputStream zipIn = - ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) { + ZipSecurity.createHardenedInputStream( + new ByteArrayInputStream(zipFile.getBytes()))) { ZipEntry entry; while ((entry = zipIn.getNextEntry()) != null) { if (!entry.isDirectory()) { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index f1f7c682f..c0badafd8 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -104,7 +104,10 @@ const FileEditor = ({ const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; 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); // Convert shared files to FileEditor format @@ -350,7 +353,7 @@ const FileEditor = ({ }, [addFiles, recordOperation, markOperationApplied]); 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]); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); @@ -382,18 +385,21 @@ const FileEditor = ({ }); // 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 setContextSelectedFiles([]); }, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); 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) { // Single select mode for tools - toggle on/off - const isCurrentlySelected = safeSelectedFileIds.includes(fileName); + const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); if (isCurrentlySelected) { // Deselect the file setContextSelectedFiles([]); @@ -402,25 +408,27 @@ const FileEditor = ({ } } else { // Select the file - setContextSelectedFiles([fileName]); - const selectedFile = files.find(f => f.id === fileId)?.file; - if (selectedFile && onFileSelect) { - onFileSelect([selectedFile]); + setContextSelectedFiles([contextFileId]); + if (onFileSelect) { + onFileSelect([targetFile.file]); } } } else { // Multi select mode (default) setContextSelectedFiles(prev => { const safePrev = Array.isArray(prev) ? prev : []; - return safePrev.includes(fileName) - ? safePrev.filter(id => id !== fileName) - : [...safePrev, fileName]; + return safePrev.includes(contextFileId) + ? safePrev.filter(id => id !== contextFileId) + : [...safePrev, contextFileId]; }); // Notify parent with selected files if (onFileSelect) { 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); onFileSelect(selectedFiles); } @@ -557,16 +565,17 @@ const FileEditor = ({ console.log('Actual file.file.name:', file.file.name); // 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 operation: FileOperation = { id: operationId, type: 'remove', timestamp: Date.now(), - fileIds: [actualFileName], + fileIds: [fileName], status: 'pending', metadata: { - originalFileName: actualFileName, + originalFileName: fileName, fileSize: file.size, parameters: { 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) - // Use the actual file name (with extension) not the display name - console.log('Calling removeFiles with:', [actualFileName]); - removeFiles([actualFileName], false); + console.log('Calling removeFiles with:', [fileId]); + removeFiles([fileId], false); // Remove from context selections setContextSelectedFiles(prev => { const safePrev = Array.isArray(prev) ? prev : []; - return safePrev.filter(id => id !== actualFileName); + return safePrev.filter(id => id !== fileId); }); // Mark operation as applied - markOperationApplied(actualFileName, operationId); + markOperationApplied(fileName, operationId); } else { console.log('File not found for fileId:', fileId); } @@ -599,7 +607,8 @@ const FileEditor = ({ const file = files.find(f => f.id === fileId); if (file) { // 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'); onOpenPageEditor?.(file.file); } diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/fileManagement/FileCard.tsx index 4b090f0d7..f0972356b 100644 --- a/frontend/src/components/fileManagement/FileCard.tsx +++ b/frontend/src/components/fileManagement/FileCard.tsx @@ -8,6 +8,7 @@ import EditIcon from "@mui/icons-material/Edit"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; +import { fileStorage } from "../../services/fileStorage"; interface FileCardProps { file: FileWithUrl; diff --git a/frontend/src/components/fileManagement/FileManager.tsx b/frontend/src/components/fileManagement/FileManager.tsx deleted file mode 100644 index 45bf95b5b..000000000 --- a/frontend/src/components/fileManagement/FileManager.tsx +++ /dev/null @@ -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>; - 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(null); - const [notification, setNotification] = useState(null); - const [filesLoaded, setFilesLoaded] = useState(false); - const [selectedFiles, setSelectedFiles] = useState([]); - const [storageConfig, setStorageConfig] = useState(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 => { - // 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 ( -
- - {/* File upload is now handled by FileUploadSelector when no files exist */} - - {/* Storage Stats Card */} - - - {/* Multi-selection controls */} - {selectedFiles.length > 0 && ( - - - - {selectedFiles.length} {t("fileManager.filesSelected", "files selected")} - - - - - - - - - )} - - - - {files.map((file, idx) => ( - handleRemoveFile(idx)} - onDoubleClick={() => handleFileDoubleClick(file)} - onView={() => handleFileView(file)} - onEdit={() => handleFileEdit(file)} - isSelected={selectedFiles.includes(file.id || file.name)} - onSelect={() => toggleFileSelection(file.id || file.name)} - /> - ))} - - - - {/* Notifications */} - {notification && ( - setNotification(null)} - style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }} - > - {notification} - - )} -
- ); -}; - -export default FileManager; diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index c4d9b951b..92b47ee30 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -19,6 +19,7 @@ interface FileGridProps { maxDisplay?: number; // If set, shows only this many files with "Show All" option onShowAll?: () => void; showingAll?: boolean; + onDeleteAll?: () => void; } type SortOption = 'date' | 'name' | 'size'; @@ -35,7 +36,8 @@ const FileGrid = ({ showSort = false, maxDisplay, onShowAll, - showingAll = false + showingAll = false, + onDeleteAll }: FileGridProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(""); @@ -70,30 +72,42 @@ const FileGrid = ({ return ( {/* Search and Sort Controls */} - {(showSearch || showSort) && ( + {(showSearch || showSort || onDeleteAll) && ( - {showSearch && ( - } - value={searchTerm} - onChange={(e) => setSearchTerm(e.currentTarget.value)} - style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }} - /> - )} + + {showSearch && ( + } + value={searchTerm} + onChange={(e) => setSearchTerm(e.currentTarget.value)} + style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }} + /> + )} - {showSort && ( - setSortBy(value as SortOption)} + leftSection={} + style={{ minWidth: 150 }} + /> + )} + + + {onDeleteAll && ( + )} )} @@ -107,17 +121,18 @@ const FileGrid = ({ style={{ overflowY: "auto", width: "100%" }} > {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 ( onRemove(originalIdx) : undefined} onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined} onView={onView ? () => onView(file) : undefined} onEdit={onEdit ? () => onEdit(file) : undefined} - isSelected={selectedFiles.includes(file.id || file.name)} - onSelect={onSelect ? () => onSelect(file.id || file.name) : undefined} + isSelected={selectedFiles.includes(fileId)} + onSelect={onSelect ? () => onSelect(fileId) : undefined} /> ); })} diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx index 40e04edcc..e3af1d3ae 100644 --- a/frontend/src/components/shared/FileUploadSelector.tsx +++ b/frontend/src/components/shared/FileUploadSelector.tsx @@ -7,6 +7,7 @@ import { fileStorage } from '../../services/fileStorage'; import { FileWithUrl } from '../../types/file'; import FileGrid from './FileGrid'; import MultiSelectControls from './MultiSelectControls'; +import { useFileManager } from '../../hooks/useFileManager'; interface FileUploadSelectorProps { // Appearance @@ -45,23 +46,25 @@ const FileUploadSelector = ({ const { t } = useTranslation(); const fileInputRef = useRef(null); - // Recent files state const [recentFiles, setRecentFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState([]); - 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; - // Auto-save uploaded files to recent files if (showRecentFiles) { try { for (const file of uploadedFiles) { - await fileStorage.storeFile(file); + await storeFile(file); } - // Refresh recent files list - loadRecentFiles(); + refreshRecentFiles(); } catch (error) { console.error('Failed to save files to recent:', error); } @@ -72,66 +75,24 @@ const FileUploadSelector = ({ } else if (onFileSelect) { onFileSelect(uploadedFiles[0]); } - }, [onFileSelect, onFilesSelect, showRecentFiles]); + }, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]); const handleFileInputChange = useCallback((event: React.ChangeEvent) => { const files = event.target.files; if (files && files.length > 0) { const fileArray = Array.from(files); console.log('File input change:', fileArray.length, 'files'); - handleFileUpload(fileArray); + handleNewFileUpload(fileArray); } - // Reset input if (fileInputRef.current) { fileInputRef.current.value = ''; } - }, [handleFileUpload]); + }, [handleNewFileUpload]); const openFileDialog = useCallback(() => { 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 => { - 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) => { try { const fileObj = await convertToFile(file); @@ -143,38 +104,27 @@ const FileUploadSelector = ({ } catch (error) { console.error('Failed to load file from recent:', error); } - }, [onFileSelect, onFilesSelect]); + }, [onFileSelect, onFilesSelect, convertToFile]); + + const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles); const handleSelectedRecentFiles = useCallback(async () => { - if (selectedFiles.length === 0) return; - - 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); + if (onFilesSelect) { + await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect); } - }, [selectedFiles, recentFiles, onFilesSelect]); + }, [recentFiles, onFilesSelect, selectionHandlers]); - const toggleFileSelection = useCallback((fileId: string) => { - setSelectedFiles(prev => - prev.includes(fileId) - ? prev.filter(id => id !== fileId) - : [...prev, fileId] - ); - }, []); + const handleRemoveFileByIndex = useCallback(async (index: number) => { + await handleRemoveFile(index, recentFiles, setRecentFiles); + const file = recentFiles[index]; + setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name))); + }, [handleRemoveFile, recentFiles]); - // Load recent files on mount useEffect(() => { - loadRecentFiles(); - }, [loadRecentFiles]); + if (showRecentFiles) { + refreshRecentFiles(); + } + }, [showRecentFiles, refreshRecentFiles]); // Get default title and subtitle from translations if not provided const displayTitle = title || t("fileUpload.selectFiles", "Select files"); @@ -199,7 +149,7 @@ const FileUploadSelector = ({ {showDropzone ? ( setSelectedFiles([])} + onClearSelection={selectionHandlers.clearSelection} onAddToUpload={handleSelectedRecentFiles} + onDeleteAll={async () => { + await Promise.all(recentFiles.map(async (file) => { + await fileStorage.deleteFile(file.id || file.name); + })); + setRecentFiles([]); + setSelectedFiles([]); + }} /> setShowingAllRecent(true)} - showingAll={showingAllRecent} - showSearch={showingAllRecent} - showSort={showingAllRecent} + showSearch={true} + showSort={true} + onDeleteAll={async () => { + await Promise.all(recentFiles.map(async (file) => { + await fileStorage.deleteFile(file.id || file.name); + })); + setRecentFiles([]); + setSelectedFiles([]); + }} /> - - {showingAllRecent && ( -
- -
- )}
)} diff --git a/frontend/src/components/shared/MultiSelectControls.tsx b/frontend/src/components/shared/MultiSelectControls.tsx index 69fca71b2..11790abf8 100644 --- a/frontend/src/components/shared/MultiSelectControls.tsx +++ b/frontend/src/components/shared/MultiSelectControls.tsx @@ -7,7 +7,8 @@ interface MultiSelectControlsProps { onClearSelection: () => void; onOpenInFileEditor?: () => void; onOpenInPageEditor?: () => void; - onAddToUpload?: () => void; // New action for recent files + onAddToUpload?: () => void; + onDeleteAll?: () => void; } const MultiSelectControls = ({ @@ -15,7 +16,8 @@ const MultiSelectControls = ({ onClearSelection, onOpenInFileEditor, onOpenInPageEditor, - onAddToUpload + onAddToUpload, + onDeleteAll }: MultiSelectControlsProps) => { const { t } = useTranslation(); @@ -67,6 +69,16 @@ const MultiSelectControls = ({ {t("fileManager.openInPageEditor", "Open in Page Editor")} )} + + {onDeleteAll && ( + + )} diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index adf2cc214..811a49db7 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -97,9 +97,10 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): }; case 'REMOVE_FILES': - const remainingFiles = state.activeFiles.filter(file => - !action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs - ); + const remainingFiles = state.activeFiles.filter(file => { + const fileId = (file as any).id || file.name; + return !action.payload.includes(fileId); + }); const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; return { ...state, @@ -496,7 +497,14 @@ export function FileContextProvider({ if (enablePersistence) { for (const file of files) { 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) { console.error('Failed to store file:', error); } @@ -518,7 +526,7 @@ export function FileContextProvider({ if (enablePersistence && deleteFromStorage) { fileIds.forEach(async (fileId) => { try { - await fileStorage.removeFile(fileId); + await fileStorage.deleteFile(fileId); } catch (error) { console.error('Failed to remove file from storage:', error); } @@ -671,7 +679,10 @@ export function FileContextProvider({ // Utility functions 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]); const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => { diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts new file mode 100644 index 000000000..d8e776f75 --- /dev/null +++ b/frontend/src/hooks/useFileManager.ts @@ -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 => { + 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 => { + 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 + }; +}; \ No newline at end of file