mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Add edit table of contents tool to React UI (#4917)
## Summary - add a dedicated edit table of contents tool to the React UI, complete with bookmark editor, import/export actions, and parameter handling - register the tool in the translated registry and extend the English translations with the new strings - wire up the backend endpoints through a new operation hook and form-data serialization helpers ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_691a4a87a9c4832899ecd1c55989f27f) --------- Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
a8ea0b60cf
commit
87bf7a5b7f
@@ -0,0 +1,313 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
|
||||
|
||||
interface BookmarkEditorProps {
|
||||
bookmarks: BookmarkNode[];
|
||||
onChange: (bookmarks: BookmarkNode[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const updateTree = (
|
||||
nodes: BookmarkNode[],
|
||||
targetId: string,
|
||||
updater: (bookmark: BookmarkNode) => BookmarkNode,
|
||||
): BookmarkNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === targetId) {
|
||||
return updater(node);
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const updatedChildren = updateTree(node.children, targetId, updater);
|
||||
if (updatedChildren !== node.children) {
|
||||
return { ...node, children: updatedChildren };
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
|
||||
return nodes
|
||||
.filter(node => node.id !== targetId)
|
||||
.map(node => ({
|
||||
...node,
|
||||
children: removeFromTree(node.children, targetId),
|
||||
}));
|
||||
};
|
||||
|
||||
const addChildToTree = (
|
||||
nodes: BookmarkNode[],
|
||||
parentId: string,
|
||||
child: BookmarkNode,
|
||||
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||
let added = false;
|
||||
const next = nodes.map(node => {
|
||||
if (node.id === parentId) {
|
||||
added = true;
|
||||
return { ...node, expanded: true, children: [...node.children, child] };
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const result = addChildToTree(node.children, parentId, child);
|
||||
if (result.added) {
|
||||
added = true;
|
||||
return { ...node, children: result.nodes };
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
return { nodes: added ? next : nodes, added };
|
||||
};
|
||||
|
||||
const addSiblingInTree = (
|
||||
nodes: BookmarkNode[],
|
||||
targetId: string,
|
||||
sibling: BookmarkNode,
|
||||
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||
let added = false;
|
||||
const result: BookmarkNode[] = [];
|
||||
|
||||
nodes.forEach(node => {
|
||||
let currentNode = node;
|
||||
|
||||
if (!added && node.children.length > 0) {
|
||||
const childResult = addSiblingInTree(node.children, targetId, sibling);
|
||||
if (childResult.added) {
|
||||
added = true;
|
||||
currentNode = { ...node, children: childResult.nodes };
|
||||
}
|
||||
}
|
||||
|
||||
result.push(currentNode);
|
||||
|
||||
if (!added && node.id === targetId) {
|
||||
result.push(sibling);
|
||||
added = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes: added ? result : nodes, added };
|
||||
};
|
||||
|
||||
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAddTopLevel = () => {
|
||||
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
|
||||
onChange([...bookmarks, newBookmark]);
|
||||
};
|
||||
|
||||
const handleTitleChange = (id: string, value: string) => {
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
|
||||
};
|
||||
|
||||
const handlePageChange = (id: string, value: number | string) => {
|
||||
const page = typeof value === 'number' ? value : parseInt(value, 10);
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
|
||||
};
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const confirmation = t(
|
||||
'editTableOfContents.editor.confirmRemove',
|
||||
'Remove this bookmark and all of its children?'
|
||||
);
|
||||
if (window.confirm(confirmation)) {
|
||||
onChange(removeFromTree(bookmarks, id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChild = (parentId: string) => {
|
||||
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
|
||||
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
|
||||
onChange(added ? nodes : [...bookmarks, child]);
|
||||
};
|
||||
|
||||
const handleAddSibling = (targetId: string) => {
|
||||
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
|
||||
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
|
||||
onChange(added ? nodes : [...bookmarks, sibling]);
|
||||
};
|
||||
|
||||
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
|
||||
const hasChildren = bookmark.children.length > 0;
|
||||
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={bookmark.id}
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: 'var(--border-default)',
|
||||
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex align="flex-start" justify="space-between" gap="md">
|
||||
<Group gap="sm" align="flex-start">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => hasChildren && handleToggle(bookmark.id)}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
<LocalIcon icon={chevronIcon} />
|
||||
</ActionIcon>
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
|
||||
{level > 0 && (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t('editTableOfContents.editor.childBadge', 'Child')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
onClick={() => handleAddChild(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="subdirectory-arrow-right-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => handleAddSibling(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="add-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleRemove(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="delete-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{bookmark.expanded && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
size="sm"
|
||||
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
|
||||
value={bookmark.title}
|
||||
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
label={t('editTableOfContents.editor.field.page', 'Target page number')}
|
||||
min={1}
|
||||
clampBehavior="strict"
|
||||
value={bookmark.pageNumber}
|
||||
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmark.expanded && hasChildren && (
|
||||
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
|
||||
{bookmark.children.map(child => (
|
||||
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
color="blue"
|
||||
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
|
||||
onClick={handleAddTopLevel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
<Paper withBorder radius="md" ta="center" py="xl">
|
||||
<Stack gap="xs" align="center" px="lg">
|
||||
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
|
||||
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
|
||||
<Text size="sm" c="dimmed" maw={420}>
|
||||
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
leftSection={<LocalIcon icon="add-rounded" />}
|
||||
onClick={handleAddTopLevel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{bookmarks.map(bookmark => renderBookmark(bookmark))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
FileButton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||
|
||||
interface EditTableOfContentsSettingsProps {
|
||||
bookmarks: BookmarkNode[];
|
||||
replaceExisting: boolean;
|
||||
onReplaceExistingChange: (value: boolean) => void;
|
||||
onSelectFiles: () => void;
|
||||
onLoadFromPdf: () => void;
|
||||
onImportJson: (file: File) => void;
|
||||
onImportClipboard: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportClipboard: () => void;
|
||||
isLoading: boolean;
|
||||
loadError?: string | null;
|
||||
canReadClipboard: boolean;
|
||||
canWriteClipboard: boolean;
|
||||
disabled?: boolean;
|
||||
selectedFileName?: string;
|
||||
}
|
||||
|
||||
export default function EditTableOfContentsSettings({
|
||||
bookmarks,
|
||||
replaceExisting,
|
||||
onReplaceExistingChange,
|
||||
onSelectFiles,
|
||||
onLoadFromPdf,
|
||||
onImportJson,
|
||||
onImportClipboard,
|
||||
onExportJson,
|
||||
onExportClipboard,
|
||||
isLoading,
|
||||
loadError,
|
||||
canReadClipboard,
|
||||
canWriteClipboard,
|
||||
disabled,
|
||||
selectedFileName,
|
||||
}: EditTableOfContentsSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const infoLines = useMemo(() => ([
|
||||
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
|
||||
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
|
||||
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
|
||||
]), [t]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="folder-rounded" />}
|
||||
onClick={onSelectFiles}
|
||||
fullWidth
|
||||
>
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
|
||||
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
|
||||
</Button>
|
||||
|
||||
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
|
||||
onClick={onLoadFromPdf}
|
||||
loading={isLoading}
|
||||
disabled={disabled || !selectedFileName}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<FileButton
|
||||
onChange={file => file && onImportJson(file)}
|
||||
accept="application/json"
|
||||
disabled={disabled}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="upload-rounded" />}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.importJson', 'Import JSON')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<Tooltip
|
||||
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||
disabled={canReadClipboard}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="content-paste-rounded" />}
|
||||
onClick={onImportClipboard}
|
||||
disabled={disabled || !canReadClipboard}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{loadError && (
|
||||
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
|
||||
{loadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="download-rounded" />}
|
||||
onClick={onExportJson}
|
||||
disabled={disabled || bookmarks.length === 0}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
|
||||
</Button>
|
||||
|
||||
<Tooltip
|
||||
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||
disabled={canWriteClipboard}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="content-copy-rounded" />}
|
||||
onClick={onExportClipboard}
|
||||
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
checked={replaceExisting}
|
||||
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
|
||||
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
|
||||
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
{infoLines.map((line, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
||||
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||
|
||||
export interface EditTableOfContentsWorkbenchViewData {
|
||||
bookmarks: BookmarkNode[];
|
||||
selectedFileName?: string;
|
||||
disabled: boolean;
|
||||
files: File[];
|
||||
thumbnails: (string | undefined)[];
|
||||
downloadUrl: string | null;
|
||||
downloadFilename: string | null;
|
||||
errorMessage: string | null;
|
||||
isGeneratingThumbnails: boolean;
|
||||
isExecuteDisabled: boolean;
|
||||
isExecuting: boolean;
|
||||
onClearError: () => void;
|
||||
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
|
||||
onExecute: () => void;
|
||||
onUndo: () => void;
|
||||
onFileClick: (file: File) => void;
|
||||
}
|
||||
|
||||
interface EditTableOfContentsWorkbenchViewProps {
|
||||
data: EditTableOfContentsWorkbenchViewData | null;
|
||||
}
|
||||
|
||||
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Card withBorder radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
selectedFileName,
|
||||
disabled,
|
||||
files,
|
||||
thumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
errorMessage,
|
||||
isGeneratingThumbnails,
|
||||
isExecuteDisabled,
|
||||
isExecuting,
|
||||
onClearError,
|
||||
onBookmarksChange,
|
||||
onExecute,
|
||||
onUndo,
|
||||
onFileClick,
|
||||
} = data;
|
||||
|
||||
const previewFiles = useMemo(
|
||||
() =>
|
||||
files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: thumbnails[index],
|
||||
})) ?? [],
|
||||
[files, thumbnails]
|
||||
);
|
||||
|
||||
const showResults = Boolean(
|
||||
previewFiles.length > 0 || downloadUrl || errorMessage
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p="lg"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-raised)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xl" maw={1200} mx="auto">
|
||||
<Stack gap={4}>
|
||||
<Text size="xl" fw={700}>
|
||||
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="menu-book-rounded" />}
|
||||
color="blue"
|
||||
onClick={onExecute}
|
||||
disabled={isExecuteDisabled}
|
||||
loading={isExecuting}
|
||||
>
|
||||
{t('editTableOfContents.submit', 'Apply table of contents')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{showResults && (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap={4}>
|
||||
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<ErrorNotification error={errorMessage} onClose={onClearError} />
|
||||
|
||||
{previewFiles.length > 0 && (
|
||||
<ResultsPreview
|
||||
files={previewFiles}
|
||||
onFileClick={onFileClick}
|
||||
isGeneratingThumbnails={isGeneratingThumbnails}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download={downloadFilename ?? undefined}
|
||||
leftSection={<LocalIcon icon='download-rounded' />}
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<LocalIcon icon="rotate-left" />}
|
||||
onClick={onUndo}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
{t('undo', 'Undo')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTableOfContentsWorkbenchView;
|
||||
Reference in New Issue
Block a user