mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Stack, Text, Group } from '@mantine/core';
|
||||
|
||||
const DeepLinks: React.FC = () => {
|
||||
const commonLinks = [
|
||||
{
|
||||
name: "Split PDF Pages 1-5",
|
||||
url: "/?tool=split&splitMode=byPages&pages=1-5&view=viewer",
|
||||
description: "Split a PDF and extract pages 1-5"
|
||||
},
|
||||
{
|
||||
name: "Compress PDF (High)",
|
||||
url: "/?tool=compress&level=9&grayscale=true&view=viewer",
|
||||
description: "Compress a PDF with high compression level"
|
||||
},
|
||||
{
|
||||
name: "Merge PDFs",
|
||||
url: "/?tool=merge&view=fileManager",
|
||||
description: "Combine multiple PDF files into one"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text fw={500}>Common PDF Operations</Text>
|
||||
{commonLinks.map((link, index) => (
|
||||
<Group key={index}>
|
||||
<Button
|
||||
component={Link}
|
||||
to={link.url}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{link.name}
|
||||
</Button>
|
||||
<Text size="sm" color="dimmed">{link.description}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepLinks;
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const languageOptions = Object.entries(supportedLanguages)
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
|
||||
'&:hover': {
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={styles.languageItem}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handleLanguageChange(option.value)}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
justifyContent: 'flex-start',
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
||||
) : 'transparent',
|
||||
color: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
||||
),
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
||||
),
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '13px',
|
||||
fontWeight: option.value === i18n.language ? 600 : 400,
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,198 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification
|
||||
} from "@mantine/core";
|
||||
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 ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const DUMMY_PAGE_COUNT = 8; // Replace with real page count from PDF
|
||||
|
||||
const PageEditor: React.FC<PageEditorProps> = ({
|
||||
file,
|
||||
setFile,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [undoStack, setUndoStack] = useState<number[][]>([]);
|
||||
const [redoStack, setRedoStack] = useState<number[][]>([]);
|
||||
|
||||
// Dummy page thumbnails
|
||||
const pages = Array.from({ length: DUMMY_PAGE_COUNT }, (_, i) => i + 1);
|
||||
|
||||
const selectAll = () => setSelectedPages(pages);
|
||||
const deselectAll = () => setSelectedPages([]);
|
||||
const togglePage = (page: number) =>
|
||||
setSelectedPages((prev) =>
|
||||
prev.includes(page) ? prev.filter((p) => p !== page) : [...prev, page]
|
||||
);
|
||||
|
||||
// Undo/redo logic for selection
|
||||
const handleUndo = () => {
|
||||
if (undoStack.length > 0) {
|
||||
setRedoStack([selectedPages, ...redoStack]);
|
||||
setSelectedPages(undoStack[0]);
|
||||
setUndoStack(undoStack.slice(1));
|
||||
}
|
||||
};
|
||||
const handleRedo = () => {
|
||||
if (redoStack.length > 0) {
|
||||
setUndoStack([selectedPages, ...undoStack]);
|
||||
setSelectedPages(redoStack[0]);
|
||||
setRedoStack(redoStack.slice(1));
|
||||
}
|
||||
};
|
||||
|
||||
// Example action handlers (replace with real API calls)
|
||||
const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", "));
|
||||
const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", "));
|
||||
const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", "));
|
||||
const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", "));
|
||||
const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", "));
|
||||
const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", "));
|
||||
const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", "));
|
||||
const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo"));
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Paper shadow="xs" radius="md" p="md">
|
||||
<Center>
|
||||
<Text color="dimmed">{t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")}</Text>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper shadow="xs" radius="md" p="md">
|
||||
<Group align="flex-start" gap="lg">
|
||||
{/* Sidebar */}
|
||||
<Stack w={180} gap="xs">
|
||||
<Text fw={600} size="lg">{t("pageEditor.title", "PDF Multitool")}</Text>
|
||||
<Button onClick={selectAll} fullWidth variant="light">{t("multiTool.selectAll", "Select All")}</Button>
|
||||
<Button onClick={deselectAll} fullWidth variant="light">{t("multiTool.deselectAll", "Deselect All")}</Button>
|
||||
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>{t("multiTool.undo", "Undo")}</Button>
|
||||
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>{t("multiTool.redo", "Redo")}</Button>
|
||||
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>{t("multiTool.addFile", "Add File")}</Button>
|
||||
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.insertPageBreak", "Insert Page Break")}</Button>
|
||||
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.split", "Split")}</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl || "#"}
|
||||
download="edited.pdf"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
fullWidth
|
||||
color="green"
|
||||
variant="light"
|
||||
disabled={!downloadUrl}
|
||||
>
|
||||
{t("multiTool.downloadAll", "Download All")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl || "#"}
|
||||
download="selected.pdf"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
fullWidth
|
||||
color="blue"
|
||||
variant="light"
|
||||
disabled={!downloadUrl || selectedPages.length === 0}
|
||||
>
|
||||
{t("multiTool.downloadSelected", "Download Selected")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setFile && setFile(null)}
|
||||
fullWidth
|
||||
>
|
||||
{t("pageEditor.closePdf", "Close PDF")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Main multitool area */}
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group mb="sm">
|
||||
<Tooltip label={t("multiTool.rotateLeft", "Rotate Left")}>
|
||||
<ActionIcon onClick={handleRotateLeft} disabled={selectedPages.length === 0} color="blue" variant="light">
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.rotateRight", "Rotate Right")}>
|
||||
<ActionIcon onClick={handleRotateRight} disabled={selectedPages.length === 0} color="blue" variant="light">
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("delete", "Delete")}>
|
||||
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red" variant="light">
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.moveLeft", "Move Left")}>
|
||||
<ActionIcon onClick={handleMoveLeft} disabled={selectedPages.length === 0} color="gray" variant="light">
|
||||
<ArrowBackIosNewIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.moveRight", "Move Right")}>
|
||||
<ActionIcon onClick={handleMoveRight} disabled={selectedPages.length === 0} color="gray" variant="light">
|
||||
<ArrowForwardIosIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea h={350}>
|
||||
<Group>
|
||||
{pages.map((page) => (
|
||||
<Stack key={page} align="center" gap={2}>
|
||||
<Checkbox
|
||||
checked={selectedPages.includes(page)}
|
||||
onChange={() => togglePage(page)}
|
||||
label={t("page", "Page") + ` ${page}`}
|
||||
/>
|
||||
<Box
|
||||
w={60}
|
||||
h={80}
|
||||
bg={selectedPages.includes(page) ? "blue.1" : "gray.1"}
|
||||
style={{ border: "1px solid #ccc", borderRadius: 4 }}
|
||||
>
|
||||
{/* Replace with real thumbnail */}
|
||||
<Center h="100%">
|
||||
<Text size="xs" color="dimmed">
|
||||
{page}
|
||||
</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
{status && (
|
||||
<Notification color="blue" mt="md" onClose={() => setStatus(null)}>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
||||
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?: { file: File; url: string }[];
|
||||
setSharedFiles?: (files: { file: File; url: string }[]) => void;
|
||||
preSelectedFiles?: { file: File; url: string }[];
|
||||
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;
|
||||
}
|
||||
808
frontend/src/components/editor/PageEditor.tsx
Normal file
808
frontend/src/components/editor/PageEditor.tsx
Normal file
@@ -0,0 +1,808 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
||||
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group, Paper, SimpleGrid
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { usePDFProcessor } from "../../hooks/usePDFProcessor";
|
||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import { generateThumbnailForFile } from "../../utils/thumbnailUtils";
|
||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||
import {
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ReorderPageCommand,
|
||||
MovePagesCommand,
|
||||
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;
|
||||
sharedFiles?: { file: File; url: string }[];
|
||||
|
||||
// Optional callbacks to expose internal functions
|
||||
onFunctionsReady?: (functions: {
|
||||
handleUndo: () => void;
|
||||
handleRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
handleRotate: (direction: 'left' | 'right') => void;
|
||||
handleDelete: () => void;
|
||||
handleSplit: () => void;
|
||||
showExportPreview: (selectedOnly: boolean) => void;
|
||||
exportLoading: boolean;
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
closePdf: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PageEditor = ({
|
||||
file,
|
||||
setFile,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
onFunctionsReady,
|
||||
sharedFiles,
|
||||
}: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
||||
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
|
||||
const [selectedPages, setSelectedPages] = 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 [filename, setFilename] = useState<string>("");
|
||||
const [draggedPage, setDraggedPage] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||
const [movingPage, setMovingPage] = useState<string | null>(null);
|
||||
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const fileInputRef = useRef<() => void>(null);
|
||||
|
||||
// Undo/Redo system
|
||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
|
||||
// Process uploaded file
|
||||
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;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const document = await processPDFFile(fileToProcess);
|
||||
setPdfDocument(document);
|
||||
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
|
||||
setSelectedPages([]);
|
||||
|
||||
if (document.pages.length > 0) {
|
||||
// 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(fileToProcess);
|
||||
setFile({ file: fileToProcess, url: fileUrl });
|
||||
}
|
||||
|
||||
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
|
||||
setError(errorMessage);
|
||||
console.error('PDF processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [processPDFFile, setFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (file?.file && !pdfDocument) {
|
||||
handleFileUpload(file.file);
|
||||
}
|
||||
}, [file, pdfDocument, handleFileUpload]);
|
||||
|
||||
// Global drag cleanup to handle drops outside valid areas
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
// Clean up drag state when drag operation ends anywhere
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
};
|
||||
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
// Prevent default to avoid browser navigation on invalid drops
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (draggedPage) {
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
document.addEventListener('drop', handleGlobalDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
document.removeEventListener('drop', handleGlobalDrop);
|
||||
};
|
||||
}, [draggedPage]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
if (pdfDocument) {
|
||||
setSelectedPages(pdfDocument.pages.map(p => p.id));
|
||||
}
|
||||
}, [pdfDocument]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedPages([]), []);
|
||||
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPages(prev =>
|
||||
prev.includes(pageId)
|
||||
? prev.filter(id => id !== pageId)
|
||||
: [...prev, pageId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
// Clear selections when exiting selection mode
|
||||
setSelectedPages([]);
|
||||
setCsvInput("");
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
if (!pdfDocument) return [];
|
||||
|
||||
const pageIds: 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 <= pdfDocument.totalPages; i++) {
|
||||
if (i > 0) {
|
||||
const page = pdfDocument.pages.find(p => p.pageNumber === i);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0 && pageNum <= pdfDocument.totalPages) {
|
||||
const page = pdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pageIds;
|
||||
}, [pdfDocument]);
|
||||
|
||||
const updatePagesFromCSV = useCallback(() => {
|
||||
const pageIds = parseCSVInput(csvInput);
|
||||
setSelectedPages(pageIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
const handleDragStart = useCallback((pageId: string) => {
|
||||
setDraggedPage(pageId);
|
||||
|
||||
// Check if this is a multi-page drag in selection mode
|
||||
if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) {
|
||||
setMultiPageDrag({
|
||||
pageIds: selectedPages,
|
||||
count: selectedPages.length
|
||||
});
|
||||
} else {
|
||||
setMultiPageDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedPages]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Clean up drag state regardless of where the drop happened
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedPage) return;
|
||||
|
||||
// Update drag position for multi-page indicator
|
||||
if (multiPageDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// Get the element under the mouse cursor
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
// Find the closest page container
|
||||
const pageContainer = elementUnderCursor.closest('[data-page-id]');
|
||||
if (pageContainer) {
|
||||
const pageId = pageContainer.getAttribute('data-page-id');
|
||||
if (pageId && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if over the end zone
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// If not over any valid drop target, clear it
|
||||
setDropTarget(null);
|
||||
}, [draggedPage, multiPageDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((pageId: string) => {
|
||||
if (draggedPage && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Don't clear drop target on drag leave - let dragover handle it
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback((pageId: string, targetIndex: number) => {
|
||||
if (!pdfDocument || isAnimating) return;
|
||||
|
||||
// In selection mode, if the dragged page is selected, move all selected pages
|
||||
const pagesToMove = selectionMode && selectedPages.includes(pageId)
|
||||
? selectedPages
|
||||
: [pageId];
|
||||
|
||||
const originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
if (originalIndex === -1 || originalIndex === targetIndex) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
// Get current positions of all pages
|
||||
const currentPositions = new Map<string, { x: number; y: number }>();
|
||||
pdfDocument.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
currentPositions.set(page.id, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the reorder - for multi-page, we use a different command
|
||||
if (pagesToMove.length > 1) {
|
||||
// Multi-page move - use MovePagesCommand
|
||||
const command = new MovePagesCommand(pdfDocument, setPdfDocument, pagesToMove, targetIndex);
|
||||
executeCommand(command);
|
||||
} else {
|
||||
// Single page move
|
||||
const command = new ReorderPageCommand(pdfDocument, setPdfDocument, pageId, targetIndex);
|
||||
executeCommand(command);
|
||||
}
|
||||
|
||||
// Wait for DOM to update, then get new positions and animate
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Get the updated document from the state after command execution
|
||||
// The command has already updated the document, so we need to get the new order
|
||||
const currentDoc = pdfDocument; // This should be the updated version after command
|
||||
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
newPositions.set(page.id, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate and apply animations
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
const currentPos = currentPositions.get(page.id);
|
||||
const newPos = newPositions.get(page.id);
|
||||
|
||||
if (element && currentPos && newPos) {
|
||||
const deltaX = currentPos.x - newPos.x;
|
||||
const deltaY = currentPos.y - newPos.y;
|
||||
|
||||
// Apply initial transform (from new position back to old position)
|
||||
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
element.style.transition = 'none';
|
||||
|
||||
// Force reflow
|
||||
element.offsetHeight;
|
||||
|
||||
// Animate to final position
|
||||
element.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
element.style.transform = 'translate(0px, 0px)';
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
element.style.transform = '';
|
||||
element.style.transition = '';
|
||||
}
|
||||
});
|
||||
setIsAnimating(false);
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
}, [pdfDocument, isAnimating, executeCommand, selectionMode, selectedPages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetPageId === 'end') {
|
||||
targetIndex = pdfDocument.pages.length;
|
||||
} else {
|
||||
targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
animateReorder(draggedPage, targetIndex);
|
||||
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
|
||||
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
||||
}, [draggedPage, pdfDocument, animateReorder, multiPageDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedPage) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const rotation = direction === 'left' ? -90 : 90;
|
||||
const pagesToRotate = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToRotate,
|
||||
rotation
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Rotated ${pageCount} pages ${direction}`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const pagesToDelete = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new DeletePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToDelete
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
if (selectionMode) {
|
||||
setSelectedPages([]);
|
||||
}
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Deleted ${pageCount} pages`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const pagesToSplit = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new ToggleSplitCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToSplit
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Split markers toggled for ${pageCount} pages`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
|
||||
setExportPreview(preview);
|
||||
setShowExportModal(true);
|
||||
}, [pdfDocument, selectedPages]);
|
||||
|
||||
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly);
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
|
||||
|
||||
if (hasSplitMarkers) {
|
||||
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename,
|
||||
splitDocuments: true
|
||||
}) as { blobs: Blob[]; filenames: string[] };
|
||||
|
||||
result.blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
pdfExportService.downloadFile(blob, result.filenames[index]);
|
||||
}, index * 500);
|
||||
});
|
||||
|
||||
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||
} else {
|
||||
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename
|
||||
}) as { blob: Blob; filename: string };
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setStatus('PDF exported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [pdfDocument, selectedPages, filename]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (undo()) {
|
||||
setStatus('Operation undone');
|
||||
}
|
||||
}, [undo]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (redo()) {
|
||||
setStatus('Operation redone');
|
||||
}
|
||||
}, [redo]);
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
setPdfDocument(null);
|
||||
setFile && setFile(null);
|
||||
}, [setFile]);
|
||||
|
||||
// Expose functions to parent component
|
||||
useEffect(() => {
|
||||
if (onFunctionsReady) {
|
||||
onFunctionsReady({
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onFunctionsReady,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf
|
||||
]);
|
||||
|
||||
if (!pdfDocument) {
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Enter filename"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<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 Pages"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedPages}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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}
|
||||
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
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '20rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
title="Export Preview"
|
||||
>
|
||||
{exportPreview && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text>Pages to export:</Text>
|
||||
<Text fw={500}>{exportPreview.pageCount}</Text>
|
||||
</Group>
|
||||
|
||||
{exportPreview.splitCount > 1 && (
|
||||
<Group justify="space-between">
|
||||
<Text>Split into documents:</Text>
|
||||
<Text fw={500}>{exportPreview.splitCount}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text>Estimated size:</Text>
|
||||
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
||||
</Group>
|
||||
|
||||
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
|
||||
<Alert color="blue">
|
||||
This will create multiple PDF files based on split markers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => setShowExportModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
loading={exportLoading}
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
const selectedOnly = exportPreview.pageCount < (pdfDocument?.totalPages || 0);
|
||||
handleExport(selectedOnly);
|
||||
}}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
accept="application/pdf"
|
||||
onChange={(file) => file && handleFileUpload(file)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
||||
191
frontend/src/components/editor/PageEditorControls.tsx
Normal file
191
frontend/src/components/editor/PageEditorControls.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Paper
|
||||
} from "@mantine/core";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
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 CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
onClosePdf: () => void;
|
||||
|
||||
// Undo/Redo
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
|
||||
// Page operations
|
||||
onRotate: (direction: 'left' | 'right') => void;
|
||||
onDelete: () => void;
|
||||
onSplit: () => void;
|
||||
|
||||
// Export functions
|
||||
onExportSelected: () => void;
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onClosePdf,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages
|
||||
}: PageEditorControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="xl"
|
||||
shadow="lg"
|
||||
p={16}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 32,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{/* Close PDF */}
|
||||
<Tooltip label="Close PDF">
|
||||
<ActionIcon
|
||||
onClick={onClosePdf}
|
||||
color="red"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
color="red"
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Export Controls */}
|
||||
{selectionMode && selectedPages.length > 0 && (
|
||||
<Tooltip label="Export Selected">
|
||||
<ActionIcon
|
||||
onClick={onExportSelected}
|
||||
disabled={exportLoading}
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Export All">
|
||||
<ActionIcon
|
||||
onClick={onExportAll}
|
||||
disabled={exportLoading}
|
||||
color="green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditorControls;
|
||||
348
frontend/src/components/editor/PageThumbnail.tsx
Normal file
348
frontend/src/components/editor/PageThumbnail.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
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
|
||||
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;
|
||||
201
frontend/src/components/fileManagement/FileCard.tsx
Normal file
201
frontend/src/components/fileManagement/FileCard.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
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
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
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
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
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}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #ddd',
|
||||
borderTop: '2px solid #666',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: 8
|
||||
}} />
|
||||
<Text size="xs" c="dimmed">Generating...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{file.storedInIndexedDB && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCard;
|
||||
92
frontend/src/components/fileManagement/StorageStatsCard.tsx
Normal file
92
frontend/src/components/fileManagement/StorageStatsCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { StorageStats } from "../../services/fileStorage";
|
||||
import { formatFileSize } from "../../utils/fileUtils";
|
||||
import { getStorageUsagePercent } from "../../utils/storageUtils";
|
||||
import { StorageConfig } from "../../types/file";
|
||||
|
||||
interface StorageStatsCardProps {
|
||||
storageStats: StorageStats | null;
|
||||
filesCount: number;
|
||||
onClearAll: () => void;
|
||||
onReloadFiles: () => void;
|
||||
storageConfig: StorageConfig;
|
||||
}
|
||||
|
||||
const StorageStatsCard = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
storageConfig,
|
||||
}: StorageStatsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
|
||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
||||
const totalUsed = storageStats.totalSize || storageStats.used;
|
||||
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
|
||||
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Group align="center" gap="md">
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={hardLimitPercent}
|
||||
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
<Group justify="space-between" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")}
|
||||
</Text>
|
||||
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
|
||||
{Math.round(hardLimitPercent)}% used
|
||||
</Text>
|
||||
</Group>
|
||||
{isNearLimit && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
{t("fileManager.reloadFiles", "Reload Files")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
||||
263
frontend/src/components/shared/FilePickerModal.tsx
Normal file
263
frontend/src/components/shared/FilePickerModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
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={t("fileUpload.selectFromStorage", "Select Files from Storage")}
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{sharedFiles.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{/* Selection controls */}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{sharedFiles.length} {t("fileUpload.filesAvailable", "files available")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={selectAll}>
|
||||
{t("pageEdit.selectAll", "Select All")}
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={selectNone}>
|
||||
{t("pageEdit.deselectAll", "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} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t("close", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerModal;
|
||||
153
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
153
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
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,
|
||||
subtitle,
|
||||
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]);
|
||||
|
||||
// Get default title and subtitle from translations if not provided
|
||||
const displayTitle = title || t(allowMultiple ? "fileUpload.selectFiles" : "fileUpload.selectFile",
|
||||
allowMultiple ? "Select files" : "Select a file");
|
||||
const displaySubtitle = subtitle || t(allowMultiple ? "fileUpload.chooseFromStorageMultiple" : "fileUpload.chooseFromStorage",
|
||||
allowMultiple ? "Choose files from storage or upload new PDFs" : "Choose a file from storage or upload a new PDF");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack align="center" gap="xl">
|
||||
{/* Title and description */}
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
||||
<Text size="xl" fw={500}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
<Text size="md" c="dimmed">
|
||||
{displaySubtitle}
|
||||
</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}
|
||||
>
|
||||
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
|
||||
</Button>
|
||||
|
||||
<Text size="md" c="dimmed">
|
||||
{t("fileUpload.or", "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}>
|
||||
{t(allowMultiple ? "fileUpload.dropFilesHere" : "fileUpload.dropFileHere",
|
||||
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')
|
||||
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
||||
: t("fileUpload.supportedFileTypes", "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}
|
||||
>
|
||||
{t(allowMultiple ? "fileUpload.uploadFiles" : "fileUpload.uploadFile",
|
||||
allowMultiple ? "Upload Files" : "Upload File")}
|
||||
</Button>
|
||||
</Dropzone>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
sharedFiles={sharedFiles}
|
||||
onSelectFiles={handleStorageSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadSelector;
|
||||
@@ -68,4 +68,21 @@
|
||||
.languageText {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple animation */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
219
frontend/src/components/shared/LanguageSelector.tsx
Normal file
219
frontend/src/components/shared/LanguageSelector.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
|
||||
|
||||
const languageOptions = Object.entries(supportedLanguages)
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
|
||||
// Start transition animation
|
||||
setIsChanging(true);
|
||||
setPendingLanguage(value);
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsChanging(false);
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
}, 300);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 50);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes ripple-expand {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={styles.languageItem}
|
||||
style={{
|
||||
opacity: animationTriggered ? 1 : 0,
|
||||
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
||||
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={(event) => handleLanguageChange(option.value, event)}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
||||
) : 'transparent',
|
||||
color: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
||||
),
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
||||
),
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '13px',
|
||||
fontWeight: option.value === i18n.language ? 600 : 400,
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
{/* Ripple effect */}
|
||||
{rippleEffect && pendingLanguage === option.value && (
|
||||
<div
|
||||
key={rippleEffect.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: rippleEffect.x,
|
||||
top: rippleEffect.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.colors.blue[4],
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
70
frontend/src/components/shared/QuickAccessBar.tsx
Normal file
70
frontend/src/components/shared/QuickAccessBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { ActionIcon, Stack, Tooltip } from "@mantine/core";
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import MenuBookIcon from "@mui/icons-material/MenuBook";
|
||||
import AppsIcon from "@mui/icons-material/Apps";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
interface QuickAccessBarProps {
|
||||
onToolsClick: () => void;
|
||||
onReaderToggle: () => void;
|
||||
selectedToolKey?: string;
|
||||
toolRegistry: any;
|
||||
leftPanelView: 'toolPicker' | 'toolContent';
|
||||
readerMode: boolean;
|
||||
}
|
||||
|
||||
const QuickAccessBar = ({
|
||||
onToolsClick,
|
||||
onReaderToggle,
|
||||
selectedToolKey,
|
||||
toolRegistry,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
}: QuickAccessBarProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-screen flex flex-col w-20 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
||||
style={{
|
||||
padding: '1rem 0.5rem',
|
||||
backgroundColor: 'var(--bg-muted)'
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" align="center" className="flex-1">
|
||||
{/* All Tools Button */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant={leftPanelView === 'toolPicker' && !readerMode ? "filled" : "subtle"}
|
||||
onClick={onToolsClick}
|
||||
>
|
||||
<AppsIcon sx={{ fontSize: 28 }} />
|
||||
</ActionIcon>
|
||||
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Tools</span>
|
||||
</div>
|
||||
|
||||
{/* Reader Mode Button */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant={readerMode ? "filled" : "subtle"}
|
||||
onClick={onReaderToggle}
|
||||
>
|
||||
<MenuBookIcon sx={{ fontSize: 28 }} />
|
||||
</ActionIcon>
|
||||
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Read</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAccessBar;
|
||||
55
frontend/src/components/shared/RainbowThemeProvider.tsx
Normal file
55
frontend/src/components/shared/RainbowThemeProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
activateRainbow: () => void;
|
||||
deactivateRainbow: () => void;
|
||||
}
|
||||
|
||||
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
|
||||
|
||||
export function useRainbowThemeContext() {
|
||||
const context = useContext(RainbowThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface RainbowThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const rainbowTheme = useRainbowTheme();
|
||||
|
||||
// Determine the Mantine color scheme
|
||||
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/shared/TopControls.tsx
Normal file
106
frontend/src/components/shared/TopControls.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { Button, SegmentedControl } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
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 = [
|
||||
{
|
||||
label: (
|
||||
<Group gap={5}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "viewer",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "pageEditor",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<InsertDriveFileIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "fileManager",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<FolderIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "fileEditor",
|
||||
},
|
||||
];
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: string;
|
||||
setCurrentView: (view: string) => void;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
if (themeMode === "dark") return <LightModeIcon />;
|
||||
return <DarkModeIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label="Toggle theme"
|
||||
disabled={isToggleDisabled}
|
||||
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
|
||||
title={
|
||||
isToggleDisabled
|
||||
? "Button disabled for 3 seconds..."
|
||||
: isRainbowMode
|
||||
? "Rainbow Mode Active! Click to exit"
|
||||
: "Toggle theme (click rapidly 6 times for a surprise!)"
|
||||
}
|
||||
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<SegmentedControl
|
||||
data={VIEW_OPTIONS}
|
||||
value={currentView}
|
||||
onChange={setCurrentView}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopControls;
|
||||
@@ -17,7 +17,7 @@ interface ToolPickerProps {
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, toolRegistry }) => {
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
74
frontend/src/components/tools/ToolRenderer.tsx
Normal file
74
frontend/src/components/tools/ToolRenderer.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
|
||||
interface ToolRendererProps {
|
||||
selectedToolKey: string;
|
||||
selectedTool: any;
|
||||
pdfFile: any;
|
||||
files: FileWithUrl[];
|
||||
downloadUrl: string | null;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
toolParams: any;
|
||||
updateParams: (params: any) => void;
|
||||
}
|
||||
|
||||
const ToolRenderer = ({
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
pdfFile,
|
||||
files,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
toolParams,
|
||||
updateParams,
|
||||
}: ToolRendererProps) => {
|
||||
if (!selectedTool || !selectedTool.component) {
|
||||
return <div>Tool not found</div>;
|
||||
}
|
||||
|
||||
const ToolComponent = selectedTool.component;
|
||||
|
||||
// Pass tool-specific props
|
||||
switch (selectedToolKey) {
|
||||
case "split":
|
||||
return (
|
||||
<ToolComponent
|
||||
file={pdfFile}
|
||||
downloadUrl={downloadUrl}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
case "compress":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
setLoading={(loading: boolean) => {}} // TODO: Add loading state
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
case "merge":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ToolRenderer;
|
||||
Reference in New Issue
Block a user