import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useToolFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { useNavigationActions } from '../../contexts/NavigationContext'; import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; import styles from '../pageEditor/PageEditor.module.css'; import FileThumbnail from '../pageEditor/FileThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; interface FileEditorProps { onOpenPageEditor?: (file: File) => void; onMergeFiles?: (files: File[]) => void; toolMode?: boolean; showUpload?: boolean; showBulkActions?: boolean; supportedExtensions?: string[]; } const FileEditor = ({ onOpenPageEditor, onMergeFiles, toolMode = false, showUpload = true, showBulkActions = true, supportedExtensions = ["pdf"] }: FileEditorProps) => { const { t } = useTranslation(); // Utility function to check if a file extension is supported const isFileSupported = useCallback((fileName: string): boolean => { const extension = detectFileExtension(fileName); return extension ? supportedExtensions.includes(extension) : false; }, [supportedExtensions]); // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); // Extract needed values from state (memoized to prevent infinite loops) const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; // Get the real context actions const { actions } = useFileActions(); const { actions: navActions } = useNavigationActions(); // Proper context-based file selection updater const setContextSelectedFiles = useCallback((fileIds: string[] | ((prev: string[]) => string[])) => { if (typeof fileIds === 'function') { // Handle callback pattern with current state const result = fileIds(selectedFileIds); actions.setSelectedFiles(result); } else { // Handle direct array pattern actions.setSelectedFiles(fileIds); } }, [selectedFileIds, actions.setSelectedFiles]); // Get tool file selection context (replaces FileSelectionContext) const { selectedFiles: toolSelectedFiles, setSelectedFiles: setToolSelectedFiles, maxFiles, isToolMode } = useToolFileSelection(); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode React.useEffect(() => { if (toolMode) { setSelectionMode(true); } }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); const [zipExtractionProgress, setZipExtractionProgress] = useState<{ isExtracting: boolean; currentFile: string; progress: number; extractedCount: number; totalFiles: number; }>({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; // Create refs for frequently changing values to stabilize callbacks const contextSelectedIdsRef = useRef([]); contextSelectedIdsRef.current = contextSelectedIds; // Use activeFileRecords directly - no conversion needed const localSelectedIds = contextSelectedIds; // Helper to convert FileRecord to FileThumbnail format const recordToFileItem = useCallback((record: any) => { const file = selectors.getFile(record.id); if (!file) return null; return { id: record.id, name: file.name, pageCount: record.processedFile?.totalPages || 1, thumbnail: record.thumbnailUrl || '', size: file.size, file: file }; }, [selectors]); // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { setError(null); try { const allExtractedFiles: File[] = []; const errors: string[] = []; for (const file of uploadedFiles) { if (file.type === 'application/pdf') { // Handle PDF files normally allExtractedFiles.push(file); } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { // Handle ZIP files - only expand if they contain PDFs try { // Validate ZIP file first const validation = await zipFileService.validateZipFile(file); if (validation.isValid && validation.containsPDFs) { // ZIP contains PDFs - extract them setZipExtractionProgress({ isExtracting: true, currentFile: file.name, progress: 0, extractedCount: 0, totalFiles: validation.fileCount }); const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { setZipExtractionProgress({ isExtracting: true, currentFile: progress.currentFile, progress: progress.progress, extractedCount: progress.extractedCount, totalFiles: progress.totalFiles }); }); // Reset extraction progress setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); if (extractionResult.success) { allExtractedFiles.push(...extractionResult.extractedFiles); // Record ZIP extraction operation const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'convert', timestamp: Date.now(), fileIds: extractionResult.extractedFiles.map(f => f.name), status: 'pending', metadata: { originalFileName: file.name, outputFileNames: extractionResult.extractedFiles.map(f => f.name), fileSize: file.size, parameters: { extractionType: 'zip', extractedCount: extractionResult.extractedCount, totalFiles: extractionResult.totalFiles } } }; if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } } else { errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); } } else { // ZIP doesn't contain PDFs or is invalid - treat as regular file allExtractedFiles.push(file); } } catch (zipError) { errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); } } else { allExtractedFiles.push(file); } } // Show any errors if (errors.length > 0) { setError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Record upload operations for PDF files for (const file of allExtractedFiles) { const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'upload', timestamp: Date.now(), fileIds: [file.name], status: 'pending', metadata: { originalFileName: file.name, fileSize: file.size, parameters: { uploadMethod: 'drag-drop' } } }; } // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); setStatus(`Added ${allExtractedFiles.length} files`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; setError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); } }, [addFiles]); const selectAll = useCallback(() => { setContextSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly }, [activeFileRecords, setContextSelectedFiles]); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); const closeAllFiles = useCallback(() => { if (activeFileRecords.length === 0) return; // Remove all files from context but keep in storage const allFileIds = activeFileRecords.map(record => record.id); removeFiles(allFileIds, false); // false = keep in storage // Clear selections setContextSelectedFiles([]); }, [activeFileRecords, removeFiles, setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { const currentSelectedIds = contextSelectedIdsRef.current; const targetRecord = activeFileRecords.find(r => r.id === fileId); if (!targetRecord) return; const contextFileId = fileId; // No need to create a new ID const isSelected = currentSelectedIds.includes(contextFileId); let newSelection: string[]; if (isSelected) { // Remove file from selection newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection if (maxFiles === 1) { newSelection = [contextFileId]; } else { // Check if we've hit the selection limit if (maxFiles > 1 && currentSelectedIds.length >= maxFiles) { setStatus(`Maximum ${maxFiles} files can be selected`); return; } newSelection = [...currentSelectedIds, contextFileId]; } } // Update context setContextSelectedFiles(newSelection); // Update tool selection context if in tool mode if (isToolMode || toolMode) { setToolSelectedFiles(newSelection); } }, [setContextSelectedFiles, maxFiles, setStatus, isToolMode, toolMode, setToolSelectedFiles, activeFileRecords]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { setContextSelectedFiles([]); } return newMode; }); }, [setContextSelectedFiles]); // File reordering handler for drag and drop const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => { const currentIds = activeFileRecords.map(r => r.id); // Find indices const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const targetIndex = currentIds.findIndex(id => id === targetFileId); if (sourceIndex === -1 || targetIndex === -1) { console.warn('Could not find source or target file for reordering'); return; } // Handle multi-file selection reordering const filesToMove = selectedFileIds.length > 1 ? selectedFileIds.filter(id => currentIds.includes(id)) : [sourceFileId]; // Create new order const newOrder = [...currentIds]; // Remove files to move from their current positions (in reverse order to maintain indices) const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) .sort((a, b) => b - a); // Sort descending sourceIndices.forEach(index => { newOrder.splice(index, 1); }); // Calculate insertion index after removals let insertIndex = newOrder.findIndex(id => id === targetFileId); if (insertIndex !== -1) { // Determine if moving forward or backward const isMovingForward = sourceIndex < targetIndex; if (isMovingForward) { // Moving forward: insert after target insertIndex += 1; } else { // Moving backward: insert before target (insertIndex already correct) } } else { // Target was moved, insert at end insertIndex = newOrder.length; } // Insert files at the calculated position newOrder.splice(insertIndex, 0, ...filesToMove); // Update file order reorderFiles(newOrder); // Update status const moveCount = filesToMove.length; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); }, [activeFileRecords, reorderFiles, setStatus]); // File operations using context const handleDeleteFile = useCallback((fileId: string) => { const record = activeFileRecords.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { // Record close operation const fileName = file.name; const contextFileId = record.id; const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'remove', timestamp: Date.now(), fileIds: [fileName], status: 'pending', metadata: { originalFileName: fileName, fileSize: record.size, parameters: { action: 'close', reason: 'user_request' } } }; // Remove file from context but keep in storage (close, don't delete) removeFiles([contextFileId], false); // Remove from context selections setContextSelectedFiles((prev: string[]) => { const safePrev = Array.isArray(prev) ? prev : []; return safePrev.filter(id => id !== contextFileId); }); } }, [activeFileRecords, selectors, removeFiles, setContextSelectedFiles]); const handleViewFile = useCallback((fileId: string) => { const record = activeFileRecords.find(r => r.id === fileId); if (record) { // Set the file as selected in context and switch to viewer for preview setContextSelectedFiles([fileId]); navActions.setMode('viewer'); } }, [activeFileRecords, setContextSelectedFiles, navActions.setMode]); const handleMergeFromHere = useCallback((fileId: string) => { const startIndex = activeFileRecords.findIndex(r => r.id === fileId); if (startIndex === -1) return; const recordsToMerge = activeFileRecords.slice(startIndex); const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[]; if (onMergeFiles) { onMergeFiles(filesToMerge); } }, [activeFileRecords, selectors, onMergeFiles]); const handleSplitFile = useCallback((fileId: string) => { const file = selectors.getFile(fileId); if (file && onOpenPageEditor) { onOpenPageEditor(file); } }, [selectors, onOpenPageEditor]); const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { if (selectedFiles.length === 0) return; try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files setStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); setError('Failed to load some files from storage'); } }, []); return ( {toolMode && ( <> )} {showBulkActions && !toolMode && ( <> )} {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
📁 No files loaded Upload PDF files, ZIP archives, or load from storage to get started
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( {/* ZIP Extraction Progress */} {zipExtractionProgress.isExtracting && ( Extracting ZIP archive... {Math.round(zipExtractionProgress.progress)}% {zipExtractionProgress.currentFile || 'Processing files...'} {zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
)} ) : (
{activeFileRecords.map((record, index) => { const fileItem = recordToFileItem(record); if (!fileItem) return null; return ( ); })}
)} {/* File Picker Modal */} setShowFilePickerModal(false)} storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent onSelectFiles={handleLoadFromStorage} /> {status && ( setStatus(null)} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} > {status} )} {error && ( setError(null)} style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }} > {error} )} ); }; export default FileEditor;