diff --git a/frontend/src/components/editor/BulkSelectionPanel.tsx b/frontend/src/components/editor/BulkSelectionPanel.tsx new file mode 100644 index 000000000..e28d0c41f --- /dev/null +++ b/frontend/src/components/editor/BulkSelectionPanel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Paper, Group, TextInput, Button, Text } from '@mantine/core'; + +interface BulkSelectionPanelProps { + csvInput: string; + setCsvInput: (value: string) => void; + selectedPages: string[]; + onUpdatePagesFromCSV: () => void; +} + +const BulkSelectionPanel = ({ + csvInput, + setCsvInput, + selectedPages, + onUpdatePagesFromCSV, +}: BulkSelectionPanelProps) => { + return ( + + + setCsvInput(e.target.value)} + placeholder="1,3,5-10" + label="Page Selection" + onBlur={onUpdatePagesFromCSV} + onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()} + style={{ flex: 1 }} + /> + + + {selectedPages.length > 0 && ( + + Selected: {selectedPages.length} pages + + )} + + ); +}; + +export default BulkSelectionPanel; \ No newline at end of file diff --git a/frontend/src/components/editor/FileEditor.tsx b/frontend/src/components/editor/FileEditor.tsx new file mode 100644 index 000000000..02c9a4cee --- /dev/null +++ b/frontend/src/components/editor/FileEditor.tsx @@ -0,0 +1,515 @@ +import React, { useState, useCallback, useRef, useEffect } 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 { fileStorage } from '../../services/fileStorage'; +import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; +import styles from './PageEditor.module.css'; +import FileThumbnail from './FileThumbnail'; +import BulkSelectionPanel from './BulkSelectionPanel'; +import DragDropGrid from './shared/DragDropGrid'; +import FilePickerModal from '../shared/FilePickerModal'; + +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; + sharedFiles?: any[]; + setSharedFiles?: (files: any[]) => void; + preSelectedFiles?: any[]; + onClearPreSelection?: () => void; +} + +const FileEditor = ({ + onOpenPageEditor, + onMergeFiles, + sharedFiles = [], + setSharedFiles, + preSelectedFiles = [], + onClearPreSelection +}: FileEditorProps) => { + const { t } = useTranslation(); + + const files = sharedFiles; // Use sharedFiles as the source of truth + + const [selectedFiles, setSelectedFiles] = useState([]); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [csvInput, setCsvInput] = useState(''); + const [selectionMode, setSelectionMode] = useState(false); + const [draggedFile, setDraggedFile] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null); + const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); + const [isAnimating, setIsAnimating] = useState(false); + const [showFilePickerModal, setShowFilePickerModal] = useState(false); + const fileRefs = useRef>(new Map()); + + // Convert shared files to FileEditor format + const convertToFileItem = useCallback(async (sharedFile: any): Promise => { + // Generate thumbnail if not already available + const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile); + + return { + id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, + name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''), + pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now + thumbnail, + size: sharedFile.file?.size || sharedFile.size || 0, + file: sharedFile.file || sharedFile, + }; + }, []); + + // Only load shared files when explicitly passed (not on mount) + useEffect(() => { + const loadSharedFiles = async () => { + // Only load if we have pre-selected files (coming from FileManager) + if (preSelectedFiles.length > 0) { + setLoading(true); + try { + const convertedFiles = await Promise.all( + preSelectedFiles.map(convertToFileItem) + ); + setFiles(convertedFiles); + } catch (err) { + console.error('Error converting pre-selected files:', err); + } finally { + setLoading(false); + } + } + }; + + loadSharedFiles(); + }, [preSelectedFiles, convertToFileItem]); + + // Handle pre-selected files + useEffect(() => { + if (preSelectedFiles.length > 0) { + const preSelectedIds = preSelectedFiles.map(f => f.id || f.name); + setSelectedFiles(preSelectedIds); + onClearPreSelection?.(); + } + }, [preSelectedFiles, onClearPreSelection]); + + // Process uploaded files + const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { + setLoading(true); + setError(null); + + try { + const newFiles: FileItem[] = []; + + for (const file of uploadedFiles) { + if (file.type !== 'application/pdf') { + setError('Please upload only PDF files'); + continue; + } + + // Generate thumbnail and get page count + const thumbnail = await generateThumbnailForFile(file); + + const fileItem: FileItem = { + id: `file-${Date.now()}-${Math.random()}`, + name: file.name.replace(/\.pdf$/i, ''), + pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count + thumbnail, + size: file.size, + file, + }; + + newFiles.push(fileItem); + + // Store in IndexedDB + await fileStorage.storeFile(file, thumbnail); + } + + if (setSharedFiles) { + setSharedFiles(prev => [...prev, ...newFiles]); + } + + setStatus(`Added ${newFiles.length} files`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; + setError(errorMessage); + console.error('File processing error:', err); + } finally { + setLoading(false); + } + }, [setSharedFiles]); + + const selectAll = useCallback(() => { + setSelectedFiles(files.map(f => f.id)); + }, [files]); + + const deselectAll = useCallback(() => setSelectedFiles([]), []); + + const toggleFile = useCallback((fileId: string) => { + setSelectedFiles(prev => + prev.includes(fileId) + ? prev.filter(id => id !== fileId) + : [...prev, fileId] + ); + }, []); + + const toggleSelectionMode = useCallback(() => { + setSelectionMode(prev => { + const newMode = !prev; + if (!newMode) { + setSelectedFiles([]); + setCsvInput(''); + } + return newMode; + }); + }, []); + + const parseCSVInput = useCallback((csv: string) => { + const fileIds: string[] = []; + const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); + + ranges.forEach(range => { + if (range.includes('-')) { + const [start, end] = range.split('-').map(n => parseInt(n.trim())); + for (let i = start; i <= end && i <= files.length; i++) { + if (i > 0) { + const file = files[i - 1]; + if (file) fileIds.push(file.id); + } + } + } else { + const fileIndex = parseInt(range); + if (fileIndex > 0 && fileIndex <= files.length) { + const file = files[fileIndex - 1]; + if (file) fileIds.push(file.id); + } + } + }); + + return fileIds; + }, [files]); + + const updateFilesFromCSV = useCallback(() => { + const fileIds = parseCSVInput(csvInput); + setSelectedFiles(fileIds); + }, [csvInput, parseCSVInput]); + + // Drag and drop handlers + const handleDragStart = useCallback((fileId: string) => { + setDraggedFile(fileId); + + if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) { + setMultiFileDrag({ + fileIds: selectedFiles, + count: selectedFiles.length + }); + } else { + setMultiFileDrag(null); + } + }, [selectionMode, selectedFiles]); + + const handleDragEnd = useCallback(() => { + setDraggedFile(null); + setDropTarget(null); + setMultiFileDrag(null); + setDragPosition(null); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + + if (!draggedFile) return; + + if (multiFileDrag) { + setDragPosition({ x: e.clientX, y: e.clientY }); + } + + const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); + if (!elementUnderCursor) return; + + const fileContainer = elementUnderCursor.closest('[data-file-id]'); + if (fileContainer) { + const fileId = fileContainer.getAttribute('data-file-id'); + if (fileId && fileId !== draggedFile) { + setDropTarget(fileId); + return; + } + } + + const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); + if (endZone) { + setDropTarget('end'); + return; + } + + setDropTarget(null); + }, [draggedFile, multiFileDrag]); + + const handleDragEnter = useCallback((fileId: string) => { + if (draggedFile && fileId !== draggedFile) { + setDropTarget(fileId); + } + }, [draggedFile]); + + const handleDragLeave = useCallback(() => { + // Let dragover handle this + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => { + e.preventDefault(); + if (!draggedFile || draggedFile === targetFileId) return; + + let targetIndex: number; + if (targetFileId === 'end') { + targetIndex = files.length; + } else { + targetIndex = files.findIndex(f => f.id === targetFileId); + if (targetIndex === -1) return; + } + + const filesToMove = selectionMode && selectedFiles.includes(draggedFile) + ? selectedFiles + : [draggedFile]; + + if (setSharedFiles) { + setSharedFiles(prev => { + const newFiles = [...prev]; + const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean); + + // Remove moved files + filesToMove.forEach(id => { + const index = newFiles.findIndex(f => f.id === id); + if (index !== -1) newFiles.splice(index, 1); + }); + + // Insert at target position + newFiles.splice(targetIndex, 0, ...movedFiles); + return newFiles; + }); + } + + const moveCount = multiFileDrag ? multiFileDrag.count : 1; + setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + + handleDragEnd(); + }, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setSharedFiles]); + + const handleEndZoneDragEnter = useCallback(() => { + if (draggedFile) { + setDropTarget('end'); + } + }, [draggedFile]); + + // File operations + const handleDeleteFile = useCallback((fileId: string) => { + if (setSharedFiles) { + setSharedFiles(prev => prev.filter(f => f.id !== fileId)); + } + setSelectedFiles(prev => prev.filter(id => id !== fileId)); + }, [setSharedFiles]); + + const handleViewFile = useCallback((fileId: string) => { + const file = files.find(f => f.id === fileId); + if (file && onOpenPageEditor) { + onOpenPageEditor(file.file); + } + }, [files, onOpenPageEditor]); + + 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; + + setLoading(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 { + setLoading(false); + } + }, [convertToFileItem]); + + + return ( + + + + + + + {selectionMode && ( + <> + + + + )} + + {/* Load from storage and upload buttons */} + + + + + + + + {selectionMode && ( + + )} + + ( + + )} + renderSplitMarker={(file, index) => ( +
+ )} + /> + + + {/* File Picker Modal */} + setShowFilePickerModal(false)} + sharedFiles={sharedFiles || []} + 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; diff --git a/frontend/src/components/editor/FileThumbnail.tsx b/frontend/src/components/editor/FileThumbnail.tsx new file mode 100644 index 000000000..46448a34c --- /dev/null +++ b/frontend/src/components/editor/FileThumbnail.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; +import DeleteIcon from '@mui/icons-material/Delete'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import MergeIcon from '@mui/icons-material/Merge'; +import SplitscreenIcon from '@mui/icons-material/Splitscreen'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import styles from './PageEditor.module.css'; + +interface FileItem { + id: string; + name: string; + pageCount: number; + thumbnail: string; + size: number; + splitBefore?: boolean; +} + +interface FileThumbnailProps { + file: FileItem; + index: number; + totalFiles: number; + selectedFiles: string[]; + selectionMode: boolean; + draggedFile: string | null; + dropTarget: string | null; + isAnimating: boolean; + fileRefs: React.MutableRefObject>; + onDragStart: (fileId: string) => void; + onDragEnd: () => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (fileId: string) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent, fileId: string) => void; + onToggleFile: (fileId: string) => void; + onDeleteFile: (fileId: string) => void; + onViewFile: (fileId: string) => void; + onMergeFromHere: (fileId: string) => void; + onSplitFile: (fileId: string) => void; + onSetStatus: (status: string) => void; +} + +const FileThumbnail = ({ + file, + index, + totalFiles, + selectedFiles, + selectionMode, + draggedFile, + dropTarget, + isAnimating, + fileRefs, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onToggleFile, + onDeleteFile, + onViewFile, + onMergeFromHere, + onSplitFile, + onSetStatus, +}: FileThumbnailProps) => { + const formatFileSize = (bytes: number) => { + 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(1)) + ' ' + sizes[i]; + }; + + return ( +
{ + if (el) { + fileRefs.current.set(file.id, el); + } else { + fileRefs.current.delete(file.id); + } + }} + data-file-id={file.id} + className={` + ${styles.pageContainer} + !rounded-lg + cursor-grab + select-none + w-[20rem] + h-[24rem] + flex flex-col items-center justify-center + flex-shrink-0 + shadow-sm + hover:shadow-md + transition-all + relative + ${selectionMode + ? 'bg-white hover:bg-gray-50' + : 'bg-white hover:bg-gray-50'} + ${draggedFile === file.id ? 'opacity-50 scale-95' : ''} + `} + style={{ + transform: (() => { + if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) { + return 'translateX(20px)'; + } + return 'translateX(0)'; + })(), + transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' + }} + draggable + onDragStart={() => onDragStart(file.id)} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragEnter={() => onDragEnter(file.id)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, file.id)} + > + {selectionMode && ( +
e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + event.stopPropagation(); + onToggleFile(file.id); + }} + onClick={(e) => e.stopPropagation()} + size="sm" + /> +
+ )} + + {/* File content area */} +
+ {/* Stacked file effect - multiple shadows to simulate pages */} +
+ {file.name} +
+ + {/* Page count badge */} + + {file.pageCount} pages + + + {/* File name overlay */} + + {file.name} + + + {/* Hover controls */} +
+ + { + e.stopPropagation(); + onViewFile(file.id); + onSetStatus(`Opened ${file.name}`); + }} + > + + + + + + { + e.stopPropagation(); + onMergeFromHere(file.id); + onSetStatus(`Starting merge from ${file.name}`); + }} + > + + + + + + { + e.stopPropagation(); + onSplitFile(file.id); + onSetStatus(`Opening ${file.name} in page editor`); + }} + > + + + + + + { + e.stopPropagation(); + onDeleteFile(file.id); + onSetStatus(`Deleted ${file.name}`); + }} + > + + + +
+ + +
+ + {/* File info */} +
+ + {file.name} + + + {formatFileSize(file.size)} + +
+
+ ); +}; + +export default FileThumbnail; \ No newline at end of file diff --git a/frontend/src/components/editor/PageEditor.module.css b/frontend/src/components/editor/PageEditor.module.css new file mode 100644 index 000000000..5901e80e6 --- /dev/null +++ b/frontend/src/components/editor/PageEditor.module.css @@ -0,0 +1,63 @@ +/* Page container hover effects */ +.pageContainer { + transition: transform 0.2s ease-in-out; +} + +.pageContainer:hover { + transform: scale(1.02); +} + +.pageContainer:hover .pageNumber { + opacity: 1 !important; +} + +.pageContainer:hover .pageHoverControls { + opacity: 1 !important; +} + +/* Checkbox container - prevent transform inheritance */ +.checkboxContainer { + transform: none !important; + transition: none !important; +} + +/* Page movement animations */ +.pageMoveAnimation { + transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.pageMoving { + z-index: 10; + transform: scale(1.05); + box-shadow: 0 10px 30px rgba(0,0,0,0.3); +} + +/* Multi-page drag indicator */ +.multiDragIndicator { + position: fixed; + background: rgba(59, 130, 246, 0.9); + color: white; + padding: 8px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + backdrop-filter: blur(4px); +} + +/* Animations */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.pulse { + animation: pulse 1s infinite; +} \ No newline at end of file diff --git a/frontend/src/components/editor/PageEditor.tsx b/frontend/src/components/editor/PageEditor.tsx index b324c08af..0897266f5 100644 --- a/frontend/src/components/editor/PageEditor.tsx +++ b/frontend/src/components/editor/PageEditor.tsx @@ -4,25 +4,8 @@ import { Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container, Stack, Group, Paper, SimpleGrid } from "@mantine/core"; -import { Dropzone } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; -import UndoIcon from "@mui/icons-material/Undo"; -import RedoIcon from "@mui/icons-material/Redo"; -import AddIcon from "@mui/icons-material/Add"; -import ContentCutIcon from "@mui/icons-material/ContentCut"; -import DownloadIcon from "@mui/icons-material/Download"; -import RotateLeftIcon from "@mui/icons-material/RotateLeft"; -import RotateRightIcon from "@mui/icons-material/RotateRight"; -import DeleteIcon from "@mui/icons-material/Delete"; -import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import UploadFileIcon from "@mui/icons-material/UploadFile"; -import ConstructionIcon from "@mui/icons-material/Construction"; -import EventListIcon from "@mui/icons-material/EventList"; -import DeselectIcon from "@mui/icons-material/Deselect"; -import SelectAllIcon from "@mui/icons-material/SelectAll"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; -import CloseIcon from "@mui/icons-material/Close"; import { usePDFProcessor } from "../../hooks/usePDFProcessor"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { fileStorage } from "../../services/fileStorage"; @@ -36,13 +19,19 @@ import { ToggleSplitCommand } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; +import styles from './PageEditor.module.css'; +import PageThumbnail from './PageThumbnail'; +import BulkSelectionPanel from './BulkSelectionPanel'; +import DragDropGrid from './shared/DragDropGrid'; +import FilePickerModal from '../shared/FilePickerModal'; +import FileUploadSelector from '../shared/FileUploadSelector'; export interface PageEditorProps { file: { file: File; url: string } | null; setFile?: (file: { file: File; url: string } | null) => void; downloadUrl?: string | null; setDownloadUrl?: (url: string | null) => void; - + // Optional callbacks to expose internal functions onFunctionsReady?: (functions: { handleUndo: () => void; @@ -66,6 +55,7 @@ const PageEditor = ({ downloadUrl, setDownloadUrl, onFunctionsReady, + sharedFiles, }: PageEditorProps) => { const { t } = useTranslation(); const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); @@ -95,8 +85,38 @@ const PageEditor = ({ const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); // Process uploaded file - const handleFileUpload = useCallback(async (uploadedFile: File) => { - if (!uploadedFile || uploadedFile.type !== 'application/pdf') { + const handleFileUpload = useCallback(async (uploadedFile: File | any) => { + if (!uploadedFile) { + setError('No file provided'); + return; + } + + let fileToProcess: File; + + // Handle FileWithUrl objects from storage + if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) { + try { + console.log('Converting FileWithUrl to File:', uploadedFile.name); + const arrayBuffer = await uploadedFile.arrayBuffer(); + const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' }); + fileToProcess = new File([blob], uploadedFile.name, { + type: uploadedFile.type || 'application/pdf', + lastModified: uploadedFile.lastModified || Date.now() + }); + } catch (error) { + console.error('Error converting FileWithUrl:', error); + setError('Unable to load file from storage'); + return; + } + } else if (uploadedFile instanceof File) { + fileToProcess = uploadedFile; + } else { + setError('Invalid file object'); + console.error('handleFileUpload received unsupported object:', uploadedFile); + return; + } + + if (fileToProcess.type !== 'application/pdf') { setError('Please upload a valid PDF file'); return; } @@ -105,19 +125,22 @@ const PageEditor = ({ setError(null); try { - const document = await processPDFFile(uploadedFile); + const document = await processPDFFile(fileToProcess); setPdfDocument(document); - setFilename(uploadedFile.name.replace(/\.pdf$/i, '')); + setFilename(fileToProcess.name.replace(/\.pdf$/i, '')); setSelectedPages([]); if (document.pages.length > 0) { - const thumbnail = await generateThumbnailForFile(uploadedFile); - await fileStorage.storeFile(uploadedFile, thumbnail); + // Only store if it's a new file (not from storage) + if (!uploadedFile.storedInIndexedDB) { + const thumbnail = await generateThumbnailForFile(fileToProcess); + await fileStorage.storeFile(fileToProcess, thumbnail); + } } if (setFile) { - const fileUrl = URL.createObjectURL(uploadedFile); - setFile({ file: uploadedFile, url: fileUrl }); + const fileUrl = URL.createObjectURL(fileToProcess); + setFile({ file: fileToProcess, url: fileUrl }); } setStatus(`PDF loaded successfully with ${document.totalPages} pages`); @@ -562,18 +585,18 @@ const PageEditor = ({ }); } }, [ - onFunctionsReady, - handleUndo, - handleRedo, - canUndo, - canRedo, - handleRotate, - handleDelete, - handleSplit, - showExportPreview, - exportLoading, - selectionMode, - selectedPages, + onFunctionsReady, + handleUndo, + handleRedo, + canUndo, + canRedo, + handleRotate, + handleDelete, + handleSplit, + showExportPreview, + exportLoading, + selectionMode, + selectedPages, closePdf ]); @@ -583,26 +606,15 @@ const PageEditor = ({ - - files[0] && handleFileUpload(files[0])} - accept={["application/pdf"]} - multiple={false} - h="60vh" - style={{ minHeight: 400 }} - > -
- - - - Drop a PDF file here or click to upload - - - Supports PDF files only - - -
-
+
); @@ -610,58 +622,6 @@ const PageEditor = ({ return ( - @@ -696,365 +656,74 @@ const PageEditor = ({ {selectionMode && ( - - - setCsvInput(e.target.value)} - placeholder="1,3,5-10" - label="Page Selection" - onBlur={updatePagesFromCSV} - onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()} - style={{ flex: 1 }} - /> - - - {selectedPages.length > 0 && ( - - Selected: {selectedPages.length} pages - - )} - + )} -
- {pdfDocument.pages.map((page, index) => ( - - {page.splitBefore && index > 0 && ( -
- )} -
{ - if (el) { - pageRefs.current.set(page.id, el); - } else { - pageRefs.current.delete(page.id); - } - }} - data-page-id={page.id} - className={` - !rounded-lg - cursor-grab - select-none - w-[20rem] - h-[20rem] - flex items-center justify-center - flex-shrink-0 - shadow-sm - hover:shadow-md - transition-all - relative - ${selectionMode - ? 'bg-white hover:bg-gray-50' - : 'bg-white hover:bg-gray-50'} - ${draggedPage === page.id ? 'opacity-50 scale-95' : ''} - ${movingPage === page.id ? 'page-moving' : ''} - `} - style={{ - transform: (() => { - // Only apply drop target indication during drag - if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) { - return 'translateX(20px)'; - } - return 'translateX(0)'; - })(), - transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' - }} - draggable - onDragStart={() => handleDragStart(page.id)} + ( + handleDragEnter(page.id)} + onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, page.id)} - > - {/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */} - {selectionMode && ( -
{ - e.stopPropagation(); // Prevent drag from starting - }} - onDragStart={(e) => { - e.preventDefault(); // Prevent drag on checkbox - e.stopPropagation(); - }} - > - { - event.stopPropagation(); - togglePage(page.id); - }} - onClick={(e) => { - e.stopPropagation(); - }} - size="sm" - /> -
- )} - -
- {/* Image wrapper with simulated border */} -
- {`Page -
- - {/* Page number overlay - shows on hover */} - - {page.pageNumber} - - - {/* Hover controls */} -
- - { - e.stopPropagation(); - if (index > 0 && !movingPage && !isAnimating) { - setMovingPage(page.id); - animateReorder(page.id, index - 1); - setTimeout(() => setMovingPage(null), 500); - setStatus(`Moved page ${page.pageNumber} left`); - } - }} - > - - - - - - { - e.stopPropagation(); - if (index < pdfDocument.pages.length - 1 && !movingPage && !isAnimating) { - setMovingPage(page.id); - animateReorder(page.id, index + 1); - setTimeout(() => setMovingPage(null), 500); - setStatus(`Moved page ${page.pageNumber} right`); - } - }} - > - - - - - - { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - -90 - ); - executeCommand(command); - setStatus(`Rotated page ${page.pageNumber} left`); - }} - > - - - - - - { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - 90 - ); - executeCommand(command); - setStatus(`Rotated page ${page.pageNumber} right`); - }} - > - - - - - - { - e.stopPropagation(); - const command = new DeletePagesCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - executeCommand(command); - setStatus(`Deleted page ${page.pageNumber}`); - }} - > - - - - - {index > 0 && ( - - { - e.stopPropagation(); - const command = new ToggleSplitCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - executeCommand(command); - setStatus(`Split marker toggled for page ${page.pageNumber}`); - }} - > - - - - )} - -
- - -
-
- - ))} - - {/* Landing zone at the end */} -
+ onDrop={handleDrop} + onTogglePage={togglePage} + onAnimateReorder={animateReorder} + onExecuteCommand={executeCommand} + onSetStatus={setStatus} + onSetMovingPage={setMovingPage} + RotatePagesCommand={RotatePagesCommand} + DeletePagesCommand={DeletePagesCommand} + ToggleSplitCommand={ToggleSplitCommand} + pdfDocument={pdfDocument} + setPdfDocument={setPdfDocument} + /> + )} + renderSplitMarker={(page, index) => (
handleDrop(e, 'end')} - > - - Drop here to
move to end -
-
-
-
+ /> + )} + /> @@ -1130,18 +799,7 @@ const PageEditor = ({ )} - {/* Multi-page drag indicator */} - {multiPageDrag && dragPosition && ( -
- {multiPageDrag.count} pages -
- )} + ); }; diff --git a/frontend/src/components/editor/PageThumbnail.tsx b/frontend/src/components/editor/PageThumbnail.tsx new file mode 100644 index 000000000..b820742ae --- /dev/null +++ b/frontend/src/components/editor/PageThumbnail.tsx @@ -0,0 +1,355 @@ +import React from 'react'; +import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import RotateLeftIcon from '@mui/icons-material/RotateLeft'; +import RotateRightIcon from '@mui/icons-material/RotateRight'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ContentCutIcon from '@mui/icons-material/ContentCut'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { PDFPage } from '../../types/pageEditor'; +import styles from './PageEditor.module.css'; + +interface PageThumbnailProps { + page: PDFPage; + index: number; + totalPages: number; + selectedPages: string[]; + selectionMode: boolean; + draggedPage: string | null; + dropTarget: string | null; + movingPage: string | null; + isAnimating: boolean; + pageRefs: React.MutableRefObject>; + onDragStart: (pageId: string) => void; + onDragEnd: () => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (pageId: string) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent, pageId: string) => void; + onTogglePage: (pageId: string) => void; + onAnimateReorder: (pageId: string, targetIndex: number) => void; + onExecuteCommand: (command: any) => void; + onSetStatus: (status: string) => void; + onSetMovingPage: (pageId: string | null) => void; + RotatePagesCommand: any; + DeletePagesCommand: any; + ToggleSplitCommand: any; + pdfDocument: any; + setPdfDocument: any; +} + +const PageThumbnail = ({ + page, + index, + totalPages, + selectedPages, + selectionMode, + draggedPage, + dropTarget, + movingPage, + isAnimating, + pageRefs, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onTogglePage, + onAnimateReorder, + onExecuteCommand, + onSetStatus, + onSetMovingPage, + RotatePagesCommand, + DeletePagesCommand, + ToggleSplitCommand, + pdfDocument, + setPdfDocument, +}: PageThumbnailProps) => { + return ( +
{ + if (el) { + pageRefs.current.set(page.id, el); + } else { + pageRefs.current.delete(page.id); + } + }} + data-page-id={page.id} + className={` + ${styles.pageContainer} + !rounded-lg + cursor-grab + select-none + w-[20rem] + h-[20rem] + flex items-center justify-center + flex-shrink-0 + shadow-sm + hover:shadow-md + transition-all + relative + ${selectionMode + ? 'bg-white hover:bg-gray-50' + : 'bg-white hover:bg-gray-50'} + ${draggedPage === page.id ? 'opacity-50 scale-95' : ''} + ${movingPage === page.id ? 'page-moving' : ''} + `} + style={{ + transform: (() => { + if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) { + return 'translateX(20px)'; + } + return 'translateX(0)'; + })(), + transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' + }} + draggable + onDragStart={() => onDragStart(page.id)} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragEnter={() => onDragEnter(page.id)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, page.id)} + > + {selectionMode && ( +
e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + event.stopPropagation(); + onTogglePage(page.id); + }} + onClick={(e) => e.stopPropagation()} + size="sm" + /> +
+ )} + +
+
+ {`Page +
+ + + {page.pageNumber} + + +
+ + { + e.stopPropagation(); + if (index > 0 && !movingPage && !isAnimating) { + onSetMovingPage(page.id); + onAnimateReorder(page.id, index - 1); + setTimeout(() => onSetMovingPage(null), 500); + onSetStatus(`Moved page ${page.pageNumber} left`); + } + }} + > + + + + + + { + e.stopPropagation(); + if (index < totalPages - 1 && !movingPage && !isAnimating) { + onSetMovingPage(page.id); + onAnimateReorder(page.id, index + 1); + setTimeout(() => onSetMovingPage(null), 500); + onSetStatus(`Moved page ${page.pageNumber} right`); + } + }} + > + + + + + + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + -90 + ); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} left`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + 90 + ); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} right`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new DeletePagesCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + onExecuteCommand(command); + onSetStatus(`Deleted page ${page.pageNumber}`); + }} + > + + + + + {index > 0 && ( + + { + e.stopPropagation(); + const command = new ToggleSplitCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + onExecuteCommand(command); + onSetStatus(`Split marker toggled for page ${page.pageNumber}`); + }} + > + + + + )} +
+ + +
+
+ ); +}; + +export default PageThumbnail; \ No newline at end of file diff --git a/frontend/src/components/editor/shared/DragDropGrid.tsx b/frontend/src/components/editor/shared/DragDropGrid.tsx new file mode 100644 index 000000000..30bfe26bd --- /dev/null +++ b/frontend/src/components/editor/shared/DragDropGrid.tsx @@ -0,0 +1,131 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Box } from '@mantine/core'; +import styles from '../PageEditor.module.css'; + +interface DragDropItem { + id: string; + splitBefore?: boolean; +} + +interface DragDropGridProps { + items: T[]; + selectedItems: string[]; + selectionMode: boolean; + isAnimating: boolean; + onDragStart: (itemId: string) => void; + onDragEnd: () => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (itemId: string) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent, targetId: string | 'end') => void; + onEndZoneDragEnter: () => void; + renderItem: (item: T, index: number, refs: React.MutableRefObject>) => React.ReactNode; + renderSplitMarker?: (item: T, index: number) => React.ReactNode; + draggedItem: string | null; + dropTarget: string | null; + multiItemDrag: {itemIds: string[], count: number} | null; + dragPosition: {x: number, y: number} | null; +} + +const DragDropGrid = ({ + items, + selectedItems, + selectionMode, + isAnimating, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onEndZoneDragEnter, + renderItem, + renderSplitMarker, + draggedItem, + dropTarget, + multiItemDrag, + dragPosition, +}: DragDropGridProps) => { + const itemRefs = useRef>(new Map()); + + // Global drag cleanup + useEffect(() => { + const handleGlobalDragEnd = () => { + onDragEnd(); + }; + + const handleGlobalDrop = (e: DragEvent) => { + e.preventDefault(); + }; + + if (draggedItem) { + document.addEventListener('dragend', handleGlobalDragEnd); + document.addEventListener('drop', handleGlobalDrop); + } + + return () => { + document.removeEventListener('dragend', handleGlobalDragEnd); + document.removeEventListener('drop', handleGlobalDrop); + }; + }, [draggedItem, onDragEnd]); + + return ( + +
+ {items.map((item, index) => ( + + {/* Split marker */} + {renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)} + + {/* Item */} + {renderItem(item, index, itemRefs)} + + ))} + + {/* End drop zone */} +
+
onDrop(e, 'end')} + > +
+ Drop here to
move to end +
+
+
+
+ + {/* Multi-item drag indicator */} + {multiItemDrag && dragPosition && ( +
+ {multiItemDrag.count} items +
+ )} +
+ ); +}; + +export default DragDropGrid; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/fileManagement/FileCard.tsx index 795e838a3..a7d0bae10 100644 --- a/frontend/src/components/fileManagement/FileCard.tsx +++ b/frontend/src/components/fileManagement/FileCard.tsx @@ -1,8 +1,10 @@ -import React from "react"; -import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core"; +import React, { useState } from "react"; +import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core"; import { useTranslation } from "react-i18next"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import StorageIcon from "@mui/icons-material/Storage"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import EditIcon from "@mui/icons-material/Edit"; import { FileWithUrl } from "../../types/file"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; @@ -12,11 +14,16 @@ interface FileCardProps { file: FileWithUrl; onRemove: () => void; onDoubleClick?: () => void; + onView?: () => void; + onEdit?: () => void; + isSelected?: boolean; + onSelect?: () => void; } -const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => { +const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => { const { t } = useTranslation(); const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file); + const [isHovered, setIsHovered] = useState(false); return ( { width: 225, minWidth: 180, maxWidth: 260, - cursor: onDoubleClick ? "pointer" : undefined + cursor: onDoubleClick ? "pointer" : undefined, + position: 'relative', + border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, + backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined }} onDoubleClick={onDoubleClick} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} > { justifyContent: "center", margin: "0 auto", background: "#fafbfc", + position: 'relative' }} > + {/* Hover action buttons */} + {isHovered && (onView || onEdit) && ( +
e.stopPropagation()} + > + {onView && ( + + { + e.stopPropagation(); + onView(); + }} + > + + + + )} + {onEdit && ( + + { + e.stopPropagation(); + onEdit(); + }} + > + + + + )} +
+ )} {thumb ? ( { color="red" size="xs" variant="light" - onClick={onRemove} + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} mt={4} > {t("delete", "Remove")} diff --git a/frontend/src/components/fileManagement/FileManager.tsx b/frontend/src/components/fileManagement/FileManager.tsx index f6eb2c653..e9904a708 100644 --- a/frontend/src/components/fileManagement/FileManager.tsx +++ b/frontend/src/components/fileManagement/FileManager.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Box, Flex, Text, Notification } from "@mantine/core"; +import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core"; import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; @@ -12,6 +12,7 @@ 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"; @@ -19,22 +20,23 @@ interface FileManagerProps { files: FileWithUrl[]; setFiles: React.Dispatch>; allowMultiple?: boolean; - setPdfFile?: (fileObj: { file: File; url: string }) => void; setCurrentView?: (view: string) => void; + onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void; } const FileManager = ({ files = [], setFiles, allowMultiple = true, - setPdfFile, setCurrentView, + onOpenFileEditor, }: FileManagerProps) => { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [storageStats, setStorageStats] = useState(null); const [notification, setNotification] = useState(null); const [filesLoaded, setFilesLoaded] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); // Extract operations from service for cleaner code const { @@ -207,15 +209,47 @@ const FileManager = ({ }; const handleFileDoubleClick = async (file: FileWithUrl) => { - if (setPdfFile) { - try { - const url = await createBlobUrlForFile(file); - setPdfFile({ file: file, url: url }); - setCurrentView && setCurrentView("viewer"); - } catch (error) { - console.error('Failed to create blob URL for file:', error); - setNotification('Failed to open file. It may have been removed from storage.'); - } + try { + const url = await createBlobUrlForFile(file); + // Add file to the beginning of files array and switch to viewer + setFiles(prev => [{ file: file, url: url }, ...prev.filter(f => f.id !== file.id)]); + setCurrentView && setCurrentView("viewer"); + } catch (error) { + console.error('Failed to create blob URL for file:', error); + setNotification('Failed to open file. It may have been removed from storage.'); + } + }; + + const handleFileView = async (file: FileWithUrl) => { + try { + const url = await createBlobUrlForFile(file); + // Add file to the beginning of files array and switch to viewer + setFiles(prev => [{ file: file, url: url }, ...prev.filter(f => f.id !== file.id)]); + setCurrentView && setCurrentView("viewer"); + } catch (error) { + console.error('Failed to create blob URL for file:', error); + setNotification('Failed to open file. It may have been removed from storage.'); + } + }; + + 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); } }; @@ -230,29 +264,7 @@ const FileManager = ({ padding: "20px" }}> - {/* File Upload Dropzone */} - - - {t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")} - - + {/* File upload is now handled by FileUploadSelector when no files exist */} {/* Storage Stats Card */} + {/* Multi-selection controls */} + {selectedFiles.length > 0 && ( + + + + {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected + + + + + + + + )} + {/* Files Display */} {files.length === 0 ? ( - - {t("noFileSelected", "No files uploaded yet.")} - + { + // Handle multiple files + handleDrop(uploadedFiles); + }} + allowMultiple={allowMultiple} + accept={["application/pdf"]} + loading={loading} + showDropzone={true} + /> ) : ( handleRemoveFile(idx)} onDoubleClick={() => handleFileDoubleClick(file)} - as FileWithUrl /> + onView={() => handleFileView(file)} + onEdit={() => handleFileEdit(file)} + isSelected={selectedFiles.includes(file.id || file.name)} + onSelect={() => toggleFileSelection(file.id || file.name)} + /> ))} diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx new file mode 100644 index 000000000..bb02332a6 --- /dev/null +++ b/frontend/src/components/shared/FilePickerModal.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Text, + Button, + Group, + Stack, + Checkbox, + ScrollArea, + Box, + Image, + Badge, + ThemeIcon, + SimpleGrid +} from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import { useTranslation } from 'react-i18next'; + +interface FilePickerModalProps { + opened: boolean; + onClose: () => void; + sharedFiles: any[]; + onSelectFiles: (selectedFiles: any[]) => void; +} + +const FilePickerModal = ({ + opened, + onClose, + sharedFiles, + onSelectFiles, +}: FilePickerModalProps) => { + const { t } = useTranslation(); + const [selectedFileIds, setSelectedFileIds] = useState([]); + + // Reset selection when modal opens + useEffect(() => { + if (opened) { + setSelectedFileIds([]); + } + }, [opened]); + + const toggleFileSelection = (fileId: string) => { + setSelectedFileIds(prev => + prev.includes(fileId) + ? prev.filter(id => id !== fileId) + : [...prev, fileId] + ); + }; + + const selectAll = () => { + setSelectedFileIds(sharedFiles.map(f => f.id || f.name)); + }; + + const selectNone = () => { + setSelectedFileIds([]); + }; + + const handleConfirm = async () => { + const selectedFiles = sharedFiles.filter(f => + selectedFileIds.includes(f.id || f.name) + ); + + // Convert FileWithUrl objects to proper File objects if needed + const convertedFiles = await Promise.all( + selectedFiles.map(async (fileItem) => { + console.log('Converting file item:', fileItem); + + // If it's already a File object, return as is + if (fileItem instanceof File) { + console.log('File is already a File object'); + return fileItem; + } + + // If it has a file property, use that + if (fileItem.file && fileItem.file instanceof File) { + console.log('Using .file property'); + return fileItem.file; + } + + // If it's a FileWithUrl from storage, reconstruct the File + if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') { + try { + console.log('Reconstructing file from storage:', fileItem.name, fileItem); + const arrayBuffer = await fileItem.arrayBuffer(); + console.log('Got arrayBuffer:', arrayBuffer); + + const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' }); + console.log('Created blob:', blob); + + const reconstructedFile = new File([blob], fileItem.name, { + type: fileItem.type || 'application/pdf', + lastModified: fileItem.lastModified || Date.now() + }); + console.log('Reconstructed file:', reconstructedFile, 'instanceof File:', reconstructedFile instanceof File); + return reconstructedFile; + } catch (error) { + console.error('Error reconstructing file:', error, fileItem); + return null; + } + } + + console.log('No valid conversion method found for:', fileItem); + return null; // Don't return invalid objects + }) + ); + + // Filter out any null values from failed conversions + const validFiles = convertedFiles.filter(f => f !== null); + + onSelectFiles(validFiles); + onClose(); + }; + + const formatFileSize = (bytes: number) => { + 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(1)) + ' ' + sizes[i]; + }; + + return ( + + + {sharedFiles.length === 0 ? ( + + No files available in storage. Upload some files first. + + ) : ( + <> + {/* Selection controls */} + + + {sharedFiles.length} files available + + + + + + + + {/* File grid */} + + + {sharedFiles.map((file) => { + const fileId = file.id || file.name; + const isSelected = selectedFileIds.includes(fileId); + + return ( + toggleFileSelection(fileId)} + > + + toggleFileSelection(fileId)} + onClick={(e) => e.stopPropagation()} + /> + + {/* Thumbnail */} + + {file.thumbnail ? ( + PDF thumbnail + ) : ( + + + + )} + + + {/* File info */} + + + {file.name} + + + + {formatFileSize(file.size || (file.file?.size || 0))} + + + + + + ); + })} + + + + {/* Selection summary */} + {selectedFileIds.length > 0 && ( + + {selectedFileIds.length} file{selectedFileIds.length > 1 ? 's' : ''} selected + + )} + + )} + + {/* Action buttons */} + + + + + + + ); +}; + +export default FilePickerModal; \ No newline at end of file diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx new file mode 100644 index 000000000..929a3af49 --- /dev/null +++ b/frontend/src/components/shared/FileUploadSelector.tsx @@ -0,0 +1,142 @@ +import React, { useState, useCallback } from 'react'; +import { Stack, Button, Text, Center } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { useTranslation } from 'react-i18next'; +import FilePickerModal from './FilePickerModal'; + +interface FileUploadSelectorProps { + // Appearance + title?: string; + subtitle?: string; + showDropzone?: boolean; + + // File handling + sharedFiles?: any[]; + onFileSelect: (file: File) => void; + onFilesSelect?: (files: File[]) => void; + allowMultiple?: boolean; + accept?: string[]; + + // Loading state + loading?: boolean; + disabled?: boolean; +} + +const FileUploadSelector = ({ + title = "Select a file", + subtitle = "Choose from storage or upload a new file", + showDropzone = true, + sharedFiles = [], + onFileSelect, + onFilesSelect, + allowMultiple = false, + accept = ["application/pdf"], + loading = false, + disabled = false, +}: FileUploadSelectorProps) => { + const { t } = useTranslation(); + const [showFilePickerModal, setShowFilePickerModal] = useState(false); + + const handleFileUpload = useCallback((uploadedFiles: File[]) => { + if (uploadedFiles.length === 0) return; + + if (allowMultiple && onFilesSelect) { + onFilesSelect(uploadedFiles); + } else { + onFileSelect(uploadedFiles[0]); + } + }, [allowMultiple, onFileSelect, onFilesSelect]); + + const handleStorageSelection = useCallback((selectedFiles: File[]) => { + if (selectedFiles.length === 0) return; + + if (allowMultiple && onFilesSelect) { + onFilesSelect(selectedFiles); + } else { + onFileSelect(selectedFiles[0]); + } + }, [allowMultiple, onFileSelect, onFilesSelect]); + + return ( + <> + + {/* Title and description */} + + + + {title} + + + {subtitle} + + + + {/* Action buttons */} + + + + + or + + + {showDropzone ? ( + +
+ + + {allowMultiple ? 'Drop files here or click to upload' : 'Drop file here or click to upload'} + + + {accept.includes('application/pdf') ? 'PDF files only' : 'Supported file types'} + + +
+
+ ) : ( + + + + )} +
+
+ + {/* File Picker Modal */} + setShowFilePickerModal(false)} + sharedFiles={sharedFiles} + onSelectFiles={handleStorageSelection} + /> + + ); +}; + +export default FileUploadSelector; \ No newline at end of file diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 3abfc83af..13c82103f 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -9,6 +9,7 @@ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import VisibilityIcon from "@mui/icons-material/Visibility"; import EditNoteIcon from "@mui/icons-material/EditNote"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import FolderIcon from "@mui/icons-material/Folder"; import { Group } from "@mantine/core"; const VIEW_OPTIONS = [ @@ -36,6 +37,14 @@ const VIEW_OPTIONS = [ ), value: "fileManager", }, + { + label: ( + + + + ), + value: "fileEditor", + }, ]; interface TopControlsProps { diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index aa10e160a..8c2e89c78 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -123,7 +123,7 @@ const LazyPageImage = ({ }; export interface ViewerProps { - pdfFile: { file: File; url: string } | null; + pdfFile: { file: File; url: string } | null; // First file in the array setPdfFile: (file: { file: File; url: string } | null) => void; sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 605b7a68a..ea367253b 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,98 +1,32 @@ -import React, { useState, useCallback, useEffect } from "react"; -import { useTranslation } from 'react-i18next'; -import { useSearchParams } from "react-router-dom"; -import { useToolParams } from "../hooks/useToolParams"; -import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; -import ContentCutIcon from "@mui/icons-material/ContentCut"; -import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; -import { Group, Paper, Box, Button, useMantineTheme } from "@mantine/core"; -import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; -import rainbowStyles from '../styles/rainbow.module.css'; - -import ToolPicker from "../components/tools/ToolPicker"; +import React, { useState, useCallback } from "react"; +import { Box, Group, Container } from "@mantine/core"; +import TopControls from "../components/shared/TopControls"; import FileManager from "../components/fileManagement/FileManager"; -import SplitPdfPanel from "../tools/Split"; -import CompressPdfPanel from "../tools/Compress"; -import MergePdfPanel from "../tools/Merge"; +import FileEditor from "../components/editor/FileEditor"; import PageEditor from "../components/editor/PageEditor"; import PageEditorControls from "../components/editor/PageEditorControls"; import Viewer from "../components/viewer/Viewer"; -import TopControls from "../components/shared/TopControls"; -import ToolRenderer from "../components/tools/ToolRenderer"; -import QuickAccessBar from "../components/shared/QuickAccessBar"; - -type ToolRegistryEntry = { - icon: React.ReactNode; - name: string; - component: React.ComponentType; - view: string; -}; - -type ToolRegistry = { - [key: string]: ToolRegistryEntry; -}; - -// Base tool registry without translations -const baseToolRegistry = { - split: { icon: , component: SplitPdfPanel, view: "viewer" }, - compress: { icon: , component: CompressPdfPanel, view: "viewer" }, - merge: { icon: , component: MergePdfPanel, view: "fileManager" }, -}; - - +import FileUploadSelector from "../components/shared/FileUploadSelector"; export default function HomePage() { - const { t } = useTranslation(); - const [searchParams] = useSearchParams(); - const theme = useMantineTheme(); - const { isRainbowMode } = useRainbowThemeContext(); - - // Core app state - const [selectedToolKey, setSelectedToolKey] = useState(searchParams.get("t") || "split"); - const [currentView, setCurrentView] = useState(searchParams.get("v") || "viewer"); - const [pdfFile, setPdfFile] = useState(null); - const [files, setFiles] = useState([]); - const [downloadUrl, setDownloadUrl] = useState(null); + const [files, setFiles] = useState([]); // Array of { file, url } + const [preSelectedFiles, setPreSelectedFiles] = useState([]); + const [currentView, setCurrentView] = useState("fileManager"); const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); - - // Page editor functions - const [pageEditorFunctions, setPageEditorFunctions] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const [pageEditorFunctions, setPageEditorFunctions] = useState(null); - // URL parameter management - const { toolParams, updateParams } = useToolParams(selectedToolKey, currentView); - - const toolRegistry: ToolRegistry = { - split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") }, - compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") }, - merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") }, - }; - - - // Handle tool selection - const handleToolSelect = useCallback( - (id: string) => { - setSelectedToolKey(id); - if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); - setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected - setReaderMode(false); // Exit reader mode when selecting a tool - }, - [toolRegistry] - ); - - // Handle quick access actions - const handleQuickAccessTools = useCallback(() => { - setLeftPanelView('toolPicker'); - setReaderMode(false); + // Handle file selection from upload + const handleFileSelect = useCallback((file) => { + const fileObj = { file, url: URL.createObjectURL(file) }; + setFiles([fileObj]); }, []); - - const handleReaderToggle = useCallback(() => { - setReaderMode(!readerMode); - }, [readerMode]); - - const selectedTool = toolRegistry[selectedToolKey]; + // Handle opening file editor with selected files + const handleOpenFileEditor = useCallback((selectedFiles) => { + setPreSelectedFiles(selectedFiles || []); + setCurrentView("fileEditor"); + }, []); return ( - {/* Quick Access Bar */} - - - {/* Left: Tool Picker OR Selected Tool Panel */} -
-
- {leftPanelView === 'toolPicker' ? ( - // Tool Picker View -
- -
- ) : ( - // Selected Tool Content View -
- {/* Back button */} -
- -
- - {/* Tool title */} -
-

{selectedTool?.name}

-
- - {/* Tool content */} -
- -
-
- )} -
-
- - {/* Main View */} - @@ -192,57 +46,86 @@ export default function HomePage() { setCurrentView={setCurrentView} /> {/* Main content area */} - - {(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? ( - + {currentView === "fileManager" ? ( + + ) : (currentView !== "fileManager") && !files[0] ? ( + + - ) : currentView === "viewer" ? ( - + ) : currentView === "fileEditor" ? ( + setPreSelectedFiles([])} + onOpenPageEditor={(file) => { + const fileObj = { file, url: URL.createObjectURL(file) }; + setFiles([fileObj]); + setCurrentView("pageEditor"); + }} + onMergeFiles={(filesToMerge) => { + setFiles(filesToMerge.map(f => ({ file: f, url: URL.createObjectURL(f) }))); + setCurrentView("viewer"); + }} + /> + ) : currentView === "viewer" ? ( + setFiles([fileObj])} + sidebarsVisible={sidebarsVisible} + setSidebarsVisible={setSidebarsVisible} + /> + ) : currentView === "pageEditor" ? ( + <> + setFiles([fileObj])} + downloadUrl={downloadUrl} + setDownloadUrl={setDownloadUrl} + onFunctionsReady={setPageEditorFunctions} + sharedFiles={files} /> - ) : currentView === "pageEditor" ? ( - <> - pageEditorFunctions.showExportPreview(true)} + onExportAll={() => pageEditorFunctions.showExportPreview(false)} + exportLoading={pageEditorFunctions.exportLoading} + selectionMode={pageEditorFunctions.selectionMode} + selectedPages={pageEditorFunctions.selectedPages} /> - {pdfFile && pageEditorFunctions && ( - pageEditorFunctions.showExportPreview(true)} - onExportAll={() => pageEditorFunctions.showExportPreview(false)} - exportLoading={pageEditorFunctions.exportLoading} - selectionMode={pageEditorFunctions.selectionMode} - selectedPages={pageEditorFunctions.selectedPages} - /> - )} - - ) : ( - - )} - + )} + + ) : ( + + )} +
);