mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-25 13:47:39 +02:00
File editor groundwork and refactor work
This commit is contained in:
parent
9c410865f9
commit
3ebf75ae6f
42
frontend/src/components/editor/BulkSelectionPanel.tsx
Normal file
42
frontend/src/components/editor/BulkSelectionPanel.tsx
Normal file
@ -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 (
|
||||||
|
<Paper p="md" mb="md" withBorder>
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
value={csvInput}
|
||||||
|
onChange={(e) => setCsvInput(e.target.value)}
|
||||||
|
placeholder="1,3,5-10"
|
||||||
|
label="Page Selection"
|
||||||
|
onBlur={onUpdatePagesFromCSV}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={onUpdatePagesFromCSV} mt="xl">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{selectedPages.length > 0 && (
|
||||||
|
<Text size="sm" c="dimmed" mt="sm">
|
||||||
|
Selected: {selectedPages.length} pages
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkSelectionPanel;
|
515
frontend/src/components/editor/FileEditor.tsx
Normal file
515
frontend/src/components/editor/FileEditor.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [csvInput, setCsvInput] = useState<string>('');
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<string | null>(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<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
// Convert shared files to FileEditor format
|
||||||
|
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||||
|
// 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 (
|
||||||
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||||
|
<LoadingOverlay visible={loading} />
|
||||||
|
|
||||||
|
<Box p="md" pt="xl">
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
variant={selectionMode ? "filled" : "outline"}
|
||||||
|
color={selectionMode ? "blue" : "gray"}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
...(selectionMode && {
|
||||||
|
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectionMode ? "Exit Selection" : "Select Files"}
|
||||||
|
</Button>
|
||||||
|
{selectionMode && (
|
||||||
|
<>
|
||||||
|
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||||
|
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load from storage and upload buttons */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="blue"
|
||||||
|
onClick={() => setShowFilePickerModal(true)}
|
||||||
|
>
|
||||||
|
Load from Storage
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleFileUpload}
|
||||||
|
accept={["application/pdf"]}
|
||||||
|
multiple={true}
|
||||||
|
maxSize={2 * 1024 * 1024 * 1024}
|
||||||
|
style={{ display: 'contents' }}
|
||||||
|
>
|
||||||
|
<Button variant="outline" color="green">
|
||||||
|
Upload Files
|
||||||
|
</Button>
|
||||||
|
</Dropzone>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{selectionMode && (
|
||||||
|
<BulkSelectionPanel
|
||||||
|
csvInput={csvInput}
|
||||||
|
setCsvInput={setCsvInput}
|
||||||
|
selectedPages={selectedFiles}
|
||||||
|
onUpdatePagesFromCSV={updateFilesFromCSV}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DragDropGrid
|
||||||
|
items={files}
|
||||||
|
selectedItems={selectedFiles}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||||
|
draggedItem={draggedFile}
|
||||||
|
dropTarget={dropTarget}
|
||||||
|
multiItemDrag={multiFileDrag}
|
||||||
|
dragPosition={dragPosition}
|
||||||
|
renderItem={(file, index, refs) => (
|
||||||
|
<FileThumbnail
|
||||||
|
file={file}
|
||||||
|
index={index}
|
||||||
|
totalFiles={files.length}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
draggedFile={draggedFile}
|
||||||
|
dropTarget={dropTarget}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
fileRefs={refs}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onToggleFile={toggleFile}
|
||||||
|
onDeleteFile={handleDeleteFile}
|
||||||
|
onViewFile={handleViewFile}
|
||||||
|
onMergeFromHere={handleMergeFromHere}
|
||||||
|
onSplitFile={handleSplitFile}
|
||||||
|
onSetStatus={setStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderSplitMarker={(file, index) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '2px',
|
||||||
|
height: '24rem',
|
||||||
|
borderLeft: '2px dashed #3b82f6',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
marginLeft: '-0.75rem',
|
||||||
|
marginRight: '-0.75rem',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File Picker Modal */}
|
||||||
|
<FilePickerModal
|
||||||
|
opened={showFilePickerModal}
|
||||||
|
onClose={() => setShowFilePickerModal(false)}
|
||||||
|
sharedFiles={sharedFiles || []}
|
||||||
|
onSelectFiles={handleLoadFromStorage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<Notification
|
||||||
|
color="blue"
|
||||||
|
mt="md"
|
||||||
|
onClose={() => setStatus(null)}
|
||||||
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Notification>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Notification
|
||||||
|
color="red"
|
||||||
|
mt="md"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Notification>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileEditor;
|
327
frontend/src/components/editor/FileThumbnail.tsx
Normal file
327
frontend/src/components/editor/FileThumbnail.tsx
Normal file
@ -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<Map<string, HTMLDivElement>>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
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 && (
|
||||||
|
<div
|
||||||
|
className={styles.checkboxContainer}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 4,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedFiles.includes(file.id)}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleFile(file.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File content area */}
|
||||||
|
<div className="file-container w-[90%] h-[80%] relative">
|
||||||
|
{/* Stacked file effect - multiple shadows to simulate pages */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={file.thumbnail}
|
||||||
|
alt={file.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page count badge */}
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.pageCount} pages
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* File name overlay */}
|
||||||
|
<Text
|
||||||
|
className={styles.pageNumber}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
c="white"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 5,
|
||||||
|
left: 5,
|
||||||
|
right: 5,
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
padding: '4px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
zIndex: 2,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Hover controls */}
|
||||||
|
<div
|
||||||
|
className={styles.pageHoverControls}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
zIndex: 3,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip label="View File">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewFile(file.id);
|
||||||
|
onSetStatus(`Opened ${file.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Merge from here">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMergeFromHere(file.id);
|
||||||
|
onSetStatus(`Starting merge from ${file.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MergeIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Split File">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSplitFile(file.id);
|
||||||
|
onSetStatus(`Opening ${file.name} in page editor`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SplitscreenIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Delete File">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="red"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteFile(file.id);
|
||||||
|
onSetStatus(`Deleted ${file.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragIndicatorIcon
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
color: 'rgba(0,0,0,0.3)',
|
||||||
|
fontSize: 16,
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<div className="w-full px-4 py-2 text-center">
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileThumbnail;
|
63
frontend/src/components/editor/PageEditor.module.css
Normal file
63
frontend/src/components/editor/PageEditor.module.css
Normal file
@ -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;
|
||||||
|
}
|
@ -4,25 +4,8 @@ import {
|
|||||||
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
||||||
Stack, Group, Paper, SimpleGrid
|
Stack, Group, Paper, SimpleGrid
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 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 { usePDFProcessor } from "../../hooks/usePDFProcessor";
|
||||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
@ -36,6 +19,12 @@ import {
|
|||||||
ToggleSplitCommand
|
ToggleSplitCommand
|
||||||
} from "../../commands/pageCommands";
|
} from "../../commands/pageCommands";
|
||||||
import { pdfExportService } from "../../services/pdfExportService";
|
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 {
|
export interface PageEditorProps {
|
||||||
file: { file: File; url: string } | null;
|
file: { file: File; url: string } | null;
|
||||||
@ -66,6 +55,7 @@ const PageEditor = ({
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
setDownloadUrl,
|
setDownloadUrl,
|
||||||
onFunctionsReady,
|
onFunctionsReady,
|
||||||
|
sharedFiles,
|
||||||
}: PageEditorProps) => {
|
}: PageEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
||||||
@ -95,8 +85,38 @@ const PageEditor = ({
|
|||||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||||
|
|
||||||
// Process uploaded file
|
// Process uploaded file
|
||||||
const handleFileUpload = useCallback(async (uploadedFile: File) => {
|
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
|
||||||
if (!uploadedFile || uploadedFile.type !== 'application/pdf') {
|
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');
|
setError('Please upload a valid PDF file');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -105,19 +125,22 @@ const PageEditor = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const document = await processPDFFile(uploadedFile);
|
const document = await processPDFFile(fileToProcess);
|
||||||
setPdfDocument(document);
|
setPdfDocument(document);
|
||||||
setFilename(uploadedFile.name.replace(/\.pdf$/i, ''));
|
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
|
||||||
setSelectedPages([]);
|
setSelectedPages([]);
|
||||||
|
|
||||||
if (document.pages.length > 0) {
|
if (document.pages.length > 0) {
|
||||||
const thumbnail = await generateThumbnailForFile(uploadedFile);
|
// Only store if it's a new file (not from storage)
|
||||||
await fileStorage.storeFile(uploadedFile, thumbnail);
|
if (!uploadedFile.storedInIndexedDB) {
|
||||||
|
const thumbnail = await generateThumbnailForFile(fileToProcess);
|
||||||
|
await fileStorage.storeFile(fileToProcess, thumbnail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setFile) {
|
if (setFile) {
|
||||||
const fileUrl = URL.createObjectURL(uploadedFile);
|
const fileUrl = URL.createObjectURL(fileToProcess);
|
||||||
setFile({ file: uploadedFile, url: fileUrl });
|
setFile({ file: fileToProcess, url: fileUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
||||||
@ -583,26 +606,15 @@ const PageEditor = ({
|
|||||||
<LoadingOverlay visible={loading || pdfLoading} />
|
<LoadingOverlay visible={loading || pdfLoading} />
|
||||||
|
|
||||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FileUploadSelector
|
||||||
<Dropzone
|
title="Select a PDF to edit"
|
||||||
onDrop={(files) => files[0] && handleFileUpload(files[0])}
|
subtitle="Choose a file from storage or upload a new PDF"
|
||||||
|
sharedFiles={sharedFiles || []}
|
||||||
|
onFileSelect={handleFileUpload}
|
||||||
|
allowMultiple={false}
|
||||||
accept={["application/pdf"]}
|
accept={["application/pdf"]}
|
||||||
multiple={false}
|
loading={loading || pdfLoading}
|
||||||
h="60vh"
|
/>
|
||||||
style={{ minHeight: 400 }}
|
|
||||||
>
|
|
||||||
<Center h="100%">
|
|
||||||
<Stack align="center" gap="md">
|
|
||||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
|
||||||
<Text size="xl" fw={500}>
|
|
||||||
Drop a PDF file here or click to upload
|
|
||||||
</Text>
|
|
||||||
<Text size="md" c="dimmed">
|
|
||||||
Supports PDF files only
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Dropzone>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@ -610,58 +622,6 @@ const PageEditor = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
.page-container:hover .page-number {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
.page-container:hover .page-hover-controls {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
.page-container {
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
.page-container:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
.checkbox-container {
|
|
||||||
transform: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
.page-move-animation {
|
|
||||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
||||||
}
|
|
||||||
.page-moving {
|
|
||||||
z-index: 10;
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.multi-drag-indicator {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<LoadingOverlay visible={loading || pdfLoading} />
|
<LoadingOverlay visible={loading || pdfLoading} />
|
||||||
|
|
||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
@ -696,41 +656,61 @@ const PageEditor = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<Paper p="md" mb="md" withBorder>
|
<BulkSelectionPanel
|
||||||
<Group>
|
csvInput={csvInput}
|
||||||
<TextInput
|
setCsvInput={setCsvInput}
|
||||||
value={csvInput}
|
selectedPages={selectedPages}
|
||||||
onChange={(e) => setCsvInput(e.target.value)}
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||||
placeholder="1,3,5-10"
|
|
||||||
label="Page Selection"
|
|
||||||
onBlur={updatePagesFromCSV}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
/>
|
||||||
<Button onClick={updatePagesFromCSV} mt="xl">
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
{selectedPages.length > 0 && (
|
|
||||||
<Text size="sm" c="dimmed" mt="sm">
|
|
||||||
Selected: {selectedPages.length} pages
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<DragDropGrid
|
||||||
style={{
|
items={pdfDocument.pages}
|
||||||
display: 'flex',
|
selectedItems={selectedPages}
|
||||||
flexWrap: 'wrap',
|
selectionMode={selectionMode}
|
||||||
gap: '1.5rem',
|
isAnimating={isAnimating}
|
||||||
justifyContent: 'flex-start',
|
onDragStart={handleDragStart}
|
||||||
paddingBottom: '100px' // Add space for floating control bar
|
onDragEnd={handleDragEnd}
|
||||||
}}
|
onDragOver={handleDragOver}
|
||||||
>
|
onDragEnter={handleDragEnter}
|
||||||
{pdfDocument.pages.map((page, index) => (
|
onDragLeave={handleDragLeave}
|
||||||
<React.Fragment key={page.id}>
|
onDrop={handleDrop}
|
||||||
{page.splitBefore && index > 0 && (
|
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||||
|
draggedItem={draggedPage}
|
||||||
|
dropTarget={dropTarget}
|
||||||
|
multiItemDrag={multiPageDrag}
|
||||||
|
dragPosition={dragPosition}
|
||||||
|
renderItem={(page, index, refs) => (
|
||||||
|
<PageThumbnail
|
||||||
|
page={page}
|
||||||
|
index={index}
|
||||||
|
totalPages={pdfDocument.pages.length}
|
||||||
|
selectedPages={selectedPages}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
draggedPage={draggedPage}
|
||||||
|
dropTarget={dropTarget}
|
||||||
|
movingPage={movingPage}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
pageRefs={refs}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
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) => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '2px',
|
width: '2px',
|
||||||
@ -743,318 +723,7 @@ const PageEditor = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
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)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={() => handleDragEnter(page.id)}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={(e) => handleDrop(e, page.id)}
|
|
||||||
>
|
|
||||||
{/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */}
|
|
||||||
{selectionMode && (
|
|
||||||
<div
|
|
||||||
className="checkbox-container"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
zIndex: 4,
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '2px',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
||||||
pointerEvents: 'auto' // Ensure checkbox can be clicked
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent drag from starting
|
|
||||||
}}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
e.preventDefault(); // Prevent drag on checkbox
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedPages.includes(page.id)}
|
|
||||||
onChange={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
togglePage(page.id);
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]">
|
|
||||||
{/* Image wrapper with simulated border */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
|
||||||
padding: 4,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={page.thumbnail}
|
|
||||||
alt={`Page ${page.pageNumber}`}
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
borderRadius: 2,
|
|
||||||
transform: `rotate(${page.rotation}deg)`,
|
|
||||||
transition: 'transform 0.3s ease-in-out'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page number overlay - shows on hover */}
|
|
||||||
<Text
|
|
||||||
className="page-number"
|
|
||||||
size="sm"
|
|
||||||
fw={500}
|
|
||||||
c="white"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 5,
|
|
||||||
left: 5,
|
|
||||||
background: 'rgba(162, 201, 255, 0.8)',
|
|
||||||
padding: '6px 8px',
|
|
||||||
borderRadius: 8,
|
|
||||||
zIndex: 2,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease-in-out'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{page.pageNumber}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Hover controls */}
|
|
||||||
<div
|
|
||||||
className="page-hover-controls"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 20,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease-in-out',
|
|
||||||
zIndex: 3,
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'center',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip label="Move Left">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={(e) => {
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowBackIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Move Right">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
disabled={index === pdfDocument.pages.length - 1}
|
|
||||||
onClick={(e) => {
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Rotate Left">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const command = new RotatePagesCommand(
|
|
||||||
pdfDocument,
|
|
||||||
setPdfDocument,
|
|
||||||
[page.id],
|
|
||||||
-90
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setStatus(`Rotated page ${page.pageNumber} left`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Rotate Right">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const command = new RotatePagesCommand(
|
|
||||||
pdfDocument,
|
|
||||||
setPdfDocument,
|
|
||||||
[page.id],
|
|
||||||
90
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setStatus(`Rotated page ${page.pageNumber} right`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label="Delete Page">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="red"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const command = new DeletePagesCommand(
|
|
||||||
pdfDocument,
|
|
||||||
setPdfDocument,
|
|
||||||
[page.id]
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setStatus(`Deleted page ${page.pageNumber}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{index > 0 && (
|
|
||||||
<Tooltip label="Split Here">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const command = new ToggleSplitCommand(
|
|
||||||
pdfDocument,
|
|
||||||
setPdfDocument,
|
|
||||||
[page.id]
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setStatus(`Split marker toggled for page ${page.pageNumber}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragIndicatorIcon
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
color: 'rgba(0,0,0,0.3)',
|
|
||||||
fontSize: 16,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Landing zone at the end */}
|
|
||||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
|
||||||
<div
|
|
||||||
data-drop-zone="end"
|
|
||||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${dropTarget === 'end' ? 'ring-2 ring-green-500 bg-green-50' : 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'}`}
|
|
||||||
style={{
|
|
||||||
borderRadius: '12px'
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleEndZoneDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={(e) => handleDrop(e, 'end')}
|
|
||||||
>
|
|
||||||
<Text c="dimmed" size="sm" ta="center" fw={500}>
|
|
||||||
Drop here to<br />move to end
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
@ -1130,18 +799,7 @@ const PageEditor = ({
|
|||||||
</Notification>
|
</Notification>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Multi-page drag indicator */}
|
|
||||||
{multiPageDrag && dragPosition && (
|
|
||||||
<div
|
|
||||||
className="multi-drag-indicator"
|
|
||||||
style={{
|
|
||||||
left: dragPosition.x,
|
|
||||||
top: dragPosition.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{multiPageDrag.count} pages
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
355
frontend/src/components/editor/PageThumbnail.tsx
Normal file
355
frontend/src/components/editor/PageThumbnail.tsx
Normal file
@ -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<Map<string, HTMLDivElement>>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
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 && (
|
||||||
|
<div
|
||||||
|
className={styles.checkboxContainer}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 4,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedPages.includes(page.id)}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTogglePage(page.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="page-container w-[90%] h-[90%]">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={page.thumbnail}
|
||||||
|
alt={`Page ${page.pageNumber}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 2,
|
||||||
|
transform: `rotate(${page.rotation}deg)`,
|
||||||
|
transition: 'transform 0.3s ease-in-out'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className={styles.pageNumber}
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
c="white"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 5,
|
||||||
|
left: 5,
|
||||||
|
background: 'rgba(162, 201, 255, 0.8)',
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
zIndex: 2,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page.pageNumber}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.pageHoverControls}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 20,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
zIndex: 3,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip label="Move Left">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Move Right">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
disabled={index === totalPages - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Rotate Left">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const command = new RotatePagesCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
[page.id],
|
||||||
|
-90
|
||||||
|
);
|
||||||
|
onExecuteCommand(command);
|
||||||
|
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Rotate Right">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const command = new RotatePagesCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
[page.id],
|
||||||
|
90
|
||||||
|
);
|
||||||
|
onExecuteCommand(command);
|
||||||
|
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Delete Page">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="red"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const command = new DeletePagesCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
[page.id]
|
||||||
|
);
|
||||||
|
onExecuteCommand(command);
|
||||||
|
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{index > 0 && (
|
||||||
|
<Tooltip label="Split Here">
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c="white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const command = new ToggleSplitCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
[page.id]
|
||||||
|
);
|
||||||
|
onExecuteCommand(command);
|
||||||
|
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragIndicatorIcon
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
color: 'rgba(0,0,0,0.3)',
|
||||||
|
fontSize: 16,
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageThumbnail;
|
131
frontend/src/components/editor/shared/DragDropGrid.tsx
Normal file
131
frontend/src/components/editor/shared/DragDropGrid.tsx
Normal file
@ -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<T extends DragDropItem> {
|
||||||
|
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<Map<string, HTMLDivElement>>) => 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 = <T extends DragDropItem>({
|
||||||
|
items,
|
||||||
|
selectedItems,
|
||||||
|
selectionMode,
|
||||||
|
isAnimating,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onEndZoneDragEnter,
|
||||||
|
renderItem,
|
||||||
|
renderSplitMarker,
|
||||||
|
draggedItem,
|
||||||
|
dropTarget,
|
||||||
|
multiItemDrag,
|
||||||
|
dragPosition,
|
||||||
|
}: DragDropGridProps<T>) => {
|
||||||
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(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 (
|
||||||
|
<Box>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '1.5rem',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
paddingBottom: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{/* Split marker */}
|
||||||
|
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
||||||
|
|
||||||
|
{/* Item */}
|
||||||
|
{renderItem(item, index, itemRefs)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* End drop zone */}
|
||||||
|
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
||||||
|
<div
|
||||||
|
data-drop-zone="end"
|
||||||
|
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
||||||
|
dropTarget === 'end'
|
||||||
|
? 'ring-2 ring-green-500 bg-green-50'
|
||||||
|
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
||||||
|
}`}
|
||||||
|
style={{ borderRadius: '12px' }}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnter={onEndZoneDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, 'end')}
|
||||||
|
>
|
||||||
|
<div className="text-gray-500 text-sm text-center font-medium">
|
||||||
|
Drop here to<br />move to end
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multi-item drag indicator */}
|
||||||
|
{multiItemDrag && dragPosition && (
|
||||||
|
<div
|
||||||
|
className={styles.multiDragIndicator}
|
||||||
|
style={{
|
||||||
|
left: dragPosition.x,
|
||||||
|
top: dragPosition.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{multiItemDrag.count} items
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DragDropGrid;
|
@ -1,8 +1,10 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
|
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
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 { FileWithUrl } from "../../types/file";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
@ -12,11 +14,16 @@ interface FileCardProps {
|
|||||||
file: FileWithUrl;
|
file: FileWithUrl;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDoubleClick?: () => 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 { t } = useTranslation();
|
||||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -28,9 +35,15 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
|
|||||||
width: 225,
|
width: 225,
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
maxWidth: 260,
|
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}
|
onDoubleClick={onDoubleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Box
|
<Box
|
||||||
@ -44,8 +57,57 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
background: "#fafbfc",
|
background: "#fafbfc",
|
||||||
|
position: 'relative'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Hover action buttons */}
|
||||||
|
{isHovered && (onView || onEdit) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 4,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 2
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{onView && (
|
||||||
|
<Tooltip label="View in Viewer">
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onView();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<Tooltip label="Open in File Editor">
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="orange"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{thumb ? (
|
{thumb ? (
|
||||||
<Image
|
<Image
|
||||||
src={thumb}
|
src={thumb}
|
||||||
@ -123,7 +185,10 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
|
|||||||
color="red"
|
color="red"
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={onRemove}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
mt={4}
|
mt={4}
|
||||||
>
|
>
|
||||||
{t("delete", "Remove")}
|
{t("delete", "Remove")}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
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 { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ import { fileOperationsService } from "../../services/fileOperationsService";
|
|||||||
import { checkStorageWarnings } from "../../utils/storageUtils";
|
import { checkStorageWarnings } from "../../utils/storageUtils";
|
||||||
import StorageStatsCard from "./StorageStatsCard";
|
import StorageStatsCard from "./StorageStatsCard";
|
||||||
import FileCard from "./FileCard";
|
import FileCard from "./FileCard";
|
||||||
|
import FileUploadSelector from "../shared/FileUploadSelector";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||||
|
|
||||||
@ -19,22 +20,23 @@ interface FileManagerProps {
|
|||||||
files: FileWithUrl[];
|
files: FileWithUrl[];
|
||||||
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
|
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
|
||||||
allowMultiple?: boolean;
|
allowMultiple?: boolean;
|
||||||
setPdfFile?: (fileObj: { file: File; url: string }) => void;
|
|
||||||
setCurrentView?: (view: string) => void;
|
setCurrentView?: (view: string) => void;
|
||||||
|
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileManager = ({
|
const FileManager = ({
|
||||||
files = [],
|
files = [],
|
||||||
setFiles,
|
setFiles,
|
||||||
allowMultiple = true,
|
allowMultiple = true,
|
||||||
setPdfFile,
|
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
|
onOpenFileEditor,
|
||||||
}: FileManagerProps) => {
|
}: FileManagerProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||||
const [notification, setNotification] = useState<string | null>(null);
|
const [notification, setNotification] = useState<string | null>(null);
|
||||||
const [filesLoaded, setFilesLoaded] = useState(false);
|
const [filesLoaded, setFilesLoaded] = useState(false);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
// Extract operations from service for cleaner code
|
// Extract operations from service for cleaner code
|
||||||
const {
|
const {
|
||||||
@ -207,15 +209,47 @@ const FileManager = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFileDoubleClick = async (file: FileWithUrl) => {
|
const handleFileDoubleClick = async (file: FileWithUrl) => {
|
||||||
if (setPdfFile) {
|
|
||||||
try {
|
try {
|
||||||
const url = await createBlobUrlForFile(file);
|
const url = await createBlobUrlForFile(file);
|
||||||
setPdfFile({ file: file, url: url });
|
// 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");
|
setCurrentView && setCurrentView("viewer");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create blob URL for file:', error);
|
console.error('Failed to create blob URL for file:', error);
|
||||||
setNotification('Failed to open file. It may have been removed from storage.');
|
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"
|
padding: "20px"
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* File Upload Dropzone */}
|
{/* File upload is now handled by FileUploadSelector when no files exist */}
|
||||||
<Dropzone
|
|
||||||
onDrop={handleDrop}
|
|
||||||
accept={[MIME_TYPES.pdf]}
|
|
||||||
multiple={allowMultiple}
|
|
||||||
maxSize={2 * 1024 * 1024 * 1024} // 2GB limit
|
|
||||||
loading={loading}
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
border: "2px dashed rgb(202, 202, 202)",
|
|
||||||
borderRadius: 8,
|
|
||||||
minHeight: 120,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "90%"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="md">
|
|
||||||
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
|
|
||||||
</Text>
|
|
||||||
</Dropzone>
|
|
||||||
|
|
||||||
{/* Storage Stats Card */}
|
{/* Storage Stats Card */}
|
||||||
<StorageStatsCard
|
<StorageStatsCard
|
||||||
@ -262,11 +274,49 @@ const FileManager = ({
|
|||||||
onReloadFiles={handleReloadFiles}
|
onReloadFiles={handleReloadFiles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Multi-selection controls */}
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">
|
||||||
|
{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setSelectedFiles([])}
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="orange"
|
||||||
|
onClick={handleOpenSelectedInEditor}
|
||||||
|
disabled={selectedFiles.length === 0}
|
||||||
|
>
|
||||||
|
Open in File Editor
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Files Display */}
|
{/* Files Display */}
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center">
|
<FileUploadSelector
|
||||||
{t("noFileSelected", "No files uploaded yet.")}
|
title="Upload PDF Files"
|
||||||
</Text>
|
subtitle="Add files to your storage for easy access across tools"
|
||||||
|
sharedFiles={[]} // FileManager is the source, so no shared files
|
||||||
|
onFilesSelect={(uploadedFiles) => {
|
||||||
|
// Handle multiple files
|
||||||
|
handleDrop(uploadedFiles);
|
||||||
|
}}
|
||||||
|
allowMultiple={allowMultiple}
|
||||||
|
accept={["application/pdf"]}
|
||||||
|
loading={loading}
|
||||||
|
showDropzone={true}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Flex
|
<Flex
|
||||||
@ -281,7 +331,11 @@ const FileManager = ({
|
|||||||
file={file}
|
file={file}
|
||||||
onRemove={() => handleRemoveFile(idx)}
|
onRemove={() => handleRemoveFile(idx)}
|
||||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||||
as FileWithUrl />
|
onView={() => handleFileView(file)}
|
||||||
|
onEdit={() => handleFileEdit(file)}
|
||||||
|
isSelected={selectedFiles.includes(file.id || file.name)}
|
||||||
|
onSelect={() => toggleFileSelection(file.id || file.name)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
260
frontend/src/components/shared/FilePickerModal.tsx
Normal file
260
frontend/src/components/shared/FilePickerModal.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Select Files from Storage"
|
||||||
|
size="lg"
|
||||||
|
scrollAreaComponent={ScrollArea.Autosize}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{sharedFiles.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No files available in storage. Upload some files first.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Selection controls */}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{sharedFiles.length} files available
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button size="xs" variant="light" onClick={selectAll}>
|
||||||
|
Select All
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="light" onClick={selectNone}>
|
||||||
|
Select None
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* File grid */}
|
||||||
|
<ScrollArea.Autosize mah={400}>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
{sharedFiles.map((file) => {
|
||||||
|
const fileId = file.id || file.name;
|
||||||
|
const isSelected = selectedFileIds.includes(fileId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={fileId}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: isSelected
|
||||||
|
? '2px solid var(--mantine-color-blue-6)'
|
||||||
|
: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'var(--mantine-color-blue-0)'
|
||||||
|
: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => toggleFileSelection(fileId)}
|
||||||
|
>
|
||||||
|
<Group gap="sm" align="flex-start">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleFileSelection(fileId)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 80,
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.thumbnail ? (
|
||||||
|
<Image
|
||||||
|
src={file.thumbnail}
|
||||||
|
alt="PDF thumbnail"
|
||||||
|
height={70}
|
||||||
|
width={50}
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size={40}
|
||||||
|
>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} lineClamp={2}>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{formatFileSize(file.size || (file.file?.size || 0))}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
|
{/* Selection summary */}
|
||||||
|
{selectedFileIds.length > 0 && (
|
||||||
|
<Text size="sm" c="blue" ta="center">
|
||||||
|
{selectedFileIds.length} file{selectedFileIds.length > 1 ? 's' : ''} selected
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="light" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={selectedFileIds.length === 0}
|
||||||
|
>
|
||||||
|
Load {selectedFileIds.length > 0 ? `${selectedFileIds.length} ` : ''}Files
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePickerModal;
|
142
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
142
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<Stack align="center" gap="xl">
|
||||||
|
{/* Title and description */}
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<UploadFileIcon style={{ fontSize: 64 }} />
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="md" c="dimmed">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<Stack align="center" gap="md" w="100%">
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setShowFilePickerModal(true)}
|
||||||
|
disabled={disabled || sharedFiles.length === 0}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Load from Storage ({sharedFiles.length} files available)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text size="md" c="dimmed">
|
||||||
|
or
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{showDropzone ? (
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleFileUpload}
|
||||||
|
accept={accept}
|
||||||
|
multiple={allowMultiple}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={{ width: '100%', minHeight: 120 }}
|
||||||
|
>
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Text size="md" fw={500}>
|
||||||
|
{allowMultiple ? 'Drop files here or click to upload' : 'Drop file here or click to upload'}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{accept.includes('application/pdf') ? 'PDF files only' : 'Supported file types'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Dropzone>
|
||||||
|
) : (
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleFileUpload}
|
||||||
|
accept={accept}
|
||||||
|
multiple={allowMultiple}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={{ display: 'contents' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
disabled={disabled}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Upload {allowMultiple ? 'Files' : 'File'}
|
||||||
|
</Button>
|
||||||
|
</Dropzone>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* File Picker Modal */}
|
||||||
|
<FilePickerModal
|
||||||
|
opened={showFilePickerModal}
|
||||||
|
onClose={() => setShowFilePickerModal(false)}
|
||||||
|
sharedFiles={sharedFiles}
|
||||||
|
onSelectFiles={handleStorageSelection}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploadSelector;
|
@ -9,6 +9,7 @@ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||||
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
|
|
||||||
const VIEW_OPTIONS = [
|
const VIEW_OPTIONS = [
|
||||||
@ -36,6 +37,14 @@ const VIEW_OPTIONS = [
|
|||||||
),
|
),
|
||||||
value: "fileManager",
|
value: "fileManager",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Group gap={4}>
|
||||||
|
<FolderIcon fontSize="small" />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
value: "fileEditor",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface TopControlsProps {
|
interface TopControlsProps {
|
||||||
|
@ -123,7 +123,7 @@ const LazyPageImage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ViewerProps {
|
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;
|
setPdfFile: (file: { file: File; url: string } | null) => void;
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
|
@ -1,98 +1,32 @@
|
|||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Box, Group, Container } from "@mantine/core";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import TopControls from "../components/shared/TopControls";
|
||||||
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 FileManager from "../components/fileManagement/FileManager";
|
import FileManager from "../components/fileManagement/FileManager";
|
||||||
import SplitPdfPanel from "../tools/Split";
|
import FileEditor from "../components/editor/FileEditor";
|
||||||
import CompressPdfPanel from "../tools/Compress";
|
|
||||||
import MergePdfPanel from "../tools/Merge";
|
|
||||||
import PageEditor from "../components/editor/PageEditor";
|
import PageEditor from "../components/editor/PageEditor";
|
||||||
import PageEditorControls from "../components/editor/PageEditorControls";
|
import PageEditorControls from "../components/editor/PageEditorControls";
|
||||||
import Viewer from "../components/viewer/Viewer";
|
import Viewer from "../components/viewer/Viewer";
|
||||||
import TopControls from "../components/shared/TopControls";
|
import FileUploadSelector from "../components/shared/FileUploadSelector";
|
||||||
import ToolRenderer from "../components/tools/ToolRenderer";
|
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
|
||||||
|
|
||||||
type ToolRegistryEntry = {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
name: string;
|
|
||||||
component: React.ComponentType<any>;
|
|
||||||
view: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolRegistry = {
|
|
||||||
[key: string]: ToolRegistryEntry;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Base tool registry without translations
|
|
||||||
const baseToolRegistry = {
|
|
||||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "viewer" },
|
|
||||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
|
|
||||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation();
|
const [files, setFiles] = useState([]); // Array of { file, url }
|
||||||
const [searchParams] = useSearchParams();
|
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
|
||||||
const theme = useMantineTheme();
|
const [currentView, setCurrentView] = useState("fileManager");
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
|
||||||
|
|
||||||
// Core app state
|
|
||||||
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split");
|
|
||||||
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
|
|
||||||
const [pdfFile, setPdfFile] = useState<any>(null);
|
|
||||||
const [files, setFiles] = useState<any[]>([]);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
const [downloadUrl, setDownloadUrl] = useState(null);
|
||||||
const [readerMode, setReaderMode] = useState(false);
|
const [pageEditorFunctions, setPageEditorFunctions] = useState(null);
|
||||||
|
|
||||||
// Page editor functions
|
// Handle file selection from upload
|
||||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
const handleFileSelect = useCallback((file) => {
|
||||||
|
const fileObj = { file, url: URL.createObjectURL(file) };
|
||||||
// URL parameter management
|
setFiles([fileObj]);
|
||||||
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 opening file editor with selected files
|
||||||
const handleReaderToggle = useCallback(() => {
|
const handleOpenFileEditor = useCallback((selectedFiles) => {
|
||||||
setReaderMode(!readerMode);
|
setPreSelectedFiles(selectedFiles || []);
|
||||||
}, [readerMode]);
|
setCurrentView("fileEditor");
|
||||||
|
}, []);
|
||||||
const selectedTool = toolRegistry[selectedToolKey];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@ -100,86 +34,6 @@ export default function HomePage() {
|
|||||||
gap={0}
|
gap={0}
|
||||||
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
||||||
>
|
>
|
||||||
{/* Quick Access Bar */}
|
|
||||||
<QuickAccessBar
|
|
||||||
onToolsClick={handleQuickAccessTools}
|
|
||||||
onReaderToggle={handleReaderToggle}
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
leftPanelView={leftPanelView}
|
|
||||||
readerMode={readerMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Left: Tool Picker OR Selected Tool Panel */}
|
|
||||||
<div
|
|
||||||
className={`h-screen z-sticky flex flex-col ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-surface)',
|
|
||||||
borderRight: '1px solid var(--border-subtle)',
|
|
||||||
width: sidebarsVisible && !readerMode ? '25vw' : '0px',
|
|
||||||
minWidth: sidebarsVisible && !readerMode ? '300px' : '0px',
|
|
||||||
maxWidth: sidebarsVisible && !readerMode ? '450px' : '0px',
|
|
||||||
transition: 'width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), min-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), max-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
||||||
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: sidebarsVisible && !readerMode ? 1 : 0,
|
|
||||||
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leftPanelView === 'toolPicker' ? (
|
|
||||||
// Tool Picker View
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<ToolPicker
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
onSelect={handleToolSelect}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Selected Tool Content View
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Back button */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setLeftPanelView('toolPicker')}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
← Back to Tools
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool title */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool content */}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<ToolRenderer
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
selectedTool={selectedTool}
|
|
||||||
pdfFile={pdfFile}
|
|
||||||
files={files}
|
|
||||||
downloadUrl={downloadUrl}
|
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
toolParams={toolParams}
|
|
||||||
updateParams={updateParams}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main View */}
|
|
||||||
<Box
|
<Box
|
||||||
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
@ -193,30 +47,59 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
|
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
|
||||||
{(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
|
{currentView === "fileManager" ? (
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files}
|
files={files}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
setPdfFile={setPdfFile}
|
|
||||||
setCurrentView={setCurrentView}
|
setCurrentView={setCurrentView}
|
||||||
|
onOpenFileEditor={handleOpenFileEditor}
|
||||||
|
/>
|
||||||
|
) : (currentView !== "fileManager") && !files[0] ? (
|
||||||
|
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FileUploadSelector
|
||||||
|
title={currentView === "viewer" ? "Select a PDF to view" : "Select a PDF to edit"}
|
||||||
|
subtitle="Choose a file from storage or upload a new PDF"
|
||||||
|
sharedFiles={files}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
allowMultiple={false}
|
||||||
|
accept={["application/pdf"]}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
) : currentView === "fileEditor" ? (
|
||||||
|
<FileEditor
|
||||||
|
sharedFiles={files}
|
||||||
|
setSharedFiles={setFiles}
|
||||||
|
preSelectedFiles={preSelectedFiles}
|
||||||
|
onClearPreSelection={() => 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" ? (
|
) : currentView === "viewer" ? (
|
||||||
<Viewer
|
<Viewer
|
||||||
pdfFile={pdfFile}
|
pdfFile={files[0]}
|
||||||
setPdfFile={setPdfFile}
|
setPdfFile={(fileObj) => setFiles([fileObj])}
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
setSidebarsVisible={setSidebarsVisible}
|
setSidebarsVisible={setSidebarsVisible}
|
||||||
/>
|
/>
|
||||||
) : currentView === "pageEditor" ? (
|
) : currentView === "pageEditor" ? (
|
||||||
<>
|
<>
|
||||||
<PageEditor
|
<PageEditor
|
||||||
file={pdfFile}
|
file={files[0]}
|
||||||
setFile={setPdfFile}
|
setFile={(fileObj) => setFiles([fileObj])}
|
||||||
downloadUrl={downloadUrl}
|
downloadUrl={downloadUrl}
|
||||||
setDownloadUrl={setDownloadUrl}
|
setDownloadUrl={setDownloadUrl}
|
||||||
onFunctionsReady={setPageEditorFunctions}
|
onFunctionsReady={setPageEditorFunctions}
|
||||||
|
sharedFiles={files}
|
||||||
/>
|
/>
|
||||||
{pdfFile && pageEditorFunctions && (
|
{files[0] && pageEditorFunctions && (
|
||||||
<PageEditorControls
|
<PageEditorControls
|
||||||
onClosePdf={pageEditorFunctions.closePdf}
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
onUndo={pageEditorFunctions.handleUndo}
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
@ -238,8 +121,8 @@ export default function HomePage() {
|
|||||||
<FileManager
|
<FileManager
|
||||||
files={files}
|
files={files}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
setPdfFile={setPdfFile}
|
|
||||||
setCurrentView={setCurrentView}
|
setCurrentView={setCurrentView}
|
||||||
|
onOpenFileEditor={handleOpenFileEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
Loading…
Reference in New Issue
Block a user