Move page actions into the TopBar as self-hosted icon buttons (#101)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 04:39:42 +02:00
parent 49e4377764
commit 65f30a5231
17 changed files with 493 additions and 215 deletions

View File

@ -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);

View File

@ -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(),

View File

@ -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",

View File

@ -12,10 +12,11 @@ import { apiGet } from '../lib/api';
export const commentsQueryKey = (pageId: string): [string, string] => ['comments', pageId];
export function useComments(pageId: string): UseQueryResult<PageCommentsView> {
export function useComments(pageId: string | undefined): UseQueryResult<PageCommentsView> {
return useQuery({
queryKey: commentsQueryKey(pageId),
queryKey: commentsQueryKey(pageId ?? ''),
queryFn: () => apiGet<PageCommentsView>(`/pages/${pageId}/comments`),
enabled: Boolean(pageId),
});
}

View File

@ -0,0 +1,26 @@
import type { ButtonHTMLAttributes } from 'react';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 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 (
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
{children}
</button>
);
}

View File

@ -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 (
<span className="editor-page__export">
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
className="button"
disabled={status[format] === 'busy'}
onClick={() => exportPage(pageId, slug, format)}
>
{label(format)}
</button>
))}
</span>
);
}

View File

@ -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<HTMLElement | null>(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 (
<SidebarChromeContext.Provider value={setForcedHidden}>
<div className="app">
<TopBar
sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div
className="app-body"
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
>
<Sidebar collapsed={collapsed} />
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
<main className="main">
<Outlet />
<Footer />
</main>
<PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app">
<TopBar
sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div
className="app-body"
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
>
<Sidebar collapsed={collapsed} />
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
<main className="main">
<Outlet />
<Footer />
</main>
</div>
</div>
</div>
</PageActionsSlotContext.Provider>
</SidebarChromeContext.Provider>
);
}

View File

@ -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<void> {
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. */}
<span aria-hidden></span>
<Menu aria-hidden />
</button>
<Link to="/" className="topbar__brand">
Dorfteich
</Link>
{user && <PondSwitcher />}
<span className="topbar__spacer" />
{/* Pages portal their icon actions here while active (issue #101). */}
{user && <div className="topbar__page-actions" ref={setPageActionsElement} />}
{user && (
<button
type="button"
@ -69,7 +73,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
onClick={() => setSearchOpen(true)}
aria-label={t('search:open')}
>
<span aria-hidden>🔍</span> {t('search:open')}
<Search aria-hidden /> {t('search:open')}
</button>
)}
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}

View File

@ -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<PageActionsSlot>({
element: null,
setElement: () => {},
});
export function usePageActionsSlot(): PageActionsSlot {
return useContext(PageActionsSlotContext);
}

View File

