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());
|
page.on('dialog', (dialog) => void dialog.accept());
|
||||||
await page.goto(`/p/${pond.slug}/${slug}`);
|
await page.goto(`/p/${pond.slug}/${slug}`);
|
||||||
await enterEditMode(page);
|
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 page.goto(`/p/${pond.slug}`);
|
||||||
await expect(page.getByRole('link', { name: title })).toHaveCount(0);
|
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();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
|
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([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download', { timeout: 30000 }),
|
page.waitForEvent('download', { timeout: 30000 }),
|
||||||
page.locator('.editor-page__export button').first().click(),
|
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();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
|
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([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download', { timeout: 30000 }),
|
page.waitForEvent('download', { timeout: 30000 }),
|
||||||
page.locator('.editor-page__export button').nth(2).click(),
|
page.locator('.editor-page__export button').nth(2).click(),
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
"prosemirror-model": "^1.25.9",
|
"prosemirror-model": "^1.25.9",
|
||||||
"prosemirror-schema-list": "^1.5.0",
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
"prosemirror-tables": "^1.8.5",
|
"prosemirror-tables": "^1.8.5",
|
||||||
@ -41,10 +42,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"fflate": "^0.8.2",
|
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
|
|||||||
@ -12,10 +12,11 @@ import { apiGet } from '../lib/api';
|
|||||||
|
|
||||||
export const commentsQueryKey = (pageId: string): [string, string] => ['comments', pageId];
|
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({
|
return useQuery({
|
||||||
queryKey: commentsQueryKey(pageId),
|
queryKey: commentsQueryKey(pageId ?? ''),
|
||||||
queryFn: () => apiGet<PageCommentsView>(`/pages/${pageId}/comments`),
|
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 { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { usePersistentState } from '../lib/use-persistent-state';
|
import { usePersistentState } from '../lib/use-persistent-state';
|
||||||
import { Footer } from './Footer';
|
import { Footer } from './Footer';
|
||||||
|
import { PageActionsSlotContext } from './page-actions';
|
||||||
import { SidebarChromeContext } from './sidebar-chrome';
|
import { SidebarChromeContext } from './sidebar-chrome';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
|
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
|
||||||
@ -18,6 +19,13 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
const collapsed = sidebarCollapsed || forcedHidden;
|
const collapsed = sidebarCollapsed || forcedHidden;
|
||||||
// Clamp on read too — the persisted value may predate a bounds change.
|
// Clamp on read too — the persisted value may predate a bounds change.
|
||||||
const widthRem = clampSidebarWidth(sidebarWidth);
|
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
|
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
|
||||||
// which element has focus.
|
// which element has focus.
|
||||||
@ -34,23 +42,25 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarChromeContext.Provider value={setForcedHidden}>
|
<SidebarChromeContext.Provider value={setForcedHidden}>
|
||||||
<div className="app">
|
<PageActionsSlotContext.Provider value={actionsSlot}>
|
||||||
<TopBar
|
<div className="app">
|
||||||
sidebarCollapsed={collapsed}
|
<TopBar
|
||||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
sidebarCollapsed={collapsed}
|
||||||
/>
|
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
<div
|
/>
|
||||||
className="app-body"
|
<div
|
||||||
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
className="app-body"
|
||||||
>
|
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
||||||
<Sidebar collapsed={collapsed} />
|
>
|
||||||
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
<Sidebar collapsed={collapsed} />
|
||||||
<main className="main">
|
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
||||||
<Outlet />
|
<main className="main">
|
||||||
<Footer />
|
<Outlet />
|
||||||
</main>
|
<Footer />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageActionsSlotContext.Provider>
|
||||||
</SidebarChromeContext.Provider>
|
</SidebarChromeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Menu, Search } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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 { useAuth } from '../auth/auth-context';
|
||||||
import { SearchPalette } from '../search/SearchPalette';
|
import { SearchPalette } from '../search/SearchPalette';
|
||||||
import { NotificationsBell } from '../notifications/NotificationsBell';
|
import { NotificationsBell } from '../notifications/NotificationsBell';
|
||||||
|
import { usePageActionsSlot } from './page-actions';
|
||||||
import { PondSwitcher } from './PondSwitcher';
|
import { PondSwitcher } from './PondSwitcher';
|
||||||
|
|
||||||
/** True when focus is in a field where "/" should type, not open search. */
|
/** 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 navigate = useNavigate();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const { setElement: setPageActionsElement } = usePageActionsSlot();
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@ -54,14 +57,15 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
aria-expanded={!sidebarCollapsed}
|
aria-expanded={!sidebarCollapsed}
|
||||||
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
|
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
|
||||||
>
|
>
|
||||||
{/* Simple hamburger glyph; replaced by an icon set later. */}
|
<Menu aria-hidden />
|
||||||
<span aria-hidden>☰</span>
|
|
||||||
</button>
|
</button>
|
||||||
<Link to="/" className="topbar__brand">
|
<Link to="/" className="topbar__brand">
|
||||||
Dorfteich
|
Dorfteich
|
||||||
</Link>
|
</Link>
|
||||||
{user && <PondSwitcher />}
|
{user && <PondSwitcher />}
|
||||||
<span className="topbar__spacer" />
|
<span className="topbar__spacer" />
|
||||||
|
{/* Pages portal their icon actions here while active (issue #101). */}
|
||||||
|
{user && <div className="topbar__page-actions" ref={setPageActionsElement} />}
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -69,7 +73,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
aria-label={t('search:open')}
|
aria-label={t('search:open')}
|
||||||
>
|
>
|
||||||
<span aria-hidden>🔍</span> {t('search:open')}
|
<Search aria-hidden /> {t('search:open')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
|
{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 type { NotificationListView, NotificationView } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -52,7 +53,7 @@ export function NotificationsBell(): React.JSX.Element {
|
|||||||
aria-label={t('title')}
|
aria-label={t('title')}
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell aria-hidden />
|
||||||
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
|
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{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 { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
@ -19,7 +20,6 @@ import { LabelPicker } from '../labels/LabelPicker';
|
|||||||
import { BacklinksPanel } from '../links/BacklinksPanel';
|
import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||||
import { documentExtensions } from '../editor/document-extensions';
|
import { documentExtensions } from '../editor/document-extensions';
|
||||||
import { DocumentExportMenu } from '../export/DocumentExportMenu';
|
|
||||||
import { PondFontScope } from '../fonts/PondFontScope';
|
import { PondFontScope } from '../fonts/PondFontScope';
|
||||||
import { ImageUpload } from '../editor/image-upload';
|
import { ImageUpload } from '../editor/image-upload';
|
||||||
import { PresenceStrip } from '../editor/PresenceStrip';
|
import { PresenceStrip } from '../editor/PresenceStrip';
|
||||||
@ -27,9 +27,10 @@ import { Toolbar } from '../editor/Toolbar';
|
|||||||
import { useCollabProvider } from '../editor/use-collab-provider';
|
import { useCollabProvider } from '../editor/use-collab-provider';
|
||||||
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
||||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||||
|
import { usePageActionsSlot } from '../layout/page-actions';
|
||||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||||
import { WatchToggle } from '../watches/WatchToggle';
|
import { ApiError, apiGet, apiPatch } from '../lib/api';
|
||||||
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
import { PageActions } from './PageActions';
|
||||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||||
import { PluginBlockContext } from '../editor/plugin-block-context';
|
import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||||
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||||
@ -63,22 +64,25 @@ function PageEditor({
|
|||||||
mode,
|
mode,
|
||||||
pondSlug,
|
pondSlug,
|
||||||
commentPolicy,
|
commentPolicy,
|
||||||
|
showAttachments,
|
||||||
|
showComments,
|
||||||
|
showPageTools,
|
||||||
|
onCloseAttachments,
|
||||||
|
onCloseComments,
|
||||||
}: {
|
}: {
|
||||||
page: ResolvedPage;
|
page: ResolvedPage;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
commentPolicy: 'readers' | 'editors';
|
commentPolicy: 'readers' | 'editors';
|
||||||
|
showAttachments: boolean;
|
||||||
|
showComments: boolean;
|
||||||
|
showPageTools: boolean;
|
||||||
|
onCloseAttachments: () => void;
|
||||||
|
onCloseComments: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
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
|
// Created and destroyed within the same effect (not `useMemo` + a separate
|
||||||
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
||||||
@ -99,9 +103,6 @@ function PageEditor({
|
|||||||
|
|
||||||
const collab = useCollabProvider(ydoc, page.id);
|
const collab = useCollabProvider(ydoc, page.id);
|
||||||
const readOnly = collab.mode === 'ro';
|
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
|
// `readers` = everyone who can see the page; `editors` = the collab token
|
||||||
// explicitly granted rw (while it is still null, stay conservative — the
|
// explicitly granted rw (while it is still null, stay conservative — the
|
||||||
// composer appears once the mode resolves).
|
// composer appears once the mode resolves).
|
||||||
@ -195,39 +196,6 @@ function PageEditor({
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
|
<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 && (
|
{showPageTools && (
|
||||||
<PageToolsPanel plugins={pondPlugins.data} context={pluginBlockScope} />
|
<PageToolsPanel plugins={pondPlugins.data} context={pluginBlockScope} />
|
||||||
)}
|
)}
|
||||||
@ -236,15 +204,11 @@ function PageEditor({
|
|||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onClose={() => setShowAttachments(false)}
|
onClose={onCloseAttachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showComments && (
|
{showComments && (
|
||||||
<CommentsPanel
|
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
|
||||||
pageId={page.id}
|
|
||||||
mayComment={mayComment}
|
|
||||||
onClose={() => setShowComments(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||||
{t(`connection.${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 {
|
export function PageEditorPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
|
const { user } = useAuth();
|
||||||
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
|
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
|
||||||
const [mode, setMode] = useState<Mode>('view');
|
const [mode, setMode] = useState<Mode>('view');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [showLabels, setShowLabels] = 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');
|
useForceSidebarHidden(mode === 'edit');
|
||||||
|
|
||||||
@ -401,6 +307,12 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
if (resolved) setTitle(resolved.title);
|
if (resolved) setTitle(resolved.title);
|
||||||
}, [resolved?.id, 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> {
|
async function saveTitle(): Promise<void> {
|
||||||
if (!resolved || title === resolved.title) return;
|
if (!resolved || title === resolved.title) return;
|
||||||
await apiPatch(`/pages/${resolved.id}`, { title });
|
await apiPatch(`/pages/${resolved.id}`, { title });
|
||||||
@ -423,6 +335,30 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
|
<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">
|
||||||
<div className="editor-page__header">
|
<div className="editor-page__header">
|
||||||
<input
|
<input
|
||||||
@ -434,21 +370,6 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
onBlur={() => void saveTitle()}
|
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>
|
||||||
<div className="editor-page__body">
|
<div className="editor-page__body">
|
||||||
<PageEditor
|
<PageEditor
|
||||||
@ -456,6 +377,11 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
|
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
|
||||||
|
showAttachments={showAttachments}
|
||||||
|
showComments={showComments}
|
||||||
|
showPageTools={showPageTools}
|
||||||
|
onCloseAttachments={() => setShowAttachments(false)}
|
||||||
|
onCloseComments={() => setShowComments(false)}
|
||||||
/>
|
/>
|
||||||
{showLabels && (
|
{showLabels && (
|
||||||
<LabelPicker
|
<LabelPicker
|
||||||
|
|||||||
@ -250,6 +250,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -262,6 +263,95 @@ button {
|
|||||||
color: var(--color-text);
|
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 {
|
.icon-button:hover {
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-subtle);
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
@ -637,12 +727,7 @@ button {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-page__actions {
|
/* The export entries flow inline with the other overflow-menu items. */
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The export buttons flow inline with the other page actions (same gap). */
|
|
||||||
.editor-page__export {
|
.editor-page__export {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
@ -2112,13 +2197,6 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Attachments: page section + pond file manager (issue #61) */
|
/* 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 {
|
.attachments-panel {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 0.375rem);
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import type { WatchStateView, WatchTargetType } from '@dorfteich/shared';
|
import type { WatchStateView, WatchTargetType } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { IconButton } from '../components/IconButton';
|
||||||
import { apiDelete, apiGet, apiPut } from '../lib/api';
|
import { apiDelete, apiGet, apiPut } from '../lib/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch/unwatch button for page and pond headers (issue #93). The state is
|
* Watch/unwatch button for page and pond headers (issue #93). The state is
|
||||||
* per-user (server-side); toggling updates optimistically via refetch.
|
* per-user (server-side); toggling updates optimistically via refetch.
|
||||||
|
* `variant="icon"` renders the compact TopBar form (issue #101).
|
||||||
*/
|
*/
|
||||||
export function WatchToggle({
|
export function WatchToggle({
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
|
variant = 'button',
|
||||||
}: {
|
}: {
|
||||||
targetType: WatchTargetType;
|
targetType: WatchTargetType;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
variant?: 'button' | 'icon';
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('watches');
|
const { t } = useTranslation('watches');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -33,6 +38,20 @@ export function WatchToggle({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const watched = state.data?.watched ?? false;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -122,6 +122,7 @@
|
|||||||
"markdownCopied": "Kopiert!",
|
"markdownCopied": "Kopiert!",
|
||||||
"markdownCopyFailed": "Kopieren fehlgeschlagen",
|
"markdownCopyFailed": "Kopieren fehlgeschlagen",
|
||||||
"downloadMarkdown": "Als Markdown herunterladen",
|
"downloadMarkdown": "Als Markdown herunterladen",
|
||||||
|
"moreActions": "Weitere Aktionen",
|
||||||
"delete": "In den Papierkorb verschieben",
|
"delete": "In den Papierkorb verschieben",
|
||||||
"deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen."
|
"deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -122,6 +122,7 @@
|
|||||||
"markdownCopied": "Copied!",
|
"markdownCopied": "Copied!",
|
||||||
"markdownCopyFailed": "Copy failed",
|
"markdownCopyFailed": "Copy failed",
|
||||||
"downloadMarkdown": "Download as Markdown",
|
"downloadMarkdown": "Download as Markdown",
|
||||||
|
"moreActions": "More actions",
|
||||||
"delete": "Move to trash",
|
"delete": "Move to trash",
|
||||||
"deleteConfirm": "Move this page to the trash? You can restore it from the pond's trash view."
|
"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:
|
i18next-browser-languagedetector:
|
||||||
specifier: ^8.2.1
|
specifier: ^8.2.1
|
||||||
version: 8.2.1
|
version: 8.2.1
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.24.0
|
||||||
|
version: 1.24.0(react@19.2.7)
|
||||||
prosemirror-model:
|
prosemirror-model:
|
||||||
specifier: ^1.25.9
|
specifier: ^1.25.9
|
||||||
version: 1.25.9
|
version: 1.25.9
|
||||||
@ -4533,6 +4536,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@ -10345,6 +10353,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@1.24.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user