File editor groundwork and refactor work

This commit is contained in:
Reece 2025-06-20 17:51:24 +01:00
parent 9c410865f9
commit 3ebf75ae6f
14 changed files with 2237 additions and 733 deletions

View 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;

View 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;

View 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;

View 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;
}

View File

@ -4,25 +4,8 @@ import {
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group, Paper, SimpleGrid
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AddIcon from "@mui/icons-material/Add";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import ConstructionIcon from "@mui/icons-material/Construction";
import EventListIcon from "@mui/icons-material/EventList";
import DeselectIcon from "@mui/icons-material/Deselect";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import CloseIcon from "@mui/icons-material/Close";
import { usePDFProcessor } from "../../hooks/usePDFProcessor";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { fileStorage } from "../../services/fileStorage";
@ -36,13 +19,19 @@ import {
ToggleSplitCommand
} from "../../commands/pageCommands";
import { pdfExportService } from "../../services/pdfExportService";
import styles from './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './shared/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import FileUploadSelector from '../shared/FileUploadSelector';
export interface PageEditorProps {
file: { file: File; url: string } | null;
setFile?: (file: { file: File; url: string } | null) => void;
downloadUrl?: string | null;
setDownloadUrl?: (url: string | null) => void;
// Optional callbacks to expose internal functions
onFunctionsReady?: (functions: {
handleUndo: () => void;
@ -66,6 +55,7 @@ const PageEditor = ({
downloadUrl,
setDownloadUrl,
onFunctionsReady,
sharedFiles,
}: PageEditorProps) => {
const { t } = useTranslation();
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
@ -95,8 +85,38 @@ const PageEditor = ({
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Process uploaded file
const handleFileUpload = useCallback(async (uploadedFile: File) => {
if (!uploadedFile || uploadedFile.type !== 'application/pdf') {
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
if (!uploadedFile) {
setError('No file provided');
return;
}
let fileToProcess: File;
// Handle FileWithUrl objects from storage
if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) {
try {
console.log('Converting FileWithUrl to File:', uploadedFile.name);
const arrayBuffer = await uploadedFile.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' });
fileToProcess = new File([blob], uploadedFile.name, {
type: uploadedFile.type || 'application/pdf',
lastModified: uploadedFile.lastModified || Date.now()
});
} catch (error) {
console.error('Error converting FileWithUrl:', error);
setError('Unable to load file from storage');
return;
}
} else if (uploadedFile instanceof File) {
fileToProcess = uploadedFile;
} else {
setError('Invalid file object');
console.error('handleFileUpload received unsupported object:', uploadedFile);
return;
}
if (fileToProcess.type !== 'application/pdf') {
setError('Please upload a valid PDF file');
return;
}
@ -105,19 +125,22 @@ const PageEditor = ({
setError(null);
try {
const document = await processPDFFile(uploadedFile);
const document = await processPDFFile(fileToProcess);
setPdfDocument(document);
setFilename(uploadedFile.name.replace(/\.pdf$/i, ''));
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
setSelectedPages([]);
if (document.pages.length > 0) {
const thumbnail = await generateThumbnailForFile(uploadedFile);
await fileStorage.storeFile(uploadedFile, thumbnail);
// Only store if it's a new file (not from storage)
if (!uploadedFile.storedInIndexedDB) {
const thumbnail = await generateThumbnailForFile(fileToProcess);
await fileStorage.storeFile(fileToProcess, thumbnail);
}
}
if (setFile) {
const fileUrl = URL.createObjectURL(uploadedFile);
setFile({ file: uploadedFile, url: fileUrl });
const fileUrl = URL.createObjectURL(fileToProcess);
setFile({ file: fileToProcess, url: fileUrl });
}
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
@ -562,18 +585,18 @@ const PageEditor = ({
});
}
}, [
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
exportLoading,
selectionMode,
selectedPages,
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
exportLoading,
selectionMode,
selectedPages,
closePdf
]);
@ -583,26 +606,15 @@ const PageEditor = ({
<LoadingOverlay visible={loading || pdfLoading} />
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Dropzone
onDrop={(files) => files[0] && handleFileUpload(files[0])}
accept={["application/pdf"]}
multiple={false}
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>
<FileUploadSelector
title="Select a PDF to edit"
subtitle="Choose a file from storage or upload a new PDF"
sharedFiles={sharedFiles || []}
onFileSelect={handleFileUpload}
allowMultiple={false}
accept={["application/pdf"]}
loading={loading || pdfLoading}
/>
</Container>
</Box>
);
@ -610,58 +622,6 @@ const PageEditor = ({
return (
<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} />
<Box p="md" pt="xl">
@ -696,365 +656,74 @@ const PageEditor = ({
</Group>
{selectionMode && (
<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={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>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedPages}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px' // Add space for floating control bar
}}
>
{pdfDocument.pages.map((page, index) => (
<React.Fragment key={page.id}>
{page.splitBefore && index > 0 && (
<div
style={{
width: '2px',
height: '20rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
<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)}
<DragDropGrid
items={pdfDocument.pages}
selectedItems={selectedPages}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
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(page.id)}
onDragEnter={handleDragEnter}
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">
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
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'
width: '2px',
height: '20rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
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>
@ -1130,18 +799,7 @@ const PageEditor = ({
</Notification>
)}
{/* Multi-page drag indicator */}
{multiPageDrag && dragPosition && (
<div
className="multi-drag-indicator"
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiPageDrag.count} pages
</div>
)}
</Box>
);
};

View 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;

View 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;

View File

@ -1,8 +1,10 @@
import React from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
import React, { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
@ -12,11 +14,16 @@ interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
}
const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false);
return (
<Card
@ -28,9 +35,15 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined
cursor: onDoubleClick ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
>
<Stack gap={6} align="center">
<Box
@ -44,8 +57,57 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
justifyContent: "center",
margin: "0 auto",
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 ? (
<Image
src={thumb}
@ -123,7 +185,10 @@ const FileCard = ({ file, onRemove, onDoubleClick }: FileCardProps) => {
color="red"
size="xs"
variant="light"
onClick={onRemove}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
mt={4}
>
{t("delete", "Remove")}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Box, Flex, Text, Notification } from "@mantine/core";
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
@ -12,6 +12,7 @@ import { fileOperationsService } from "../../services/fileOperationsService";
import { checkStorageWarnings } from "../../utils/storageUtils";
import StorageStatsCard from "./StorageStatsCard";
import FileCard from "./FileCard";
import FileUploadSelector from "../shared/FileUploadSelector";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@ -19,22 +20,23 @@ interface FileManagerProps {
files: FileWithUrl[];
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
allowMultiple?: boolean;
setPdfFile?: (fileObj: { file: File; url: string }) => void;
setCurrentView?: (view: string) => void;
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
}
const FileManager = ({
files = [],
setFiles,
allowMultiple = true,
setPdfFile,
setCurrentView,
onOpenFileEditor,
}: FileManagerProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
// Extract operations from service for cleaner code
const {
@ -207,15 +209,47 @@ const FileManager = ({
};
const handleFileDoubleClick = async (file: FileWithUrl) => {
if (setPdfFile) {
try {
const url = await createBlobUrlForFile(file);
setPdfFile({ file: file, url: url });
setCurrentView && setCurrentView("viewer");
} catch (error) {
console.error('Failed to create blob URL for file:', error);
setNotification('Failed to open file. It may have been removed from storage.');
}
try {
const url = await createBlobUrlForFile(file);
// Add file to the beginning of files array and switch to viewer
setFiles(prev => [{ file: file, url: url }, ...prev.filter(f => f.id !== file.id)]);
setCurrentView && setCurrentView("viewer");
} catch (error) {
console.error('Failed to create blob URL for file:', error);
setNotification('Failed to open file. It may have been removed from storage.');
}
};
const handleFileView = async (file: FileWithUrl) => {
try {
const url = await createBlobUrlForFile(file);
// Add file to the beginning of files array and switch to viewer
setFiles(prev => [{ file: file, url: url }, ...prev.filter(f => f.id !== file.id)]);
setCurrentView && setCurrentView("viewer");
} catch (error) {
console.error('Failed to create blob URL for file:', error);
setNotification('Failed to open file. It may have been removed from storage.');
}
};
const handleFileEdit = (file: FileWithUrl) => {
if (onOpenFileEditor) {
onOpenFileEditor([file]);
}
};
const toggleFileSelection = (fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
const handleOpenSelectedInEditor = () => {
if (onOpenFileEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenFileEditor(selected);
}
};
@ -230,29 +264,7 @@ const FileManager = ({
padding: "20px"
}}>
{/* File Upload Dropzone */}
<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>
{/* File upload is now handled by FileUploadSelector when no files exist */}
{/* Storage Stats Card */}
<StorageStatsCard
@ -262,11 +274,49 @@ const FileManager = ({
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.length === 0 ? (
<Text c="dimmed" ta="center">
{t("noFileSelected", "No files uploaded yet.")}
</Text>
<FileUploadSelector
title="Upload PDF Files"
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>
<Flex
@ -281,7 +331,11 @@ const FileManager = ({
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => handleFileDoubleClick(file)}
as FileWithUrl />
onView={() => handleFileView(file)}
onEdit={() => handleFileEdit(file)}
isSelected={selectedFiles.includes(file.id || file.name)}
onSelect={() => toggleFileSelection(file.id || file.name)}
/>
))}
</Flex>
</Box>

View 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;

View 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;

View File

@ -9,6 +9,7 @@ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
const VIEW_OPTIONS = [
@ -36,6 +37,14 @@ const VIEW_OPTIONS = [
),
value: "fileManager",
},
{
label: (
<Group gap={4}>
<FolderIcon fontSize="small" />
</Group>
),
value: "fileEditor",
},
];
interface TopControlsProps {

View File

@ -123,7 +123,7 @@ const LazyPageImage = ({
};
export interface ViewerProps {
pdfFile: { file: File; url: string } | null;
pdfFile: { file: File; url: string } | null; // First file in the array
setPdfFile: (file: { file: File; url: string } | null) => void;
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;

View File

@ -1,98 +1,32 @@
import React, { useState, useCallback, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { useSearchParams } from "react-router-dom";
import { useToolParams } from "../hooks/useToolParams";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import { Group, Paper, Box, Button, useMantineTheme } from "@mantine/core";
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
import rainbowStyles from '../styles/rainbow.module.css';
import ToolPicker from "../components/tools/ToolPicker";
import React, { useState, useCallback } from "react";
import { Box, Group, Container } from "@mantine/core";
import TopControls from "../components/shared/TopControls";
import FileManager from "../components/fileManagement/FileManager";
import SplitPdfPanel from "../tools/Split";
import CompressPdfPanel from "../tools/Compress";
import MergePdfPanel from "../tools/Merge";
import FileEditor from "../components/editor/FileEditor";
import PageEditor from "../components/editor/PageEditor";
import PageEditorControls from "../components/editor/PageEditorControls";
import Viewer from "../components/viewer/Viewer";
import TopControls from "../components/shared/TopControls";
import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar";
type ToolRegistryEntry = {
icon: React.ReactNode;
name: string;
component: React.ComponentType<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" },
};
import FileUploadSelector from "../components/shared/FileUploadSelector";
export default function HomePage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const theme = useMantineTheme();
const { isRainbowMode } = useRainbowThemeContext();
// Core app state
const [selectedToolKey, setSelectedToolKey] = useState<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 [files, setFiles] = useState([]); // Array of { file, url }
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
const [currentView, setCurrentView] = useState("fileManager");
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false);
// Page editor functions
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
const [downloadUrl, setDownloadUrl] = useState(null);
const [pageEditorFunctions, setPageEditorFunctions] = useState(null);
// URL parameter management
const { toolParams, updateParams } = useToolParams(selectedToolKey, currentView);
const toolRegistry: ToolRegistry = {
split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") },
compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") },
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
};
// Handle tool selection
const handleToolSelect = useCallback(
(id: string) => {
setSelectedToolKey(id);
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected
setReaderMode(false); // Exit reader mode when selecting a tool
},
[toolRegistry]
);
// Handle quick access actions
const handleQuickAccessTools = useCallback(() => {
setLeftPanelView('toolPicker');
setReaderMode(false);
// Handle file selection from upload
const handleFileSelect = useCallback((file) => {
const fileObj = { file, url: URL.createObjectURL(file) };
setFiles([fileObj]);
}, []);
const handleReaderToggle = useCallback(() => {
setReaderMode(!readerMode);
}, [readerMode]);
const selectedTool = toolRegistry[selectedToolKey];
// Handle opening file editor with selected files
const handleOpenFileEditor = useCallback((selectedFiles) => {
setPreSelectedFiles(selectedFiles || []);
setCurrentView("fileEditor");
}, []);
return (
<Group
@ -100,89 +34,9 @@ export default function HomePage() {
gap={0}
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"
style={{
style={{
backgroundColor: 'var(--bg-background)'
}}
>
@ -192,57 +46,86 @@ export default function HomePage() {
setCurrentView={setCurrentView}
/>
{/* Main content area */}
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
{(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
<FileManager
files={files}
setFiles={setFiles}
setPdfFile={setPdfFile}
setCurrentView={setCurrentView}
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
{currentView === "fileManager" ? (
<FileManager
files={files}
setFiles={setFiles}
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}
/>
) : currentView === "viewer" ? (
<Viewer
pdfFile={pdfFile}
setPdfFile={setPdfFile}
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
</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" ? (
<Viewer
pdfFile={files[0]}
setPdfFile={(fileObj) => setFiles([fileObj])}
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
/>
) : currentView === "pageEditor" ? (
<>
<PageEditor
file={files[0]}
setFile={(fileObj) => setFiles([fileObj])}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
onFunctionsReady={setPageEditorFunctions}
sharedFiles={files}
/>
) : currentView === "pageEditor" ? (
<>
<PageEditor
file={pdfFile}
setFile={setPdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
onFunctionsReady={setPageEditorFunctions}
{files[0] && pageEditorFunctions && (
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onExportSelected={() => pageEditorFunctions.showExportPreview(true)}
onExportAll={() => pageEditorFunctions.showExportPreview(false)}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPages={pageEditorFunctions.selectedPages}
/>
{pdfFile && pageEditorFunctions && (
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onExportSelected={() => pageEditorFunctions.showExportPreview(true)}
onExportAll={() => pageEditorFunctions.showExportPreview(false)}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPages={pageEditorFunctions.selectedPages}
/>
)}
</>
) : (
<FileManager
files={files}
setFiles={setFiles}
setPdfFile={setPdfFile}
setCurrentView={setCurrentView}
/>
)}
</Box>
)}
</>
) : (
<FileManager
files={files}
setFiles={setFiles}
setCurrentView={setCurrentView}
onOpenFileEditor={handleOpenFileEditor}
/>
)}
</Box>
</Box>
</Group>
);