@ -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)}
>
🔔
<Bell aria-hidden />
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
</button>
{open && (

View File

@ -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 (
<div className="page-actions">
<IconButton
className="editor-page__mode-toggle"
label={props.mode === 'edit' ? t('mode.view') : t('mode.edit')}
onClick={props.onToggleMode}
>
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
</IconButton>
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
<IconButton
className="editor-shell__comments-toggle"
label={
props.unread > 0
? `${t('comments:toggle')}${t('comments:unread', { count: props.unread })}`
: t('comments:toggle')
}
active={props.showComments}
aria-expanded={props.showComments}
onClick={props.onToggleComments}
>
<MessageSquare aria-hidden />
{props.unread > 0 && <span className="comments-unread-badge">{props.unread}</span>}
</IconButton>
<IconButton
className="editor-shell__attachments-toggle"
label={t('files:title')}
active={props.showAttachments}
aria-expanded={props.showAttachments}
onClick={props.onToggleAttachments}
>
<Paperclip aria-hidden />
</IconButton>
{props.hasTools && (
<IconButton
className="editor-shell__page-tools-toggle"
label={t('plugins:tools.title')}
active={props.showPageTools}
aria-expanded={props.showPageTools}
onClick={props.onTogglePageTools}
>
<Wrench aria-hidden />
</IconButton>
)}
<IconButton
className="editor-page__labels-toggle"
label={t('labels:picker.open')}
active={props.showLabels}
aria-expanded={props.showLabels}
onClick={props.onToggleLabels}
>
<Tag aria-hidden />
</IconButton>
<IconButton
label={t('history.open')}
active={props.showHistory}
aria-expanded={props.showHistory}
onClick={props.onToggleHistory}
>
<History aria-hidden />
</IconButton>
<PageOverflowMenu pageId={props.pageId} slug={props.slug} pondSlug={props.pondSlug} />
</div>
);
}
/** 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<void> {
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<void> {
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 (
<div className="page-actions__more">
<IconButton
label={t('page.moreActions')}
active={open}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<Ellipsis aria-hidden />
</IconButton>
{open && (
<div className="page-actions__menu" role="menu">
<button type="button" role="menuitem" onClick={() => void copyMarkdown()}>
<Copy aria-hidden />
{copyStatus === 'idle' && t('page.copyMarkdown')}
{copyStatus === 'copied' && t('page.markdownCopied')}
{copyStatus === 'error' && t('page.markdownCopyFailed')}
</button>
<a
role="menuitem"
href={`/api/v1/pages/${pageId}/export/markdown`}
download={`${slug}.md`}
onClick={() => setOpen(false)}
>
<Download aria-hidden />
{t('page.downloadMarkdown')}
</a>
<span className="editor-page__export">
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
role="menuitem"
disabled={status[format] === 'busy'}
onClick={() => exportPage(pageId, slug, format)}
>
<Download aria-hidden />
{exportLabel(format)}
</button>
))}
</span>
<button
type="button"
role="menuitem"
className="page-actions__menu-danger"
onClick={() => void deletePage()}
>
<Trash2 aria-hidden />
{t('page.delete')}
</button>
</div>
)}
</div>
);
}

View File

@ -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 && (
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
)}
<div className="editor-shell__tools">
<button
type="button"
className="button editor-shell__attachments-toggle"
aria-expanded={showAttachments}
onClick={() => setShowAttachments((open) => !open)}
>
{t('files:title')}
</button>
<button
type="button"
className="button editor-shell__comments-toggle"
aria-expanded={showComments}
onClick={() => setShowComments((open) => !open)}
>
{t('comments:toggle')}
{unread > 0 && (
<span className="comments-unread-badge">
{t('comments:unread', { count: unread })}
</span>
)}
</button>
{hasPageTools(pondPlugins.data) && (
<button
type="button"
className="button editor-shell__page-tools-toggle"
aria-expanded={showPageTools}
onClick={() => setShowPageTools((open) => !open)}
>
{t('plugins:tools.title')}
</button>
)}
</div>
{showPageTools && (
<PageToolsPanel plugins={pondPlugins.data} context={pluginBlockScope} />
)}
@ -236,15 +204,11 @@ function PageEditor({
pageId={page.id}
editor={editor}
canEdit={canEdit}
onClose={() => setShowAttachments(false)}
onClose={onCloseAttachments}
/>
)}
{showComments && (
<CommentsPanel
pageId={page.id}
mayComment={mayComment}
onClose={() => setShowComments(false)}
/>
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
)}
<div className="editor-connection" role="status" data-status={collab.status}>
{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<void> {
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<void> {
if (!window.confirm(t('page.deleteConfirm'))) return;
await apiDelete(`/pages/${pageId}`);
navigate(`/p/${pondSlug}`);
}
return (
<div className="editor-page__actions">
<button type="button" className="button" onClick={() => void copyMarkdown()}>
{copyStatus === 'idle' && t('page.copyMarkdown')}
{copyStatus === 'copied' && t('page.markdownCopied')}
{copyStatus === 'error' && t('page.markdownCopyFailed')}
</button>
<a
className="button"
href={`/api/v1/pages/${pageId}/export/markdown`}
download={`${slug}.md`}
>
{t('page.downloadMarkdown')}
</a>
<DocumentExportMenu pageId={pageId} slug={slug} />
<button type="button" className="button editor-page__labels-toggle" onClick={onToggleLabels}>
{t('labels:picker.open')}
</button>
<button type="button" className="button" onClick={onToggleHistory}>
{t('history.open')}
</button>
<button type="button" className="button" onClick={() => void deletePage()}>
{t('page.delete')}
</button>
</div>
);
}
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<Mode>('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<void> {
if (!resolved || title === resolved.title) return;
await apiPatch(`/pages/${resolved.id}`, { title });
@ -423,6 +335,30 @@ export function PageEditorPage(): React.JSX.Element {
return (
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
{/* The page's actions render as icons in the TopBar (issue #101). */}
{actionsSlot.element &&
createPortal(
<PageActions
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => 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,
)}
<div className="editor-page">
<div className="editor-page__header">
<input
@ -434,21 +370,6 @@ export function PageEditorPage(): React.JSX.Element {
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
<WatchToggle targetType="page" targetId={resolved.id} />
<button
type="button"
className="button editor-page__mode-toggle"
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
>
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button>
<PageMenu
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
onToggleHistory={() => setShowHistory((open) => !open)}
onToggleLabels={() => setShowLabels((open) => !open)}
/>
</div>
<div className="editor-page__body">
<PageEditor
@ -456,6 +377,11 @@ export function PageEditorPage(): React.JSX.Element {
mode={mode}
pondSlug={pondSlug}
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
showAttachments={showAttachments}
showComments={showComments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onCloseComments={() => setShowComments(false)}
/>
{showLabels && (
<LabelPicker

View File

@ -250,6 +250,7 @@ button {
}
.icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
@ -262,6 +263,95 @@ button {
color: var(--color-text);
}
.icon-button svg {
width: 1.15rem;
height: 1.15rem;
}
.icon-button--active {
color: var(--color-accent);
background: var(--color-bg-subtle);
}
.icon-button:disabled {
opacity: 0.5;
cursor: default;
}
/* Page actions in the TopBar (issue #101). */
.topbar__page-actions {
display: flex;
align-items: center;
}
.page-actions {
display: flex;
align-items: center;
gap: var(--space-1);
}
.icon-button .comments-unread-badge {
position: absolute;
top: -4px;
right: -6px;
margin-left: 0;
line-height: 1.1rem;
padding: 0 0.3rem;
}
.page-actions__more {
position: relative;
}
.page-actions__menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 14rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
display: flex;
flex-direction: column;
z-index: 10;
}
.page-actions__menu a,
.page-actions__menu button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
text-align: left;
background: none;
border: none;
color: var(--color-text);
text-decoration: none;
cursor: pointer;
font: inherit;
}
.page-actions__menu svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.page-actions__menu a:hover,
.page-actions__menu button:hover:not(:disabled) {
background: var(--color-bg-subtle);
}
.page-actions__menu button:disabled {
color: var(--color-text-muted);
cursor: default;
}
.page-actions__menu button.page-actions__menu-danger {
color: var(--color-danger);
}
.icon-button:hover {
background: var(--color-bg-subtle);
border-color: var(--color-border);
@ -637,12 +727,7 @@ button {
outline-offset: 2px;
}
.editor-page__actions {
display: flex;
gap: var(--space-2);
}
/* The export buttons flow inline with the other page actions (same gap). */
/* The export entries flow inline with the other overflow-menu items. */
.editor-page__export {
display: contents;
}
@ -2112,13 +2197,6 @@ button {
}
/* Attachments: page section + pond file manager (issue #61) */
.editor-shell__tools {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.attachments-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.375rem);

View File

@ -1,19 +1,24 @@
import type { WatchStateView, WatchTargetType } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Eye } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { apiDelete, apiGet, apiPut } from '../lib/api';
/**
* Watch/unwatch button for page and pond headers (issue #93). The state is
* per-user (server-side); toggling updates optimistically via refetch.
* `variant="icon"` renders the compact TopBar form (issue #101).
*/
export function WatchToggle({
targetType,
targetId,
variant = 'button',
}: {
targetType: WatchTargetType;
targetId: string;
variant?: 'button' | 'icon';
}): React.JSX.Element {
const { t } = useTranslation('watches');
const queryClient = useQueryClient();
@ -33,6 +38,20 @@ export function WatchToggle({
};
const watched = state.data?.watched ?? false;
if (variant === 'icon') {
return (
<IconButton
className="watch-toggle"
label={watched ? t('watching') : t('watch')}
active={watched}
aria-pressed={watched}
disabled={state.isLoading}
onClick={() => void toggle()}
>
<Eye aria-hidden />
</IconButton>
);
}
return (
<button
type="button"

View File

@ -122,6 +122,7 @@
"markdownCopied": "Kopiert!",
"markdownCopyFailed": "Kopieren fehlgeschlagen",
"downloadMarkdown": "Als Markdown herunterladen",
"moreActions": "Weitere Aktionen",
"delete": "In den Papierkorb verschieben",
"deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen."
},

View File

@ -122,6 +122,7 @@
"markdownCopied": "Copied!",
"markdownCopyFailed": "Copy failed",
"downloadMarkdown": "Download as Markdown",
"moreActions": "More actions",
"delete": "Move to trash",
"deleteConfirm": "Move this page to the trash? You can restore it from the pond's trash view."
},

12
pnpm-lock.yaml generated
View File

@ -284,6 +284,9 @@ importers:
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
lucide-react:
specifier: ^1.24.0
version: 1.24.0(react@19.2.7)
prosemirror-model:
specifier: ^1.25.9
version: 1.25.9
@ -4533,6 +4536,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.24.0:
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@ -10345,6 +10353,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.24.0(react@19.2.7):
dependencies:
react: 19.2.7
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5