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
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:
parent
49e4377764
commit
65f30a5231
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
26
apps/web/src/components/IconButton.tsx
Normal file
26
apps/web/src/components/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)} />}
|
||||
|
||||
21
apps/web/src/layout/page-actions.tsx
Normal file
21
apps/web/src/layout/page-actions.tsx
Normal 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);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
213
apps/web/src/pages/PageActions.tsx
Normal file
213
apps/web/src/pages/PageActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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."
|
||||
},
|
||||
|
||||
@ -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
12
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user