From 83b8c9be09d54eaa1bf0fd7d4376f174bb14c565 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 10 Jul 2025 17:51:50 +0100 Subject: [PATCH] feat: Enhance PDF splitting functionality and operation history tracking - Improved the useEnhancedProcessedFiles hook to only update processed files when content changes. - Added preview functionality for split PDF files in the HomePage component. - Implemented a new PDFExportService with enhanced error handling and file validation. - Updated Split tool to generate and display thumbnails for split PDF files. - Introduced a comprehensive FileOperationHistory component to track and display file operations. - Enhanced file context types to support new operation types and history management. - Created a ZipFileService for validating and extracting PDF files from ZIP archives. --- frontend/package-lock.json | 76 +++- frontend/package.json | 1 + .../src/components/fileEditor/FileEditor.tsx | 365 +++++++++++++++--- .../history/FileOperationHistory.tsx | 177 +++++++++ .../components/pageEditor/FileThumbnail.tsx | 48 ++- .../components/shared/FileUploadSelector.tsx | 6 +- .../src/components/shared/TopControls.tsx | 4 +- .../src/components/tools/ToolRenderer.tsx | 3 + frontend/src/components/viewer/Viewer.tsx | 144 +++++-- frontend/src/contexts/FileContext.tsx | 134 ++++++- .../src/hooks/useEnhancedProcessedFiles.ts | 7 +- frontend/src/pages/HomePage.tsx | 18 + frontend/src/services/pdfExportService.ts | 38 +- frontend/src/services/zipFileService.ts | 300 ++++++++++++++ frontend/src/tools/Split.tsx | 305 ++++++++++++--- frontend/src/types/fileContext.ts | 31 +- frontend/src/types/pageEditor.ts | 13 +- 17 files changed, 1496 insertions(+), 174 deletions(-) create mode 100644 frontend/src/components/history/FileOperationHistory.tsx create mode 100644 frontend/src/services/zipFileService.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e33392bc..6c19c7632 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "jszip": "^3.10.1", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", @@ -2689,6 +2690,11 @@ "node": ">=18" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -3450,6 +3456,11 @@ "cross-fetch": "4.0.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3491,8 +3502,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/is-arrayish": { "version": "0.2.1", @@ -3571,6 +3581,11 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -3630,6 +3645,52 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -4729,6 +4790,11 @@ "node": ">= 0.8" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5237,6 +5303,11 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5697,7 +5768,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, "license": "MIT" }, "node_modules/vite": { diff --git a/frontend/package.json b/frontend/package.json index fa7a0b5d2..38dfbf56e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "jszip": "^3.10.1", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 2988799f7..bdf8bd961 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -7,8 +7,10 @@ import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useFileContext } from '../../contexts/FileContext'; +import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; +import { zipFileService } from '../../services/zipFileService'; import styles from '../pageEditor/PageEditor.module.css'; import FileThumbnail from '../pageEditor/FileThumbnail'; import DragDropGrid from '../pageEditor/DragDropGrid'; @@ -56,7 +58,9 @@ const FileEditor = ({ isProcessing, addFiles, removeFiles, - setCurrentView + setCurrentView, + recordOperation, + markOperationApplied } = fileContext; const [files, setFiles] = useState([]); @@ -78,11 +82,29 @@ const FileEditor = ({ const [isAnimating, setIsAnimating] = useState(false); 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 fileRefs = useRef>(new Map()); + const lastActiveFilesRef = useRef([]); + const lastProcessedFilesRef = useRef(0); // Map context selected file names to local file IDs + // Defensive programming: ensure selectedFileIds is always an array + const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + const localSelectedFiles = files - .filter(file => selectedFileIds.includes(file.name)) + .filter(file => safeSelectedFileIds.includes(file.name)) .map(file => file.id); // Convert shared files to FileEditor format @@ -102,7 +124,23 @@ const FileEditor = ({ // Convert activeFiles to FileItem format using context (async to avoid blocking) useEffect(() => { + // Check if the actual content has changed, not just references + const currentActiveFileNames = activeFiles.map(f => f.name); + const currentProcessedFilesSize = processedFiles.size; + + const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current); + const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current; + + if (!activeFilesChanged && !processedFilesChanged) { + return; + } + + // Update refs + lastActiveFilesRef.current = currentActiveFileNames; + lastProcessedFilesRef.current = currentProcessedFilesSize; + const convertActiveFiles = async () => { + if (activeFiles.length > 0) { setLocalLoading(true); try { @@ -170,25 +208,146 @@ const FileEditor = ({ setError(null); try { - const validFiles = uploadedFiles.filter(file => { - if (file.type !== 'application/pdf') { - setError('Please upload only PDF files'); - return false; - } - return true; - }); + 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 + try { + // Validate ZIP file first + const validation = await zipFileService.validateZipFile(file); + if (!validation.isValid) { + errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`); + continue; + } + + // Extract PDF files from ZIP + 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 + } + } + }; + + recordOperation(file.name, operation); + markOperationApplied(file.name, operationId); + + if (extractionResult.errors.length > 0) { + errors.push(...extractionResult.errors); + } + } else { + errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); + } + } 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 { + errors.push(`Unsupported file type: ${file.name} (${file.type})`); + } + } + + // 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' + } + } + }; + + recordOperation(file.name, operation); + markOperationApplied(file.name, operationId); + } - if (validFiles.length > 0) { // Add files to context (they will be processed automatically) - await addFiles(validFiles); - setStatus(`Added ${validFiles.length} files`); + 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]); + }, [addFiles, recordOperation, markOperationApplied]); const selectAll = useCallback(() => { setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context @@ -196,12 +355,45 @@ const FileEditor = ({ const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); + const closeAllFiles = useCallback(() => { + if (activeFiles.length === 0) return; + + // Record close all operation for each file + activeFiles.forEach(file => { + const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const operation: FileOperation = { + id: operationId, + type: 'remove', + timestamp: Date.now(), + fileIds: [file.name], + status: 'pending', + metadata: { + originalFileName: file.name, + fileSize: file.size, + parameters: { + action: 'close_all', + reason: 'user_request' + } + } + }; + + recordOperation(file.name, operation); + markOperationApplied(file.name, operationId); + }); + + // Remove all files from context but keep in storage + removeFiles(activeFiles.map(f => 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; if (!multiSelect) { // Single select mode for tools - toggle on/off - const isCurrentlySelected = selectedFileIds.includes(fileName); + const isCurrentlySelected = safeSelectedFileIds.includes(fileName); if (isCurrentlySelected) { // Deselect the file setContextSelectedFiles([]); @@ -218,21 +410,22 @@ const FileEditor = ({ } } else { // Multi select mode (default) - setContextSelectedFiles(prev => - prev.includes(fileName) - ? prev.filter(id => id !== fileName) - : [...prev, fileName] - ); + setContextSelectedFiles(prev => { + const safePrev = Array.isArray(prev) ? prev : []; + return safePrev.includes(fileName) + ? safePrev.filter(id => id !== fileName) + : [...safePrev, fileName]; + }); // Notify parent with selected files if (onFileSelect) { const selectedFiles = files - .filter(f => selectedFileIds.includes(f.name) || f.name === fileName) + .filter(f => safeSelectedFileIds.includes(f.name) || f.name === fileName) .map(f => f.file); onFileSelect(selectedFiles); } } - }, [files, setContextSelectedFiles, multiSelect, onFileSelect, selectedFileIds]); + }, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -354,14 +547,53 @@ const FileEditor = ({ // 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) { - // Remove from context - removeFiles([file.name]); + 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 actualFileName = file.file.name; + const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const operation: FileOperation = { + id: operationId, + type: 'remove', + timestamp: Date.now(), + fileIds: [actualFileName], + status: 'pending', + metadata: { + originalFileName: actualFileName, + fileSize: file.size, + parameters: { + action: 'close', + reason: 'user_request' + } + } + }; + + recordOperation(actualFileName, 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); + // Remove from context selections - setContextSelectedFiles(prev => prev.filter(id => id !== file.name)); + setContextSelectedFiles(prev => { + const safePrev = Array.isArray(prev) ? prev : []; + return safePrev.filter(id => id !== actualFileName); + }); + + // Mark operation as applied + markOperationApplied(actualFileName, operationId); + } else { + console.log('File not found for fileId:', fileId); } - }, [files, removeFiles, setContextSelectedFiles]); + }, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); const handleViewFile = useCallback((fileId: string) => { const file = files.find(f => f.id === fileId); @@ -419,6 +651,9 @@ const FileEditor = ({ <> + )} @@ -435,7 +670,7 @@ const FileEditor = ({ - {files.length === 0 && !localLoading ? ( + {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
📁 No files loaded - Upload files or load from storage to get started + Upload PDF files, ZIP archives, or load from storage to get started
- ) : files.length === 0 && localLoading ? ( + ) : files.length === 0 && (localLoading || 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 + +
+
+
+ + )} + {/* Processing indicator */} - - - Loading files... - {Math.round(conversionProgress)}% - -
-
-
- + {localLoading && ( + + + Loading files... + {Math.round(conversionProgress)}% + +
+
+
+ + )} diff --git a/frontend/src/components/history/FileOperationHistory.tsx b/frontend/src/components/history/FileOperationHistory.tsx new file mode 100644 index 000000000..365b5a8f8 --- /dev/null +++ b/frontend/src/components/history/FileOperationHistory.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + Stack, + Paper, + Text, + Badge, + Group, + Collapse, + Box, + ScrollArea, + Code, + Divider +} from '@mantine/core'; +import { useFileContext } from '../../contexts/FileContext'; +import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext'; +import { PageOperation } from '../../types/pageEditor'; + +interface FileOperationHistoryProps { + fileId: string; + showOnlyApplied?: boolean; + maxHeight?: number; +} + +const FileOperationHistory: React.FC = ({ + fileId, + showOnlyApplied = false, + maxHeight = 400 +}) => { + const { getFileHistory, getAppliedOperations } = useFileContext(); + + const history = getFileHistory(fileId); + const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || []; + + const formatTimestamp = (timestamp: number) => { + return new Date(timestamp).toLocaleString(); + }; + + const getOperationIcon = (type: string) => { + switch (type) { + case 'split': return '✂️'; + case 'merge': return '🔗'; + case 'compress': return '🗜️'; + case 'rotate': return '🔄'; + case 'delete': return '🗑️'; + case 'move': return '↕️'; + case 'insert': return '📄'; + case 'upload': return '⬆️'; + case 'add': return '➕'; + case 'remove': return '➖'; + case 'replace': return '🔄'; + case 'convert': return '🔄'; + default: return '⚙️'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'applied': return 'green'; + case 'failed': return 'red'; + case 'pending': return 'yellow'; + default: return 'gray'; + } + }; + + const renderOperationDetails = (operation: FileOperation | PageOperation) => { + if ('metadata' in operation && operation.metadata) { + const { metadata } = operation; + return ( + + {metadata.parameters && ( + + Parameters: {JSON.stringify(metadata.parameters, null, 2)} + + )} + {metadata.originalFileName && ( + + Original file: {metadata.originalFileName} + + )} + {metadata.outputFileNames && ( + + Output files: {metadata.outputFileNames.join(', ')} + + )} + {metadata.fileSize && ( + + File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB + + )} + {metadata.pageCount && ( + + Pages: {metadata.pageCount} + + )} + {metadata.error && ( + + Error: {metadata.error} + + )} + + ); + } + return null; + }; + + if (!history || operations.length === 0) { + return ( + + + {showOnlyApplied ? 'No applied operations found' : 'No operation history available'} + + + ); + } + + return ( + + + + {showOnlyApplied ? 'Applied Operations' : 'Operation History'} + + + {operations.length} operations + + + + + + {operations.map((operation, index) => ( + + + + + {getOperationIcon(operation.type)} + + + + {operation.type.charAt(0).toUpperCase() + operation.type.slice(1)} + + + {formatTimestamp(operation.timestamp)} + + + + + + {operation.status} + + + + {renderOperationDetails(operation)} + + {index < operations.length - 1 && } + + ))} + + + + {history && ( + + + Created: {formatTimestamp(history.createdAt)} + + + Last modified: {formatTimestamp(history.lastModified)} + + + )} + + ); +}; + +export default FileOperationHistory; \ No newline at end of file diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index eff36f1f9..c350bae91 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,11 +1,13 @@ -import React from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; -import DeleteIcon from '@mui/icons-material/Delete'; +import React, { useState } from 'react'; +import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core'; +import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; import MergeIcon from '@mui/icons-material/Merge'; import SplitscreenIcon from '@mui/icons-material/Splitscreen'; +import HistoryIcon from '@mui/icons-material/History'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import styles from './PageEditor.module.css'; +import FileOperationHistory from '../history/FileOperationHistory'; interface FileItem { id: string; @@ -65,6 +67,8 @@ const FileThumbnail = ({ onSetStatus, toolMode = false, }: FileThumbnailProps) => { + const [showHistory, setShowHistory] = useState(false); + const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -289,18 +293,33 @@ const FileThumbnail = ({ )} - + { + e.stopPropagation(); + setShowHistory(true); + onSetStatus(`Viewing history for ${file.name}`); + }} + > + + + + + + { e.stopPropagation(); onDeleteFile(file.id); - onSetStatus(`Deleted ${file.name}`); + onSetStatus(`Closed ${file.name}`); }} > - +
@@ -326,6 +345,21 @@ const FileThumbnail = ({ {formatFileSize(file.size)}
+ + {/* History Modal */} + setShowHistory(false)} + title={`Operation History - ${file.name}`} + size="lg" + scrollAreaComponent="div" + > + +
); }; diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx index 2753d861d..40e04edcc 100644 --- a/frontend/src/components/shared/FileUploadSelector.tsx +++ b/frontend/src/components/shared/FileUploadSelector.tsx @@ -36,7 +36,7 @@ const FileUploadSelector = ({ sharedFiles = [], onFileSelect, onFilesSelect, - accept = ["application/pdf"], + accept = ["application/pdf", "application/zip", "application/x-zip-compressed"], loading = false, disabled = false, showRecentFiles = true, @@ -212,7 +212,9 @@ const FileUploadSelector = ({ {t("fileUpload.dropFilesHere", "Drop files here or click to upload")} - {accept.includes('application/pdf') + {accept.includes('application/pdf') && accept.includes('application/zip') + ? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files") + : accept.includes('application/pdf') ? t("fileUpload.pdfFilesOnly", "PDF files only") : t("fileUpload.supportedFileTypes", "Supported file types") } diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 1fbd8e1b2..5b772c90a 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -91,7 +91,9 @@ const TopControls = ({ return (
-
+
@@ -457,6 +460,21 @@ export default function HomePage() { }} sidebarsVisible={sidebarsVisible} setSidebarsVisible={setSidebarsVisible} + previewFile={previewFile} + {...(previewFile && { + onClose: () => { + setPreviewFile(null); // Clear preview file + const previousMode = sessionStorage.getItem('previousMode'); + if (previousMode === 'split') { + setSelectedToolKey('split'); + setCurrentView('split'); + setLeftPanelView('toolContent'); + sessionStorage.removeItem('previousMode'); + } else { + setCurrentView('fileEditor'); + } + } + })} /> ) : currentView === "pageEditor" ? ( <> diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index cb5003efe..b0662437e 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -12,12 +12,12 @@ export class PDFExportService { * Export PDF document with applied operations */ async exportPDF( - pdfDocument: PDFDocument, + pdfDocument: PDFDocument, selectedPageIds: string[] = [], options: ExportOptions = {} ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { const { selectedOnly = false, filename, splitDocuments = false } = options; - + try { // Determine which pages to export const pagesToExport = selectedOnly && selectedPageIds.length > 0 @@ -57,16 +57,16 @@ export class PDFExportService { for (const page of pages) { // Get the original page from source document const sourcePageIndex = page.pageNumber - 1; - + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { // Copy the page const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - + // Apply rotation if (page.rotation !== 0) { copiedPage.setRotation(degrees(page.rotation)); } - + newDoc.addPage(copiedPage); } } @@ -108,20 +108,20 @@ export class PDFExportService { for (const endIndex of splitPoints) { const segmentPages = pages.slice(startIndex, endIndex); - + if (segmentPages.length > 0) { const newDoc = await PDFLibDocument.create(); - + for (const page of segmentPages) { const sourcePageIndex = page.pageNumber - 1; - + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - + if (page.rotation !== 0) { copiedPage.setRotation(degrees(page.rotation)); } - + newDoc.addPage(copiedPage); } } @@ -130,16 +130,16 @@ export class PDFExportService { newDoc.setCreator('Stirling PDF'); newDoc.setProducer('Stirling PDF'); newDoc.setTitle(`${baseFilename} - Part ${partNumber}`); - + const pdfBytes = await newDoc.save(); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); const filename = this.generateSplitFilename(baseFilename, partNumber); - + blobs.push(blob); filenames.push(filename); partNumber++; } - + startIndex = endIndex; } @@ -172,11 +172,11 @@ export class PDFExportService { link.href = url; link.download = filename; link.style.display = 'none'; - + document.body.appendChild(link); link.click(); document.body.removeChild(link); - + // Clean up the URL after a short delay setTimeout(() => URL.revokeObjectURL(url), 1000); } @@ -185,7 +185,7 @@ export class PDFExportService { * Download multiple files as a ZIP */ async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise { - // For now, download files individually + // For now, download files wherindividually blobs.forEach((blob, index) => { setTimeout(() => { this.downloadFile(blob, filenames[index]); @@ -207,7 +207,7 @@ export class PDFExportService { errors.push('No pages available to export'); } - const pagesToExport = selectedOnly + const pagesToExport = selectedOnly ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) : pdfDocument.pages; @@ -226,7 +226,7 @@ export class PDFExportService { splitCount: number; estimatedSize: string; } { - const pagesToExport = selectedOnly + const pagesToExport = selectedOnly ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) : pdfDocument.pages; @@ -259,4 +259,4 @@ export class PDFExportService { } // Export singleton instance -export const pdfExportService = new PDFExportService(); \ No newline at end of file +export const pdfExportService = new PDFExportService(); diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts new file mode 100644 index 000000000..3c238e159 --- /dev/null +++ b/frontend/src/services/zipFileService.ts @@ -0,0 +1,300 @@ +import JSZip from 'jszip'; + +export interface ZipExtractionResult { + success: boolean; + extractedFiles: File[]; + errors: string[]; + totalFiles: number; + extractedCount: number; +} + +export interface ZipValidationResult { + isValid: boolean; + fileCount: number; + totalSizeBytes: number; + containsPDFs: boolean; + errors: string[]; +} + +export interface ZipExtractionProgress { + currentFile: string; + extractedCount: number; + totalFiles: number; + progress: number; +} + +export class ZipFileService { + private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file + private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit + private readonly supportedExtensions = ['.pdf']; + + /** + * Validate a ZIP file without extracting it + */ + async validateZipFile(file: File): Promise { + const result: ZipValidationResult = { + isValid: false, + fileCount: 0, + totalSizeBytes: 0, + containsPDFs: false, + errors: [] + }; + + try { + // Check file size + if (file.size > this.maxTotalSize) { + result.errors.push(`ZIP file too large: ${this.formatFileSize(file.size)} (max: ${this.formatFileSize(this.maxTotalSize)})`); + return result; + } + + // Check file type + if (!this.isZipFile(file)) { + result.errors.push('File is not a valid ZIP archive'); + return result; + } + + // Load and validate ZIP contents + const zip = new JSZip(); + const zipContents = await zip.loadAsync(file); + + let totalSize = 0; + let fileCount = 0; + let containsPDFs = false; + + // Analyze ZIP contents + for (const [filename, zipEntry] of Object.entries(zipContents.files)) { + if (zipEntry.dir) { + continue; // Skip directories + } + + fileCount++; + const uncompressedSize = zipEntry._data?.uncompressedSize || 0; + totalSize += uncompressedSize; + + // Check if file is a PDF + if (this.isPdfFile(filename)) { + containsPDFs = true; + } + + // Check individual file size + if (uncompressedSize > this.maxFileSize) { + result.errors.push(`File "${filename}" too large: ${this.formatFileSize(uncompressedSize)} (max: ${this.formatFileSize(this.maxFileSize)})`); + } + } + + // Check total uncompressed size + if (totalSize > this.maxTotalSize) { + result.errors.push(`Total uncompressed size too large: ${this.formatFileSize(totalSize)} (max: ${this.formatFileSize(this.maxTotalSize)})`); + } + + result.fileCount = fileCount; + result.totalSizeBytes = totalSize; + result.containsPDFs = containsPDFs; + result.isValid = result.errors.length === 0 && containsPDFs; + + if (!containsPDFs) { + result.errors.push('ZIP file does not contain any PDF files'); + } + + return result; + } catch (error) { + result.errors.push(`Failed to validate ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`); + return result; + } + } + + /** + * Extract PDF files from a ZIP archive + */ + async extractPdfFiles( + file: File, + onProgress?: (progress: ZipExtractionProgress) => void + ): Promise { + const result: ZipExtractionResult = { + success: false, + extractedFiles: [], + errors: [], + totalFiles: 0, + extractedCount: 0 + }; + + try { + // Validate ZIP file first + const validation = await this.validateZipFile(file); + if (!validation.isValid) { + result.errors = validation.errors; + return result; + } + + // Load ZIP contents + const zip = new JSZip(); + const zipContents = await zip.loadAsync(file); + + // Get all PDF files + const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) => + !zipEntry.dir && this.isPdfFile(filename) + ); + + result.totalFiles = pdfFiles.length; + + // Extract each PDF file + for (let i = 0; i < pdfFiles.length; i++) { + const [filename, zipEntry] = pdfFiles[i]; + + try { + // Report progress + if (onProgress) { + onProgress({ + currentFile: filename, + extractedCount: i, + totalFiles: pdfFiles.length, + progress: (i / pdfFiles.length) * 100 + }); + } + + // Extract file content + const content = await zipEntry.async('uint8array'); + + // Create File object + const extractedFile = new File([content], this.sanitizeFilename(filename), { + type: 'application/pdf', + lastModified: zipEntry.date?.getTime() || Date.now() + }); + + // Validate extracted PDF + if (await this.isValidPdfFile(extractedFile)) { + result.extractedFiles.push(extractedFile); + result.extractedCount++; + } else { + result.errors.push(`File "${filename}" is not a valid PDF`); + } + } catch (error) { + result.errors.push(`Failed to extract "${filename}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Final progress report + if (onProgress) { + onProgress({ + currentFile: '', + extractedCount: result.extractedCount, + totalFiles: result.totalFiles, + progress: 100 + }); + } + + result.success = result.extractedCount > 0; + return result; + } catch (error) { + result.errors.push(`Failed to extract ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`); + return result; + } + } + + /** + * Check if a file is a ZIP file based on type and extension + */ + private isZipFile(file: File): boolean { + const validTypes = [ + 'application/zip', + 'application/x-zip-compressed', + 'application/x-zip', + 'application/octet-stream' // Some browsers use this for ZIP files + ]; + + const validExtensions = ['.zip']; + const hasValidType = validTypes.includes(file.type); + const hasValidExtension = validExtensions.some(ext => + file.name.toLowerCase().endsWith(ext) + ); + + return hasValidType || hasValidExtension; + } + + /** + * Check if a filename indicates a PDF file + */ + private isPdfFile(filename: string): boolean { + return filename.toLowerCase().endsWith('.pdf'); + } + + /** + * Validate that a file is actually a PDF by checking its header + */ + private async isValidPdfFile(file: File): Promise { + try { + // Read first few bytes to check PDF header + const buffer = await file.slice(0, 8).arrayBuffer(); + const bytes = new Uint8Array(buffer); + + // Check for PDF header: %PDF- + return bytes[0] === 0x25 && // % + bytes[1] === 0x50 && // P + bytes[2] === 0x44 && // D + bytes[3] === 0x46 && // F + bytes[4] === 0x2D; // - + } catch (error) { + return false; + } + } + + /** + * Sanitize filename for safe use + */ + private sanitizeFilename(filename: string): string { + // Remove directory path and get just the filename + const basename = filename.split('/').pop() || filename; + + // Remove or replace unsafe characters + return basename + .replace(/[<>:"/\\|?*]/g, '_') // Replace unsafe chars with underscore + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/_{2,}/g, '_') // Replace multiple underscores with single + .replace(/^_|_$/g, ''); // Remove leading/trailing underscores + } + + /** + * Format file size for display + */ + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * Get file extension from filename + */ + private getFileExtension(filename: string): string { + return filename.substring(filename.lastIndexOf('.')).toLowerCase(); + } + + /** + * Check if ZIP file contains password protection + */ + private async isPasswordProtected(file: File): Promise { + try { + const zip = new JSZip(); + await zip.loadAsync(file); + + // Check if any files are encrypted + for (const [filename, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.options?.compression === 'STORE' && zipEntry._data?.compressedSize === 0) { + // This might indicate encryption, but JSZip doesn't provide direct encryption detection + // We'll handle this in the extraction phase + } + } + + return false; // JSZip will throw an error if password is required + } catch (error) { + // If we can't load the ZIP, it might be password protected + const errorMessage = error instanceof Error ? error.message : ''; + return errorMessage.includes('password') || errorMessage.includes('encrypted'); + } + } +} + +// Export singleton instance +export const zipFileService = new ZipFileService(); \ No newline at end of file diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 0b55eda57..e600257c0 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -12,10 +12,17 @@ import { Alert, Box, Group, + Grid, + Image, + Loader, + Center, } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useFileContext } from "../contexts/FileContext"; +import { FileOperation } from "../types/fileContext"; +import { zipFileService } from "../services/zipFileService"; +import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import FileEditor from "../components/fileEditor/FileEditor"; export interface SplitPdfPanelProps { @@ -33,21 +40,33 @@ export interface SplitPdfPanelProps { }; updateParams: (newParams: Partial) => void; selectedFiles?: File[]; + onPreviewFile?: (file: File | null) => void; } const SplitPdfPanel: React.FC = ({ params, updateParams, selectedFiles = [], + onPreviewFile, }) => { const { t } = useTranslation(); const fileContext = useFileContext(); - const { activeFiles, selectedFileIds, updateProcessedFile } = fileContext; + const { activeFiles, selectedFileIds, updateProcessedFile, recordOperation, markOperationApplied, markOperationFailed, setCurrentMode } = fileContext; const [status, setStatus] = useState(""); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [downloadUrl, setDownloadUrl] = useState(null); + const [splitResults, setSplitResults] = useState<{ + files: File[]; + thumbnails: string[]; + isGeneratingThumbnails: boolean; + }>({ + files: [], + thumbnails: [], + isGeneratingThumbnails: false + }); + const [previewFile, setPreviewFile] = useState(null); const { mode, @@ -68,8 +87,15 @@ const SplitPdfPanel: React.FC = ({ setDownloadUrl(null); setStatus(""); } - // Reset step 2 completion when parameters change (but not when just status/loading changes) - setStep2Completed(false); + // Clear split results and preview file + setSplitResults({ + files: [], + thumbnails: [], + isGeneratingThumbnails: false + }); + setPreviewFile(null); + onPreviewFile?.(null); + // Parameters changed - results will be cleared automatically }, [mode, pages, hDiv, vDiv, merge, splitType, splitValue, bookmarkLevel, includeMetadata, allowDuplicates, selectedFiles]); @@ -118,6 +144,36 @@ const SplitPdfPanel: React.FC = ({ return; } + // Record the operation before starting + const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fileId = selectedFiles[0].name; // Use first file's name as primary ID + + const operation: FileOperation = { + id: operationId, + type: 'split', + timestamp: Date.now(), + fileIds: selectedFiles.map(f => f.name), + status: 'pending', + metadata: { + originalFileName: selectedFiles[0].name, + parameters: { + mode, + pages: mode === 'byPages' ? pages : undefined, + hDiv: mode === 'bySections' ? hDiv : undefined, + vDiv: mode === 'bySections' ? vDiv : undefined, + merge: mode === 'bySections' ? merge : undefined, + splitType: mode === 'bySizeOrCount' ? splitType : undefined, + splitValue: mode === 'bySizeOrCount' ? splitValue : undefined, + bookmarkLevel: mode === 'byChapters' ? bookmarkLevel : undefined, + includeMetadata: mode === 'byChapters' ? includeMetadata : undefined, + allowDuplicates: mode === 'byChapters' ? allowDuplicates : undefined, + }, + fileSize: selectedFiles[0].size + } + }; + + recordOperation(fileId, operation); + setStatus(t("loading")); setIsLoading(true); setErrorMessage(null); @@ -128,6 +184,47 @@ const SplitPdfPanel: React.FC = ({ const url = window.URL.createObjectURL(blob); setDownloadUrl(url); setStatus(t("downloadComplete")); + + // Extract files from ZIP response for preview + try { + // Create a File object from the blob to use with zipFileService + const zipFile = new File([blob], "split_result.zip", { type: "application/zip" }); + + // Extract PDF files for preview + const extractionResult = await zipFileService.extractPdfFiles(zipFile); + + if (extractionResult.success && extractionResult.extractedFiles.length > 0) { + setSplitResults(prev => ({ + ...prev, + files: extractionResult.extractedFiles, + isGeneratingThumbnails: true + })); + + // Generate thumbnails for preview + const thumbnails = await Promise.all( + extractionResult.extractedFiles.map(async (file) => { + try { + return await generateThumbnailForFile(file); + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; // Empty string for failed thumbnails + } + }) + ); + + setSplitResults(prev => ({ + ...prev, + thumbnails, + isGeneratingThumbnails: false + })); + } + } catch (extractError) { + console.warn('Failed to extract files for preview:', extractError); + // Don't fail the whole operation just because preview extraction failed + } + + // Mark operation as applied on success + markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF."); @@ -138,6 +235,9 @@ const SplitPdfPanel: React.FC = ({ } setErrorMessage(errorMsg); setStatus(t("error._value", "Split failed.")); + + // Mark operation as failed + markOperationFailed(fileId, operationId, errorMsg); } finally { setIsLoading(false); } @@ -148,13 +248,29 @@ const SplitPdfPanel: React.FC = ({ return currentMode && currentMode !== ""; // All modes need some params }; - // Step 2 completion state - const [step2Completed, setStep2Completed] = useState(false); + // Handle thumbnail click to open in viewer + const handleThumbnailClick = (file: File) => { + try { + // Set as preview file (no context pollution) + setPreviewFile(file); + onPreviewFile?.(file); - // Check if step 2 settings are valid (for enabling Done button) + // Store that we came from Split tool for return navigation + sessionStorage.setItem('previousMode', 'split'); + + // Switch to viewer mode + setCurrentMode('viewer'); + } catch (error) { + console.error('Failed to open file in viewer:', error); + } + }; + + // No longer needed - step completion is determined by split results + + // Check if step 2 settings are valid (for enabling Split button) const step2SettingsValid = (() => { if (!mode) return false; - + switch (mode) { case "byPages": return pages.trim() !== ""; @@ -172,11 +288,11 @@ const SplitPdfPanel: React.FC = ({ // Determine what steps to show const showStep1 = true; // Always show - Files const showStep2 = selectedFiles.length > 0; // Settings (mode + params) - const showStep3 = step2Completed; // Review (apply & continue vs export) + const showStep3 = downloadUrl !== null; // Review (show results after split) // Determine if steps are collapsed (completed) const step1Collapsed = selectedFiles.length > 0; - const step2Collapsed = step2Completed; + const step2Collapsed = downloadUrl !== null; return ( @@ -199,23 +315,35 @@ const SplitPdfPanel: React.FC = ({ {/* Step 2: Settings */} {showStep2 && ( - { - // Go back to step 2 - setStep2Completed(false); + // Reset to allow changing settings + setDownloadUrl(null); + setSplitResults({ + files: [], + thumbnails: [], + isGeneratingThumbnails: false + }); + setStatus(""); + setErrorMessage(null); + // Clear any active preview and return to previous view + setPreviewFile(null); + onPreviewFile?.(null); + // Return to the Split tool view + setCurrentMode('split'); } : undefined} > 2. Settings {step2Collapsed ? ( - ✓ Settings configured (click to change) + ✓ Split completed (click to change settings) ) : ( @@ -312,59 +440,128 @@ const SplitPdfPanel: React.FC = ({ )} - {/* Done Button */} + {/* Split Button */} {mode && ( - +
+ +
)} )}
)} - {/* Step 3: Review */} + {/* Step 3: Results */} {showStep3 && ( - 3. Review - -
- + )} - {status && {status}} + {/* Split Results Preview */} + {(splitResults.files.length > 0 || splitResults.isGeneratingThumbnails) && ( + + + Split Results ({splitResults.files.length} files) + - {errorMessage && ( - setErrorMessage(null)} mt="sm"> - {errorMessage} - - )} - - {downloadUrl && ( - - )} - + {splitResults.isGeneratingThumbnails ? ( +
+ + + Generating previews... + +
+ ) : ( + + {splitResults.files.map((file, index) => ( + + handleThumbnailClick(file)} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.02)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = ''; + }} + > + + {splitResults.thumbnails[index] ? ( + {`Preview + ) : ( + No preview + )} + + + {file.name} + + + {(file.size / 1024).toFixed(1)} KB + + + + ))} + + )} +
+ )}
)} diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index bda36dd6a..d28490277 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -13,10 +13,27 @@ export type ToolType = 'merge' | 'split' | 'compress' | null; export interface FileOperation { id: string; - type: 'merge' | 'add' | 'remove' | 'replace'; + type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload'; timestamp: number; fileIds: string[]; + status: 'pending' | 'applied' | 'failed'; data?: any; + metadata?: { + originalFileName?: string; + outputFileNames?: string[]; + parameters?: Record; + fileSize?: number; + pageCount?: number; + error?: string; + }; +} + +export interface FileOperationHistory { + fileId: string; + fileName: string; + operations: (FileOperation | PageOperation)[]; + createdAt: number; + lastModified: number; } export interface ViewerConfig { @@ -46,6 +63,8 @@ export interface FileContextState { // Edit history and state fileEditHistory: Map; globalFileOperations: FileOperation[]; + // New comprehensive operation history + fileOperationHistory: Map; // UI state that persists across views selectedFileIds: string[]; @@ -72,7 +91,7 @@ export interface FileContextState { export interface FileContextActions { // File management addFiles: (files: File[]) => Promise; - removeFiles: (fileIds: string[]) => void; + removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; replaceFile: (oldFileId: string, newFile: File) => Promise; clearAllFiles: () => void; @@ -93,6 +112,14 @@ export interface FileContextActions { applyFileOperation: (operation: FileOperation) => void; undoLastOperation: (fileId?: string) => void; + // Operation history management + recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void; + markOperationApplied: (fileId: string, operationId: string) => void; + markOperationFailed: (fileId: string, operationId: string, error: string) => void; + getFileHistory: (fileId: string) => FileOperationHistory | undefined; + getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[]; + clearFileHistory: (fileId: string) => void; + // Viewer state updateViewerConfig: (config: Partial) => void; diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts index ba26d1d75..7e0dda16e 100644 --- a/frontend/src/types/pageEditor.ts +++ b/frontend/src/types/pageEditor.ts @@ -16,9 +16,20 @@ export interface PDFDocument { } export interface PageOperation { - type: 'rotate' | 'delete' | 'move' | 'split' | 'insert'; + id: string; + type: 'rotate' | 'delete' | 'move' | 'split' | 'insert' | 'reorder'; pageIds: string[]; + timestamp: number; + status: 'pending' | 'applied' | 'failed'; data?: any; + metadata?: { + rotation?: number; + fromPosition?: number; + toPosition?: number; + splitType?: string; + insertAfterPage?: number; + error?: string; + }; } export interface UndoRedoState {