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
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
371 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|