dorfteich/apps/web/src/pages/PageEditorPage.tsx
Claude Opus 4.8 f500198c5d
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Auth e2e pack (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add per-pond fonts: catalog, build, application, and admin UI (#66)
Self-hosted Google Fonts with per-pond selection (ADR 0016), the GDPR
"zero external requests" posture (security.md, CSP `font-src 'self'`).

- Catalog: a curated 15-family OFL/Apache list in shared (family, weights,
  category, license, google-webfonts-helper id). `deploy/fonts/build-fonts.mjs`
  validates every entry has license info (fails the build otherwise),
  downloads the WOFF2 weights into apps/web/public/fonts/ (gitignored), and
  generates the @font-face stylesheet — run at image build time from the web
  Dockerfile (with retries), never from a visitor's browser.
- Application: PondFontScope sets --font-heading/body/mono (+ weights) from
  pond.settings.fonts on the editor + read view; the existing global CSS
  already reads those custom properties, so headings/body/code re-resolve to
  the pond's fonts. A pond with no settings arrives with the defaulted values
  (Roboto 400 / Roboto 200 / Fira Code), so the vision defaults always render.
- Admin UI: pond-settings 'Appearance' section — three slots (family + weight)
  with a live preview, Pond-Admin-gated (fonts added to updatePondInputSchema
  and merged in PondsService.update); a font catalog attribution page (/fonts)
  listing families and licenses. New `font` i18n namespace (de+en).
- CSP: strict Content-Security-Policy in nginx.conf (default-src 'self';
  font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; …) —
  the app's scripts are all external files, inline styles cover CSS variables.
- Tests: shared catalog-integrity unit test (the invariant the build enforces);
  e2e fonts pack — no request leaves the origin when rendering a pond (the GDPR
  network assertion), a font choice applies to a page and persists, and a pond
  without settings renders the defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 11:16:29 +02:00

371 lines
14 KiB
TypeScript

import { DEFAULT_FONTS } from '@dorfteich/shared';
import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared';
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 { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { AttachmentsPanel } from '../files/AttachmentsPanel';
import { HistoryPanel } from '../editor/HistoryPanel';
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';
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 { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
import { recallPage, rememberPage } from '../offline/page-cache';
// A pond loaded offline (no settings) still renders the vision defaults.
const DEFAULT_POND_FONTS = {
heading: DEFAULT_FONTS.heading,
body: DEFAULT_FONTS.body,
mono: DEFAULT_FONTS.mono,
};
type Mode = 'view' | 'edit';
/** The minimal page identity the editor needs — available from the API online
* or from the offline page cache after a reload without a connection (#38). */
interface ResolvedPage {
id: string;
pondId: string;
slug: string;
title: string;
}
function PageEditor({
page,
mode,
pondSlug,
}: {
page: ResolvedPage;
mode: Mode;
pondSlug: string;
}): React.JSX.Element {
const { t } = useTranslation('editor');
const { user } = useAuth();
const navigate = useNavigate();
const [showAttachments, setShowAttachments] = useState(false);
// Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
// otherwise destroy the memoized `Y.Doc` on the simulated unmount without
// ever creating a fresh one, silently breaking Yjs-internal machinery
// (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate`
// still happen to keep working — a bug that only shows up in dev.
//
// The document starts empty: the collab provider (below) loads the page
// state from the server (#35/#36), so there is no REST seed to merge — that
// would create a second doc lineage and duplicate the content.
const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
useEffect(() => {
const doc = new Y.Doc();
setYdoc(doc);
return () => doc.destroy();
}, [page.id]);
const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro';
// A revoked page can no longer be edited (issue #39); the content stays
// visible for export via the dialog below.
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
async function discardLocalAndLeave(): Promise<void> {
await collab.discardLocal();
navigate(`/p/${pondSlug}`);
}
const editor = useEditor(
{
// `documentExtensions` alone is a valid (uncollaborated) schema, so the
// editor never gets built without its 'doc'/'paragraph'/'text' nodes
// while `ydoc` is still being created (see the effect above). The
// collaboration-caret extension is added once the provider exists, so
// remote carets and presence share the same awareness (#37).
extensions: ydoc
? [
...documentExtensions,
ImageUpload.configure({ pondId: page.pondId }),
Collaboration.configure({ document: ydoc, field: 'default' }),
...(collab.provider && user
? [collaborationCaretFor(collab.provider, user, readOnly)]
: []),
]
: documentExtensions,
editable: canEdit,
immediatelyRender: false,
},
[ydoc, collab.provider, readOnly, user?.id],
);
useLayoutEffect(() => {
editor?.setEditable(canEdit);
}, [editor, canEdit]);
// Pond pages power wikilink title resolution + the `[[` autocomplete (#46).
const pondPages = useQuery({
queryKey: ['pages', page.pondId],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${page.pondId}/pages`),
});
const wikilinks = useMemo(() => {
const targets = (pondPages.data ?? []).map((p) => ({ slug: p.slug, title: p.title }));
return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit };
}, [pondPages.data, pondSlug, canEdit]);
if (!editor || !ydoc) return <></>;
return (
<WikilinkContext.Provider value={wikilinks}>
<div className="editor-shell">
{canEdit && <Toolbar editor={editor} />}
<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>
</div>
{showAttachments && (
<AttachmentsPanel
pageId={page.id}
editor={editor}
canEdit={canEdit}
onClose={() => setShowAttachments(false)}
/>
)}
<div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)}
</div>
<PresenceStrip provider={collab.provider} />
{collab.localOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('offline.localOnly')}
</div>
)}
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}
</div>
)}
{collab.tooLarge && (
<div className="editor-banner editor-banner--error" role="alert">
{t('tooLarge.notice')}
</div>
)}
{collab.accessRevoked && (
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
)}
<EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />}
</div>
</WikilinkContext.Provider>
);
}
/** 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 { 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);
useForceSidebarHidden(mode === 'edit');
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const page = useQuery({
queryKey: ['page', pond.data?.id, pageSlug],
queryFn: () => apiGet<PageStateView>(`/ponds/${pond.data!.id}/pages/${pageSlug}`),
enabled: Boolean(pond.data),
});
// Remember this page's metadata while online so it can be opened offline (#38).
useEffect(() => {
if (pond.data && page.data) {
rememberPage({
pondSlug,
pondId: pond.data.id,
pageSlug,
pageId: page.data.id,
title: page.data.title,
});
}
}, [pondSlug, pageSlug, pond.data?.id, page.data?.id, page.data?.title]);
// Prefer the live API result; when offline and it is unavailable, fall back to
// the locally cached metadata so the editor still mounts and shows the
// IndexedDB copy of a previously-visited page (#38). Errors while online
// (e.g. a trashed page) still surface below.
const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null;
const resolved: ResolvedPage | null = page.data
? { id: page.data.id, pondId: page.data.pondId, slug: page.data.slug, title: page.data.title }
: offlineCached
? {
id: offlineCached.pageId,
pondId: offlineCached.pondId,
slug: offlineCached.pageSlug,
title: offlineCached.title,
}
: null;
useEffect(() => {
if (resolved) setTitle(resolved.title);
}, [resolved?.id, resolved?.title]);
async function saveTitle(): Promise<void> {
if (!resolved || title === resolved.title) return;
await apiPatch(`/pages/${resolved.id}`, { title });
}
if ((pond.error || page.error) && !resolved) {
// Editors get a distinguishable hint (and a way out) instead of a dead
// end when the page they followed a link to is in the trash (#31).
const trashed = page.error instanceof ApiError && page.error.body.code === 'page_trashed';
return (
<>
<FormError error={pond.error ?? page.error} />
{trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>}
</>
);
}
if (!resolved) {
return <></>;
}
return (
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
<div className="editor-page">
<div className="editor-page__header">
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
<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 page={resolved} mode={mode} pondSlug={pondSlug} />
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && (
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48). */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
</div>
</PondFontScope>
);
}