From 65f30a5231ff5c89ec1b3ae78b1e55c13d3f67bf Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 04:39:42 +0200 Subject: [PATCH] Move page actions into the TopBar as self-hosted icon buttons (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lucide-react (MIT, tree-shaken, compiled into the bundle — no runtime requests; fonts.spec's off-origin assertion covers the page route) - page-actions slot: TopBar registers a DOM element via context, the active page portals its actions into it, TopBar stays page-agnostic - PageActions: mode toggle, watch (WatchToggle icon variant), comments (unread badge kept), attachments, plugin page tools, labels, history as icon buttons with localized aria-label+tooltip (de+en), plus an overflow menu for markdown copy/download, docx/odt/pdf export and the destructive delete (confirm kept) - page header keeps only the title; the editor-shell tools row is gone; panel state lives in PageEditorPage now - hamburger/search/bell adopt the same icon set - e2e: content/export open the overflow menu; class hooks (editor-page__mode-toggle, editor-shell__*-toggle, editor-page__labels-toggle, editor-page__export) kept stable Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/web/e2e/content.spec.ts | 4 +- apps/web/e2e/export.spec.ts | 7 +- apps/web/package.json | 3 +- apps/web/src/comments/use-comments.ts | 5 +- apps/web/src/components/IconButton.tsx | 26 +++ apps/web/src/export/DocumentExportMenu.tsx | 41 ---- apps/web/src/layout/AppLayout.tsx | 44 ++-- apps/web/src/layout/TopBar.tsx | 10 +- apps/web/src/layout/page-actions.tsx | 21 ++ .../src/notifications/NotificationsBell.tsx | 3 +- apps/web/src/pages/PageActions.tsx | 213 ++++++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 194 +++++----------- apps/web/src/styles/base.css | 104 +++++++-- apps/web/src/watches/WatchToggle.tsx | 19 ++ packages/shared/i18n/de/editor.json | 1 + packages/shared/i18n/en/editor.json | 1 + pnpm-lock.yaml | 12 + 17 files changed, 493 insertions(+), 215 deletions(-) create mode 100644 apps/web/src/components/IconButton.tsx delete mode 100644 apps/web/src/export/DocumentExportMenu.tsx create mode 100644 apps/web/src/layout/page-actions.tsx create mode 100644 apps/web/src/pages/PageActions.tsx diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts index 54e96cd..c8bb2bf 100644 --- a/apps/web/e2e/content.spec.ts +++ b/apps/web/e2e/content.spec.ts @@ -174,7 +174,9 @@ test('trash: deleting hides a page from the sidebar; restoring brings it back', page.on('dialog', (dialog) => void dialog.accept()); await page.goto(`/p/${pond.slug}/${slug}`); await enterEditMode(page); - await page.getByRole('button', { name: /move to trash|papierkorb verschieben/i }).click(); + // Delete sits behind the TopBar overflow menu since #101. + await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click(); + await page.getByRole('menuitem', { name: /move to trash|papierkorb verschieben/i }).click(); await page.goto(`/p/${pond.slug}`); await expect(page.getByRole('link', { name: title })).toHaveCount(0); diff --git a/apps/web/e2e/export.spec.ts b/apps/web/e2e/export.spec.ts index 19f7756..d42e97d 100644 --- a/apps/web/e2e/export.spec.ts +++ b/apps/web/e2e/export.spec.ts @@ -49,7 +49,9 @@ test('exports a page to .docx from the page menu', async ({ browser }) => { const page = await context.newPage(); await page.goto(`/p/${pond.slug}/${created_page.slug}`); - // The first export button is `.docx`; clicking runs the job and downloads it. + // The export entries live in the TopBar overflow menu since #101; the + // first one is `.docx`. Clicking runs the job and downloads the result. + await page.locator('.page-actions__more .icon-button').click(); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 30000 }), page.locator('.editor-page__export button').first().click(), @@ -73,7 +75,8 @@ test('exports a page to PDF from the page menu', async ({ browser }) => { const page = await context.newPage(); await page.goto(`/p/${pond.slug}/${created_page.slug}`); - // The third export button is PDF (docx, odt, pdf). + // The third export entry in the overflow menu is PDF (docx, odt, pdf). + await page.locator('.page-actions__more .icon-button').click(); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 30000 }), page.locator('.editor-page__export button').nth(2).click(), diff --git a/apps/web/package.json b/apps/web/package.json index f38228e..56febbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@tiptap/react": "^3.27.1", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^1.24.0", "prosemirror-model": "^1.25.9", "prosemirror-schema-list": "^1.5.0", "prosemirror-tables": "^1.8.5", @@ -41,10 +42,10 @@ }, "devDependencies": { "@playwright/test": "^1.61.1", - "fflate": "^0.8.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "fflate": "^0.8.2", "jsdom": "^26.0.0", "typescript": "^5.7.0", "vite": "^6.1.0", diff --git a/apps/web/src/comments/use-comments.ts b/apps/web/src/comments/use-comments.ts index 9253341..5c23504 100644 --- a/apps/web/src/comments/use-comments.ts +++ b/apps/web/src/comments/use-comments.ts @@ -12,10 +12,11 @@ import { apiGet } from '../lib/api'; export const commentsQueryKey = (pageId: string): [string, string] => ['comments', pageId]; -export function useComments(pageId: string): UseQueryResult { +export function useComments(pageId: string | undefined): UseQueryResult { return useQuery({ - queryKey: commentsQueryKey(pageId), + queryKey: commentsQueryKey(pageId ?? ''), queryFn: () => apiGet(`/pages/${pageId}/comments`), + enabled: Boolean(pageId), }); } diff --git a/apps/web/src/components/IconButton.tsx b/apps/web/src/components/IconButton.tsx new file mode 100644 index 0000000..d19c45f --- /dev/null +++ b/apps/web/src/components/IconButton.tsx @@ -0,0 +1,26 @@ +import type { ButtonHTMLAttributes } from 'react'; + +interface IconButtonProps extends ButtonHTMLAttributes { + /** Localized accessible name; also shown as the hover tooltip. */ + label: string; + active?: boolean; +} + +/** A compact icon-only button (issue #101): aria-label + title carry the + * localized name, `active` marks toggles that are currently on. */ +export function IconButton({ + label, + active, + className, + children, + ...rest +}: IconButtonProps): React.JSX.Element { + const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? ''] + .filter(Boolean) + .join(' '); + return ( + + ); +} diff --git a/apps/web/src/export/DocumentExportMenu.tsx b/apps/web/src/export/DocumentExportMenu.tsx deleted file mode 100644 index 7d833fc..0000000 --- a/apps/web/src/export/DocumentExportMenu.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared'; -import { useTranslation } from 'react-i18next'; - -import { useDocumentExport } from './use-document-export'; - -interface DocumentExportMenuProps { - pageId: string; - slug: string; -} - -/** - * Document export buttons for the page menu (issues #65/#67): `.docx`/`.odt` - * (pandoc) and PDF (Gotenberg) each run a conversion job and download the - * result. Markdown copy/download live in the page menu already (#30). - */ -export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element { - const { t } = useTranslation('export'); - const { status, exportPage } = useDocumentExport(); - - const label = (format: ExportFormat): string => { - if (status[format] === 'busy') return t('exporting'); - if (status[format] === 'error') return t('failed'); - return t(format); - }; - - return ( - - {EXPORT_FORMATS.map((format) => ( - - ))} - - ); -} diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index 0a296ea..b6439b3 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Outlet } from 'react-router-dom'; import { usePersistentState } from '../lib/use-persistent-state'; import { Footer } from './Footer'; +import { PageActionsSlotContext } from './page-actions'; import { SidebarChromeContext } from './sidebar-chrome'; import { Sidebar } from './Sidebar'; import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer'; @@ -18,6 +19,13 @@ export function AppLayout(): React.JSX.Element { const collapsed = sidebarCollapsed || forcedHidden; // Clamp on read too — the persisted value may predate a bounds change. const widthRem = clampSidebarWidth(sidebarWidth); + // The TopBar registers its page-actions element here; the active page + // portals its icon actions into it (issue #101). + const [actionsElement, setActionsElement] = useState(null); + const actionsSlot = useMemo( + () => ({ element: actionsElement, setElement: setActionsElement }), + [actionsElement], + ); // Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of // which element has focus. @@ -34,23 +42,25 @@ export function AppLayout(): React.JSX.Element { return ( -
- setSidebarCollapsed(!sidebarCollapsed)} - /> -
- - {!collapsed && } -
- -
-
+ +
+ setSidebarCollapsed(!sidebarCollapsed)} + /> +
+ + {!collapsed && } +
+ +
+
+
-
+ ); } diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index fc66969..f740352 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -1,3 +1,4 @@ +import { Menu, Search } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; @@ -5,6 +6,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { SearchPalette } from '../search/SearchPalette'; import { NotificationsBell } from '../notifications/NotificationsBell'; +import { usePageActionsSlot } from './page-actions'; import { PondSwitcher } from './PondSwitcher'; /** True when focus is in a field where "/" should type, not open search. */ @@ -25,6 +27,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const { setElement: setPageActionsElement } = usePageActionsSlot(); async function handleLogout(): Promise { setMenuOpen(false); @@ -54,14 +57,15 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac aria-expanded={!sidebarCollapsed} aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')} > - {/* Simple hamburger glyph; replaced by an icon set later. */} - + Dorfteich {user && } + {/* Pages portal their icon actions here while active (issue #101). */} + {user &&
} {user && ( )} {searchOpen && user && setSearchOpen(false)} />} diff --git a/apps/web/src/layout/page-actions.tsx b/apps/web/src/layout/page-actions.tsx new file mode 100644 index 0000000..68cdd6a --- /dev/null +++ b/apps/web/src/layout/page-actions.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react'; + +/** + * The TopBar's page-actions slot (issue #101). The TopBar registers a DOM + * element here; the active page portals its icon actions into it, so the + * TopBar itself stays page-agnostic. `null` outside page routes (or before + * the TopBar has mounted) — consumers simply render nothing then. + */ +export interface PageActionsSlot { + element: HTMLElement | null; + setElement: (element: HTMLElement | null) => void; +} + +export const PageActionsSlotContext = createContext({ + element: null, + setElement: () => {}, +}); + +export function usePageActionsSlot(): PageActionsSlot { + return useContext(PageActionsSlotContext); +} diff --git a/apps/web/src/notifications/NotificationsBell.tsx b/apps/web/src/notifications/NotificationsBell.tsx index 6ca742d..049f04b 100644 --- a/apps/web/src/notifications/NotificationsBell.tsx +++ b/apps/web/src/notifications/NotificationsBell.tsx @@ -1,5 +1,6 @@ import type { NotificationListView, NotificationView } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Bell } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -52,7 +53,7 @@ export function NotificationsBell(): React.JSX.Element { aria-label={t('title')} onClick={() => setOpen((value) => !value)} > - 🔔 + {unread > 0 && {unread}} {open && ( diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx new file mode 100644 index 0000000..65fcc89 --- /dev/null +++ b/apps/web/src/pages/PageActions.tsx @@ -0,0 +1,213 @@ +import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared'; +import { + BookOpen, + Copy, + Download, + Ellipsis, + History, + MessageSquare, + Paperclip, + Pencil, + Tag, + Trash2, + Wrench, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { IconButton } from '../components/IconButton'; +import { useDocumentExport } from '../export/use-document-export'; +import { apiDelete, apiGetText } from '../lib/api'; +import { WatchToggle } from '../watches/WatchToggle'; + +interface PageActionsProps { + pageId: string; + slug: string; + pondSlug: string; + mode: 'view' | 'edit'; + onToggleMode: () => void; + unread: number; + showComments: boolean; + onToggleComments: () => void; + showAttachments: boolean; + onToggleAttachments: () => void; + hasTools: boolean; + showPageTools: boolean; + onTogglePageTools: () => void; + showLabels: boolean; + onToggleLabels: () => void; + showHistory: boolean; + onToggleHistory: () => void; +} + +/** + * The page's action icons in the TopBar (issue #101), portalled into the + * page-actions slot while a page route is active. Rarely used and + * destructive actions live behind the trailing overflow menu. + */ +export function PageActions(props: PageActionsProps): React.JSX.Element { + const { t } = useTranslation('editor'); + + return ( +
+ + {props.mode === 'edit' ? : } + + + 0 + ? `${t('comments:toggle')} — ${t('comments:unread', { count: props.unread })}` + : t('comments:toggle') + } + active={props.showComments} + aria-expanded={props.showComments} + onClick={props.onToggleComments} + > + + {props.unread > 0 && {props.unread}} + + + + + {props.hasTools && ( + + + + )} + + + + + + + +
+ ); +} + +/** Overflow "…" menu: Markdown copy/download (#30), office/PDF export + * (#65/#67), and the destructive move-to-trash (#31, keeps its confirm). */ +function PageOverflowMenu({ + pageId, + slug, + pondSlug, +}: { + pageId: string; + slug: string; + pondSlug: string; +}): React.JSX.Element { + const { t } = useTranslation('editor'); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); + const { status, exportPage } = useDocumentExport(); + + async function copyMarkdown(): Promise { + try { + const markdown = await apiGetText(`/pages/${pageId}/export/markdown`); + await navigator.clipboard.writeText(markdown); + setCopyStatus('copied'); + } catch { + setCopyStatus('error'); + } + setTimeout(() => setCopyStatus('idle'), 2000); + } + + async function deletePage(): Promise { + if (!window.confirm(t('page.deleteConfirm'))) return; + await apiDelete(`/pages/${pageId}`); + navigate(`/p/${pondSlug}`); + } + + const exportLabel = (format: ExportFormat): string => { + if (status[format] === 'busy') return t('export:exporting'); + if (status[format] === 'error') return t('export:failed'); + return t(`export:${format}`); + }; + + return ( +
+ setOpen((value) => !value)} + > + + + {open && ( +
+ + setOpen(false)} + > + + {t('page.downloadMarkdown')} + + + {EXPORT_FORMATS.map((format) => ( + + ))} + + +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 297c630..15ace63 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as Y from 'yjs'; @@ -19,7 +20,6 @@ import { LabelPicker } from '../labels/LabelPicker'; import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; -import { DocumentExportMenu } from '../export/DocumentExportMenu'; import { PondFontScope } from '../fonts/PondFontScope'; import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; @@ -27,9 +27,10 @@ import { Toolbar } from '../editor/Toolbar'; import { useCollabProvider } from '../editor/use-collab-provider'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; +import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { WatchToggle } from '../watches/WatchToggle'; -import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; +import { ApiError, apiGet, apiPatch } from '../lib/api'; +import { PageActions } from './PageActions'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; @@ -63,22 +64,25 @@ function PageEditor({ mode, pondSlug, commentPolicy, + showAttachments, + showComments, + showPageTools, + onCloseAttachments, + onCloseComments, }: { page: ResolvedPage; mode: Mode; pondSlug: string; commentPolicy: 'readers' | 'editors'; + showAttachments: boolean; + showComments: boolean; + showPageTools: boolean; + onCloseAttachments: () => void; + onCloseComments: () => void; }): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); const navigate = useNavigate(); - const [showAttachments, setShowAttachments] = useState(false); - // Deep link from a comment notification (issue #94): ?comments=1 opens - // the panel immediately. - const [showComments, setShowComments] = useState( - () => new URLSearchParams(window.location.search).get('comments') === '1', - ); - const [showPageTools, setShowPageTools] = useState(false); // Created and destroyed within the same effect (not `useMemo` + a separate // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would @@ -99,9 +103,6 @@ function PageEditor({ const collab = useCollabProvider(ydoc, page.id); const readOnly = collab.mode === 'ro'; - // Comments (issue #92): the badge needs the data before the panel opens. - const comments = useComments(page.id); - const unread = showComments ? 0 : unreadCount(comments.data, page.id, user?.id); // `readers` = everyone who can see the page; `editors` = the collab token // explicitly granted rw (while it is still null, stay conservative — the // composer appears once the mode resolves). @@ -195,39 +196,6 @@ function PageEditor({ {canEdit && ( )} -
- - - {hasPageTools(pondPlugins.data) && ( - - )} -
{showPageTools && ( )} @@ -236,15 +204,11 @@ function PageEditor({ pageId={page.id} editor={editor} canEdit={canEdit} - onClose={() => setShowAttachments(false)} + onClose={onCloseAttachments} /> )} {showComments && ( - setShowComments(false)} - /> + )}
{t(`connection.${collab.status}`)} @@ -280,80 +244,22 @@ function PageEditor({ ); } -/** Page-level actions: Markdown export (issue #30, both read from the - * server-cached `page_content_cache.markdown` so "copy" and "download" - * always agree with each other and the last saved state) and moving the - * page to the trash (issue #31) — a soft delete, so this is reversible via - * the pond's trash view; a plain `confirm()` is enough given that. */ -function PageMenu({ - pageId, - slug, - pondSlug, - onToggleHistory, - onToggleLabels, -}: { - pageId: string; - slug: string; - pondSlug: string; - onToggleHistory: () => void; - onToggleLabels: () => void; -}): React.JSX.Element { - const { t } = useTranslation('editor'); - const navigate = useNavigate(); - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); - - async function copyMarkdown(): Promise { - try { - const markdown = await apiGetText(`/pages/${pageId}/export/markdown`); - await navigator.clipboard.writeText(markdown); - setCopyStatus('copied'); - } catch { - setCopyStatus('error'); - } - setTimeout(() => setCopyStatus('idle'), 2000); - } - - async function deletePage(): Promise { - if (!window.confirm(t('page.deleteConfirm'))) return; - await apiDelete(`/pages/${pageId}`); - navigate(`/p/${pondSlug}`); - } - - return ( -
- - - {t('page.downloadMarkdown')} - - - - - -
- ); -} - export function PageEditorPage(): React.JSX.Element { const { t } = useTranslation('editor'); + const { user } = useAuth(); const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); const [mode, setMode] = useState('view'); const [title, setTitle] = useState(''); const [showHistory, setShowHistory] = useState(false); const [showLabels, setShowLabels] = useState(false); + const [showAttachments, setShowAttachments] = useState(false); + // Deep link from a comment notification (issue #94): ?comments=1 opens + // the panel immediately. + const [showComments, setShowComments] = useState( + () => new URLSearchParams(window.location.search).get('comments') === '1', + ); + const [showPageTools, setShowPageTools] = useState(false); + const actionsSlot = usePageActionsSlot(); useForceSidebarHidden(mode === 'edit'); @@ -401,6 +307,12 @@ export function PageEditorPage(): React.JSX.Element { if (resolved) setTitle(resolved.title); }, [resolved?.id, resolved?.title]); + // TopBar action data (issue #101): the comments badge and the plugin + // page-tools visibility live next to the icons, not inside the editor. + const comments = useComments(resolved?.id); + const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id); + const pagePlugins = usePondPlugins(resolved?.pondId); + async function saveTitle(): Promise { if (!resolved || title === resolved.title) return; await apiPatch(`/pages/${resolved.id}`, { title }); @@ -423,6 +335,30 @@ export function PageEditorPage(): React.JSX.Element { return ( + {/* The page's actions render as icons in the TopBar (issue #101). */} + {actionsSlot.element && + createPortal( + setMode(mode === 'edit' ? 'view' : 'edit')} + unread={unread} + showComments={showComments} + onToggleComments={() => setShowComments((open) => !open)} + showAttachments={showAttachments} + onToggleAttachments={() => setShowAttachments((open) => !open)} + hasTools={hasPageTools(pagePlugins.data)} + showPageTools={showPageTools} + onTogglePageTools={() => setShowPageTools((open) => !open)} + showLabels={showLabels} + onToggleLabels={() => setShowLabels((open) => !open)} + showHistory={showHistory} + onToggleHistory={() => setShowHistory((open) => !open)} + />, + actionsSlot.element, + )}
setTitle(event.target.value)} onBlur={() => void saveTitle()} /> - - - setShowHistory((open) => !open)} - onToggleLabels={() => setShowLabels((open) => !open)} - />
setShowAttachments(false)} + onCloseComments={() => setShowComments(false)} /> {showLabels && ( void toggle()} + > + + + ); + } return (