Reece Browne 28c5e675ac bug fixes
2025-08-20 12:53:02 +01:00

756 lines
27 KiB
TypeScript

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, useProcessedFiles, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
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 FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
file: File;
splitBefore?: boolean;
}
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 } = useFileManagement();
const processedFiles = useProcessedFiles(); // Now gets real processed files
// 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();
// Create a stable ref to access current selected files and actions without dependency
const selectedFileIdsRef = useRef<string[]>([]);
const actionsRef = useRef(actions);
selectedFileIdsRef.current = selectedFileIds;
actionsRef.current = actions;
// Legacy compatibility for existing code - now actually updates context (completely stable)
const setContextSelectedFiles = useCallback((fileIds: string[] | ((prev: string[]) => string[])) => {
if (typeof fileIds === 'function') {
// Handle callback pattern - get current state from ref
const result = fileIds(selectedFileIdsRef.current);
actionsRef.current.setSelectedFiles(result);
} else {
// Handle direct array pattern
actionsRef.current.setSelectedFiles(fileIds);
}
}, []); // No dependencies at all - completely stable
const setCurrentView = (mode: any) => {
// Will be handled by parent component actions
console.log('FileEditor setCurrentView called with:', mode);
};
// Get tool file selection context (replaces FileSelectionContext)
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useToolFileSelection();
const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
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 [conversionProgress, setConversionProgress] = useState(0);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
progress: number;
extractedCount: number;
totalFiles: number;
}>({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(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<string[]>([]);
const filesDataRef = useRef<any[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
filesDataRef.current = files;
// Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => {
// file.id is already the correct UUID from FileContext
return contextSelectedIds.includes(file.id);
})
.map(file => file.id);
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Use processed data if available, otherwise fallback to legacy approach
const thumbnail = sharedFile.thumbnail || sharedFile.thumbnailUrl ||
(await generateThumbnailForFile(sharedFile.file || sharedFile));
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
pageCount: sharedFile.processedFile?.totalPages || sharedFile.pageCount || 1,
thumbnail: thumbnail || '',
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
};
}, []);
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileIds = activeFileRecords.map(r => r.id);
const currentProcessedFilesSize = processedFiles.processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileIds) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
return;
}
// Update refs
lastActiveFilesRef.current = currentActiveFileIds;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFileRecords.length > 0) {
setLocalLoading(true);
try {
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFileRecords.length; i++) {
const record = activeFileRecords[i];
const file = selectors.getFile(record.id);
if (!file) continue; // Skip if file not found
// Use processed data from centralized file processing service
const thumbnail = record.thumbnailUrl; // Already processed by FileProcessingService
const pageCount = record.processedFile?.totalPages || 1; // Use processed page count
console.log(`📄 FileEditor: Using processed data for ${file.name}: ${pageCount} pages, thumbnail: ${!!thumbnail}`);
const convertedFile = {
id: record.id, // Use the record's UUID from FileContext
name: file.name,
pageCount: pageCount,
thumbnail: thumbnail || '',
size: file.size,
file,
};
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFileRecords.length) * 100);
// Yield to main thread between files
if (i < activeFileRecords.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLocalLoading(false);
setConversionProgress(0);
}
} else {
setFiles([]);
setLocalLoading(false);
setConversionProgress(0);
}
};
convertActiveFiles();
}, [activeFileRecords, processedFiles, 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
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
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 {
console.log(`Adding none PDF file: ${file.name} (${file.type})`);
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(files.map(f => f.id)); // Use FileEditor file IDs which are now correct UUIDs
}, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
// Clear selections
setContextSelectedFiles([]);
}, [activeFileRecords, removeFiles, setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
const currentFiles = filesDataRef.current;
const currentSelectedIds = contextSelectedIdsRef.current;
const targetFile = currentFiles.find(f => f.id === fileId);
if (!targetFile) 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]); // Removed changing dependencies
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[]) => {
setFiles(prevFiles => {
const newFiles = [...prevFiles];
// Find original source and target indices
const sourceIndex = newFiles.findIndex(f => f.id === sourceFileId);
const targetIndex = newFiles.findIndex(f => f.id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering');
return prevFiles;
}
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.map(id => newFiles.find(f => f.id === id)!).filter(Boolean)
: [newFiles[sourceIndex]];
// Calculate the correct target position before removing files
let insertIndex = targetIndex;
// If we're moving forward (right), we need to adjust for the files we're removing
const sourceIndices = filesToMove.map(f => newFiles.findIndex(nf => nf.id === f.id));
const minSourceIndex = Math.min(...sourceIndices);
if (minSourceIndex < targetIndex) {
// Moving forward: target moves left by the number of files we're removing before it
const filesBeforeTarget = sourceIndices.filter(idx => idx < targetIndex).length;
insertIndex = targetIndex - filesBeforeTarget + 1; // +1 to insert after target
}
// Remove files to move from their current positions (in reverse order to maintain indices)
sourceIndices
.sort((a, b) => b - a) // Sort descending to remove from end first
.forEach(index => {
newFiles.splice(index, 1);
});
// Insert files at the calculated position
newFiles.splice(insertIndex, 0, ...filesToMove);
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
return newFiles;
});
}, [setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: string) => {
console.log('handleDeleteFile called with fileId:', fileId);
const file = files.find(f => f.id === fileId);
console.log('Found file:', file);
if (file) {
console.log('Attempting to remove file:', file.name);
console.log('Actual file object:', file.file);
console.log('Actual file.file.name:', file.file.name);
// Record close operation
const fileName = file.file.name;
const contextFileId = file.id; // Use the correct file ID (UUID from FileContext)
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: file.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [contextFileId]);
removeFiles([contextFileId], false);
// Remove from context selections
setContextSelectedFiles((prev: string[]) => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== contextFileId);
});
} else {
console.log('File not found for fileId:', fileId);
}
}, [files, removeFiles, setContextSelectedFiles]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file) {
// Set the file as selected in context and switch to viewer for preview
const contextFileId = file.id; // Use the correct file ID (UUID from FileContext)
setContextSelectedFiles([contextFileId]);
setCurrentView('viewer');
}
}, [files, setContextSelectedFiles, setCurrentView]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLocalLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
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');
} finally {
setLocalLoading(false);
}
}, [convertToFileItem]);
return (
<Dropzone
onDrop={handleFileUpload}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
height: '100vh',
border: 'none',
borderRadius: 0,
backgroundColor: 'transparent'
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Group mb="md">
{showBulkActions && !toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
</>
)}
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.currentFile || 'Processing files...'}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(zipExtractionProgress.progress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-orange-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
{/* Processing indicator */}
{localLoading && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Loading files...</Text>
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(conversionProgress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{files.map((file, index) => (
<FileThumbnail
key={file.id}
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
))}
</div>
)}
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
</Dropzone>
);
};
export default FileEditor;