mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
File management tweaks
This commit is contained in:
parent
55473b4f25
commit
37e2e61d6f
@ -5,7 +5,8 @@
|
|||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(./gradlew:*)",
|
"Bash(./gradlew:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(cat:*)"
|
"Bash(cat:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
@ -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()) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 => {
|
||||||
|
122
frontend/src/hooks/useFileManager.ts
Normal file
122
frontend/src/hooks/useFileManager.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user