#304: custom fonts in the pickers, an admin screen, and the licence page #313

Merged
opus-5 merged 3 commits from issue-304-custom-font-ui into main 2026-08-01 19:25:30 +02:00
21 changed files with 1002 additions and 45 deletions

View File

@ -23,6 +23,7 @@ import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
@ -106,7 +107,15 @@ export class CustomFontsAdminController {
}
/**
* Serving route. Unauthenticated on purpose: a font is referenced from CSS,
* Reading side of the uploaded fonts: the family list every signed-in user
* needs, and the bytes themselves.
*
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
* fonts in their pond's Appearance settings, reads the licence page, and needs
* the `@font-face` rules injected the admin list at `/admin/fonts` carries
* the same data, so gating this one would only force a second, admin-only UI.
*
* The file route is unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content.
*/
@ -117,6 +126,15 @@ export class CustomFontsFileController {
private readonly fonts: CustomFontsService,
) {}
// Explicit access declaration, as every route needs (issue #52's fence
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
// the list says which families exist, which is what the pickers offer.
@AuthenticatedOnly()
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Public()
@Get(':slug/:file')
async serve(

View File

@ -155,6 +155,30 @@ describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
expect(res.body.code).toBe('font_woff2_missing');
});
/**
* Issue #304: an ordinary member picks fonts in their pond's Appearance
* settings and reads the licence page, so the family list cannot be
* Site-Admin-only only the management routes are.
*/
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Leseschrift ${suffix}`)
.field('category', 'monospace')
.field('licence', 'Read me')
.attach('woff2-500', woff2(), 'x.woff2')
.expect(201);
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
const seen = (listed.body as { family: string; weights: number[] }[]).find(
(font) => font.family === `Leseschrift ${suffix}`,
);
expect(seen?.weights).toEqual([500]);
await api().get('/api/v1/fonts/custom').expect(401);
});
it('keeps every management route away from a non-admin', async () => {
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
await api()

View File

@ -6,6 +6,7 @@ import {
ConversionJobView,
ExportFormat,
PondFonts,
customFontEntries,
fontSlug,
PageClassification,
classificationMarking,
@ -335,6 +336,9 @@ export class ExportService {
pondName: page.pond.name,
bodyHtml,
fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string.

View File

@ -0,0 +1,51 @@
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { buildPdfHtml } from './pdf-html';
const CUSTOM = customFontEntries([
{
id: 'f1',
family: 'Corporate Grotesk',
slug: 'corporate-grotesk',
category: 'sans-serif',
licence: 'Bought from Foundry X',
licenceUrl: null,
weights: [400, 700],
createdAt: '2026-08-01T00:00:00.000Z',
},
]);
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
return {
title: 'T',
pondName: 'P',
bodyHtml: '<p>x</p>',
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
};
}
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
it('names an operator-uploaded family in the CSS stack when it is known', () => {
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
expect(html).toContain("--font-body: 'Corporate Grotesk',");
});
/**
* The regression this pins: the `@font-face` rule for a custom family was
* embedded, but `fontStack` not knowing the family produced the bare
* system fallback, so the rule was never referenced and the PDF rendered in
* the system font while everything reported success.
*/
it('would fall back to the system stack without the uploaded families', () => {
const html = buildPdfHtml(base('Corporate Grotesk'));
expect(html).not.toContain("'Corporate Grotesk',");
expect(html).toContain('--font-body: system-ui');
});
it('leaves catalog families working without any uploaded ones', () => {
const html = buildPdfHtml(base('Lora'));
expect(html).toContain("--font-body: 'Lora', Georgia");
});
});

View File

@ -1,4 +1,4 @@
import { PondFonts, fontStack } from '@dorfteich/shared';
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams {
title: string;
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
fontFaceCss: string;
/** The instance's operator-uploaded families (issue #303), so a pond set to
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
* cannot tell a custom family from a typo and yields the bare system
* fallback the `@font-face` rule would then be embedded but never
* referenced, and the PDF would silently render in the system font. */
customFonts?: readonly FontCatalogEntry[];
/** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally their class matches
@ -35,6 +41,7 @@ function escapeHtml(value: string): string {
*/
export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html>
<html lang="en">
<head>
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss}
@page { size: A4; }
:root {
--font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family)};
--font-heading: ${fontStack(fonts.heading.family, extra)};
--font-body: ${fontStack(fonts.body.family, extra)};
--font-mono: ${fontStack(fonts.mono.family, extra)};
}
html { font-size: 11pt; }
body {

View File

@ -88,6 +88,14 @@ for (const scheme of SCHEMES) {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
await page.goto('/fonts');
await page.waitForLoadState('networkidle');
await expectClean(page, `/fonts (${scheme})`);
await context.close();
});
@ -99,6 +107,9 @@ for (const scheme of SCHEMES) {
await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor();
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
await context.close();
});

View File

@ -63,7 +63,11 @@ test('the admin form previews and publishes the privacy policy', async ({ browse
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
await expect(page.getByRole('status')).toBeVisible();
// Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
// Erfolgsmeldung DIESES Formulars.
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
await admin.close();
const anonymous = await browser.newContext({ baseURL: BASE_URL });

View File

@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import { FormError } from '../components/forms';
import { apiPatch } from '../lib/api';
import { useCustomFontEntries } from './use-custom-fonts';
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
@ -29,9 +30,12 @@ export function AppearanceManager({
const [draft, setDraft] = useState<PondFonts>(fonts);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [error, setError] = useState<unknown>(null);
// The operator's own families (issue #304) — offered next to the catalog,
// in their own labelled group, and resolvable by `fontEntry`/`fontStack`.
const custom = useCustomFontEntries();
function chooseFamily(slot: keyof PondFonts, family: string): void {
const weights = fontEntry(family)?.weights ?? [];
const weights = fontEntry(family, custom)?.weights ?? [];
// Keep the current weight if the new family offers it, else its first.
const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400);
setDraft((prev) => ({ ...prev, [slot]: { family, weight } }));
@ -62,7 +66,7 @@ export function AppearanceManager({
<FormError error={error} />
{SLOTS.map((slot) => {
const value = draft[slot];
const weights = fontEntry(value.family)?.weights ?? [value.weight];
const weights = fontEntry(value.family, custom)?.weights ?? [value.weight];
return (
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
@ -72,8 +76,18 @@ export function AppearanceManager({
value={value.family}
onChange={(event) => chooseFamily(slot, event.target.value)}
>
{/* Bundled and uploaded families are told apart by the group
they sit in, not by a badge (issue #304): the grouping is
then part of the control's semantics a screen reader
announces it on entering, and the native mobile select
keeps it. Within each source the category grouping of the
catalog is preserved, so a custom family appears under its
own category exactly like a bundled one. */}
{CATEGORIES.map((category) => (
<optgroup key={category} label={t(`category.${category}`)}>
<optgroup
key={category}
label={t('group.bundled', { category: t(`category.${category}`) })}
>
{FONT_CATALOG.filter((font) => font.category === category).map((font) => (
<option key={font.family} value={font.family}>
{font.family}
@ -81,6 +95,22 @@ export function AppearanceManager({
))}
</optgroup>
))}
{CATEGORIES.filter((category) =>
custom.some((font) => font.category === category),
).map((category) => (
<optgroup
key={`custom-${category}`}
label={t('group.custom', { category: t(`category.${category}`) })}
>
{custom
.filter((font) => font.category === category)
.map((font) => (
<option key={font.family} value={font.family}>
{font.family}
</option>
))}
</optgroup>
))}
</select>
</label>
<label className="appearance__field">
@ -98,7 +128,7 @@ export function AppearanceManager({
</label>
<p
className="appearance__preview"
style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }}
style={{ fontFamily: fontStack(value.family, custom), fontWeight: value.weight }}
>
{t('preview')}
</p>

View File

@ -0,0 +1,40 @@
import { useCustomFonts } from './use-custom-fonts';
/**
* A family name is free text the operator typed. It ends up inside a CSS
* string, so quote and backslash are escaped and everything that could end
* the declaration, the rule or the `<style>` element is dropped. Site Admins
* are trusted with far more than this, but a rule that silently breaks the
* whole stylesheet on an apostrophe would be a bug either way.
*/
function cssFamily(family: string): string {
return family.replace(/[\\'<>{};\r\n]/g, '');
}
/**
* `@font-face` rules for the operator-uploaded families (issue #304).
*
* Catalog families are declared in the generated `public/fonts/catalog.css`,
* which the build writes and `index.html` links. Uploaded ones only exist at
* runtime, so their rules are injected here same shape, same `swap`
* behaviour, bytes from the api's public font route.
*
* Without this the pickers would offer families the browser cannot resolve:
* `fontStack` names them, nothing declares them, and the text renders in the
* system fallback.
*/
export function CustomFontFaces(): React.JSX.Element | null {
const fonts = useCustomFonts();
if (fonts.length === 0) return null;
const css = fonts
.flatMap((font) =>
font.weights.map(
(weight) =>
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
` font-weight: ${weight}; font-display: swap;` +
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
),
)
.join('\n');
return <style data-custom-fonts="">{css}</style>;
}

View File

@ -1,19 +1,28 @@
import { PondSettings, fontStack } from '@dorfteich/shared';
import { FontCatalogEntry, PondSettings, fontStack } from '@dorfteich/shared';
import type { CSSProperties, ReactNode } from 'react';
import { useCustomFontEntries } from './use-custom-fonts';
/** The CSS custom properties a pond's font choice sets on its content root
* (ADR 0016). The content CSS reads these; a family that fails to load falls
* back to the category's system stack (`fontStack`). */
export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties {
* back to the category's system stack (`fontStack`).
*
* `custom` carries the operator-uploaded families (issue #304): `fontStack`
* cannot tell an uploaded family from a deleted one, so without them a pond
* set to its operator's own font would render in the system fallback. */
export function pondFontVariables(
fonts: PondSettings['fonts'],
custom: readonly FontCatalogEntry[] = [],
): CSSProperties {
// Overrides the same custom properties the app-wide CSS already reads
// (tokens.css), so headings, body, and code inside the scope re-resolve to
// the pond's fonts without any per-element rules.
return {
'--font-heading': fontStack(fonts.heading.family),
'--font-heading': fontStack(fonts.heading.family, custom),
'--font-weight-heading': String(fonts.heading.weight),
'--font-body': fontStack(fonts.body.family),
'--font-body': fontStack(fonts.body.family, custom),
'--font-weight-body': String(fonts.body.weight),
'--font-mono': fontStack(fonts.mono.family),
'--font-mono': fontStack(fonts.mono.family, custom),
'--font-weight-mono': String(fonts.mono.weight),
} as CSSProperties;
}
@ -32,8 +41,9 @@ export function PondFontScope({
fonts: PondSettings['fonts'];
children: ReactNode;
}): React.JSX.Element {
const custom = useCustomFontEntries();
return (
<div className="pond-font-scope" style={pondFontVariables(fonts)}>
<div className="pond-font-scope" style={pondFontVariables(fonts, custom)}>
{children}
</div>
);

View File

@ -0,0 +1,39 @@
import { CustomFontView, FontCatalogEntry, customFontEntries } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
export const CUSTOM_FONTS_KEY = ['fonts', 'custom'];
/**
* The instance's operator-uploaded font families (issues #303/#304).
*
* Every font-aware surface needs them: the pickers offer them, the licence
* page attributes them, `fontStack` needs them to NAME the family instead of
* falling through to the system stack, and `CustomFontFaces` turns them into
* `@font-face` rules. One query key, so they are fetched once per session and
* shared.
*
* Only fetched while signed in the endpoint requires a session, and asking
* on the login screen would produce a 401 for nothing.
*/
export function useCustomFonts(): CustomFontView[] {
const { user } = useAuth();
const query = useQuery({
queryKey: CUSTOM_FONTS_KEY,
queryFn: () => apiGet<CustomFontView[]>('/fonts/custom'),
enabled: Boolean(user),
// Uploading a font is a rare Site-Admin act; the manager invalidates the
// key itself, so a long life here costs nothing.
staleTime: 5 * 60 * 1000,
});
return query.data ?? [];
}
/** The same families in the shape `fontEntry`/`fontStack` accept. */
export function useCustomFontEntries(): FontCatalogEntry[] {
const fonts = useCustomFonts();
return useMemo(() => customFontEntries(fonts), [fonts]);
}

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { CustomFontFaces } from '../fonts/CustomFontFaces';
import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer';
import { PageActionsSlotContext } from './page-actions';
@ -55,6 +56,9 @@ export function AppLayout(): React.JSX.Element {
<SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app">
{/* Declares the operator-uploaded families (#304) for every screen
below pickers, previews, editor and read view alike. */}
<CustomFontFaces />
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
<a className="skip-link" href="#main">
{t('layout.skipToContent')}

View File

@ -90,6 +90,27 @@ export async function apiUploadFile<T>(
return response.json() as Promise<T>;
}
/** Multipart upload of a whole form (issue #304's font upload: several files
* plus metadata in one request). `apiUploadFile` above covers the single-file
* case; this one takes the `FormData` the caller assembled. */
export async function apiPostForm<T>(path: string, form: FormData): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz');
}

View File

@ -9,6 +9,7 @@ import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api';
import { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager';
@ -180,6 +181,7 @@ export function AdminSettingsPage(): React.JSX.Element {
<LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} />
<CustomFontManager />
<PluginManager />
<QuotaManager />
<UserManager />
@ -457,7 +459,10 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
}
return (
<section className="settings-section">
// Named class so the e2e can scope its success-message assertion to this
// form: /admin has more than one live region since #304 (upload progress),
// and a page-wide getByRole('status') became ambiguous.
<section className="settings-section legal-settings">
<h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>

View File

@ -0,0 +1,425 @@
import {
CustomFontView,
FONT_CATEGORIES,
FONT_WEIGHTS,
FontCategory,
MAX_FONT_FILE_BYTES,
fontStack,
customFontEntries,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms';
import { CUSTOM_FONTS_KEY } from '../fonts/use-custom-fonts';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
/** One weight the operator is about to upload. `woff` is optional the api
* rejects a WOFF without its WOFF2 because the PDF path reads WOFF2 only. */
interface WeightDraft {
weight: number;
woff2: File | null;
woff: File | null;
}
const MAX_MIB = Math.round(MAX_FONT_FILE_BYTES / (1024 * 1024));
/** Preselects the weight the operator most likely wants next: Regular for the
* first row, then upwards from the heaviest one already chosen (Regular
* Bold is the usual second file), and only after that whatever is left. The
* selection stays free this is a starting point, not a rule. */
function emptyDraft(used: number[]): WeightDraft {
const free = FONT_WEIGHTS.filter((weight) => !used.includes(weight));
const heaviest = Math.max(0, ...used);
const next = free.includes(400) ? 400 : (free.find((w) => w > heaviest) ?? free[0] ?? 400);
return { weight: next, woff2: null, woff: null };
}
function appendWeight(form: FormData, draft: WeightDraft): void {
if (draft.woff2) form.append(`woff2-${draft.weight}`, draft.woff2);
if (draft.woff) form.append(`woff-${draft.weight}`, draft.woff);
}
/**
* Site-Admin management of operator-uploaded font families (issue #304,
* backend #303, ADR 0016 §#303).
*
* The whole cycle lives here: upload a family with its licence and one file
* per weight, see what is installed, add a weight later, and delete a family
* after being told how many ponds still use it.
*
* Deleting is never blocked (the api's decision): an unknown family falls back
* to the system stack, so the affected ponds change appearance rather than
* break. The confirmation therefore names the consequence in text instead of
* refusing.
*/
export function CustomFontManager(): React.JSX.Element {
const { t } = useTranslation('font');
const queryClient = useQueryClient();
const fonts = useQuery({
queryKey: ['admin', 'fonts'],
queryFn: () => apiGet<CustomFontView[]>('/admin/fonts'),
});
const installed = fonts.data ?? [];
const entries = customFontEntries(installed);
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'fonts'] });
// The pickers, the licence page and the injected `@font-face` rules read
// the non-admin list — without this they keep the pre-upload state.
await queryClient.invalidateQueries({ queryKey: CUSTOM_FONTS_KEY });
};
return (
<section className="settings-section custom-fonts">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
<UploadForm onUploaded={invalidate} />
<h3>{t('admin.installed')}</h3>
{installed.length === 0 ? (
<p>{t('admin.empty')}</p>
) : (
<ul className="custom-fonts__list">
{installed.map((font) => (
<FontRow key={font.id} font={font} entries={entries} onChanged={invalidate} />
))}
</ul>
)}
</section>
);
}
function UploadForm({ onUploaded }: { onUploaded: () => Promise<void> }): React.JSX.Element {
const { t } = useTranslation('font');
const [family, setFamily] = useState('');
const [category, setCategory] = useState<FontCategory>('sans-serif');
const [licence, setLicence] = useState('');
const [licenceUrl, setLicenceUrl] = useState('');
const [weights, setWeights] = useState<WeightDraft[]>([emptyDraft([])]);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const form = new FormData();
form.append('family', family.trim());
form.append('category', category);
form.append('licence', licence.trim());
if (licenceUrl.trim()) form.append('licenceUrl', licenceUrl.trim());
for (const draft of weights) appendWeight(form, draft);
return apiPostForm<CustomFontView>('/admin/fonts', form);
},
onSuccess: async () => {
setFamily('');
setLicence('');
setLicenceUrl('');
setWeights([emptyDraft([])]);
setDone(true);
await onUploaded();
},
});
const ready = family.trim() !== '' && licence.trim() !== '' && weights.some((w) => w.woff2);
return (
<form
className="custom-fonts__upload"
noValidate
onSubmit={(event) => {
event.preventDefault();
setDone(false);
upload.mutate();
}}
>
<h3>{t('admin.upload.title')}</h3>
<FormError error={upload.error} />
{/* role="status", so completion reaches assistive technology instead of
being a colour change in the corner. */}
<FormSuccess message={done ? t('admin.upload.done') : null} />
<Field label={t('admin.upload.family')} hint={t('admin.upload.familyHint')}>
<input
type="text"
value={family}
maxLength={80}
autoComplete="off"
onChange={(event) => setFamily(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.category')}>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FontCategory)}
>
{FONT_CATEGORIES.map((value) => (
<option key={value} value={value}>
{t(`category.${value}`)}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.licence')} hint={t('admin.upload.licenceHint')}>
<input
type="text"
value={licence}
maxLength={200}
onChange={(event) => setLicence(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.licenceUrl')}>
<input
type="url"
value={licenceUrl}
maxLength={500}
placeholder="https://"
onChange={(event) => setLicenceUrl(event.target.value)}
/>
</Field>
<fieldset className="custom-fonts__weights">
<legend>{t('admin.upload.weights')}</legend>
{/* The accepted formats are stated up front, not only when a rejected
upload comes back an operator should not have to fail to learn
the requirement. */}
<p>{t('admin.upload.formatHint', { max: MAX_MIB })}</p>
{weights.map((draft, index) => (
<div className="custom-fonts__weight" key={index}>
<Field label={t('admin.upload.weight')}>
<select
value={draft.weight}
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, weight: Number(event.target.value) } : entry,
),
)
}
>
{FONT_WEIGHTS.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: draft.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff2: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: draft.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
{weights.length > 1 && (
<button
type="button"
className="linklike"
onClick={() => setWeights((prev) => prev.filter((_, i) => i !== index))}
>
{t('admin.upload.removeWeight', { weight: draft.weight })}
</button>
)}
</div>
))}
<button
type="button"
className="button button--outline"
onClick={() =>
setWeights((prev) => [...prev, emptyDraft(prev.map((entry) => entry.weight))])
}
>
{t('admin.upload.addWeight')}
</button>
</fieldset>
<button type="submit" className="button" disabled={!ready || upload.isPending}>
{t('admin.upload.submit')}
</button>
{/* Announced, not just spun: an 8 MiB face over a slow link takes long
enough that silence reads as failure. */}
<p role="status" className="custom-fonts__status">
{upload.isPending ? t('admin.upload.uploading') : ''}
</p>
</form>
);
}
function FontRow({
font,
entries,
onChanged,
}: {
font: CustomFontView;
entries: ReturnType<typeof customFontEntries>;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('font');
const [confirming, setConfirming] = useState(false);
const [adding, setAdding] = useState<WeightDraft | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const deleteRef = useRef<HTMLButtonElement | null>(null);
const usage = useQuery({
queryKey: ['admin', 'fonts', font.id, 'usage'],
queryFn: () => apiGet<{ pondsAffected: number }>(`/admin/fonts/${font.id}/usage`),
enabled: confirming,
});
// The confirmation appears below the button that opened it; without moving
// focus a keyboard user would have to hunt for it, and a screen reader would
// never learn it exists.
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming, usage.data]);
const addWeight = useMutation({
mutationFn: async (draft: WeightDraft) => {
const form = new FormData();
appendWeight(form, draft);
return apiPostForm<CustomFontView>(`/admin/fonts/${font.id}/weights`, form);
},
onSuccess: async () => {
setAdding(null);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete(`/admin/fonts/${font.id}`),
onSuccess: async () => {
setConfirming(false);
await onChanged();
},
});
return (
<li className="custom-fonts__item" data-font-slug={font.slug}>
<p className="custom-fonts__name" style={{ fontFamily: fontStack(font.family, entries) }}>
{font.family}
</p>
<p className="custom-fonts__meta">
{t(`category.${font.category}`)} · {t('catalog.weights')}: {font.weights.join(', ')} ·{' '}
{font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
)}
</p>
<FormError error={remove.error ?? addWeight.error} />
<div className="custom-fonts__actions">
<button
type="button"
className="linklike"
onClick={() => setAdding((prev) => (prev ? null : emptyDraft(font.weights)))}
aria-expanded={adding !== null}
>
{t('admin.addWeight.toggle')}
</button>
<button
type="button"
className="linklike"
ref={deleteRef}
onClick={() => setConfirming(true)}
aria-expanded={confirming}
>
{t('admin.delete.start', { family: font.family })}
</button>
</div>
{adding && (
<form
className="custom-fonts__add-weight"
noValidate
onSubmit={(event) => {
event.preventDefault();
addWeight.mutate(adding);
}}
>
<Field label={t('admin.upload.weight')}>
<select
value={adding.weight}
onChange={(event) => setAdding({ ...adding, weight: Number(event.target.value) })}
>
{FONT_WEIGHTS.filter((weight) => !font.weights.includes(weight)).map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: adding.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) => setAdding({ ...adding, woff2: event.target.files?.[0] ?? null })}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: adding.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) => setAdding({ ...adding, woff: event.target.files?.[0] ?? null })}
/>
</Field>
<button type="submit" className="button" disabled={!adding.woff2 || addWeight.isPending}>
{t('admin.addWeight.submit')}
</button>
<p role="status">{addWeight.isPending ? t('admin.upload.uploading') : ''}</p>
</form>
)}
{confirming && (
<div className="custom-fonts__confirm">
{/* The count comes from the api; until it arrives the consequence is
still stated, so the text never reads as "nothing will happen". */}
<p>
{usage.data
? t('admin.delete.usage', { count: usage.data.pondsAffected })
: t('admin.delete.usageLoading')}
</p>
<p>{t('admin.delete.consequence')}</p>
<button
type="button"
className="button button--danger"
ref={confirmRef}
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('admin.delete.confirm')}
</button>
<button
type="button"
className="button button--outline"
onClick={() => {
setConfirming(false);
deleteRef.current?.focus();
}}
>
{t('admin.delete.cancel')}
</button>
</div>
)}
</li>
);
}

View File

@ -1,19 +1,29 @@
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
import { CustomFontView, FONT_CATALOG, FontCatalogEntry, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next';
import { useCustomFontEntries, useCustomFonts } from '../fonts/use-custom-fonts';
import { useDocumentTitle } from '../lib/use-document-title';
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*/
export function FontCatalogPage(): React.JSX.Element {
/** One licence table. Both sources carry the same four columns; only where the
* licence text comes from differs a catalog entry names a licence id we
* ship, an uploaded family whatever the operator typed. */
function LicenceTable({
label,
rows,
}: {
label: string;
rows: {
key: string;
family: string;
category: string;
weights: number[];
licence: React.ReactNode;
stack: string;
}[];
}): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<div className="table-scroll" tabIndex={0} role="region" aria-label={label}>
<table className="font-catalog__table">
<thead>
<tr>
@ -24,16 +34,12 @@ export function FontCatalogPage(): React.JSX.Element {
</tr>
</thead>
<tbody>
{FONT_CATALOG.map((font) => (
<tr key={font.family}>
<td style={{ fontFamily: fontStack(font.family) }}>{font.family}</td>
<td>{t(`category.${font.category}`)}</td>
<td>{font.weights.join(', ')}</td>
<td>
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
</td>
{rows.map((row) => (
<tr key={row.key}>
<td style={{ fontFamily: row.stack }}>{row.family}</td>
<td>{row.category}</td>
<td>{row.weights.join(', ')}</td>
<td>{row.licence}</td>
</tr>
))}
</tbody>
@ -41,3 +47,72 @@ export function FontCatalogPage(): React.JSX.Element {
</div>
);
}
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*
* Since issue #304 the operator's own uploaded families are listed too, with
* the licence label and link recorded at upload. That is what makes an
* attribution obligation satisfiable: many commercial font licences require
* naming the foundry or the licence, and an operator who cannot point at such
* a page cannot comply.
*/
export function FontCatalogPage(): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
const customFonts: CustomFontView[] = useCustomFonts();
const customEntries: FontCatalogEntry[] = useCustomFontEntries();
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<h2>{t('catalog.bundledHeading')}</h2>
<p>{t('catalog.bundledIntro')}</p>
<LicenceTable
label={t('catalog.bundledHeading')}
rows={FONT_CATALOG.map((font) => ({
key: font.family,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family),
licence: (
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
),
}))}
/>
<h2>{t('catalog.customHeading')}</h2>
<p>{t('catalog.customIntro')}</p>
{customFonts.length === 0 ? (
<p>{t('catalog.customEmpty')}</p>
) : (
<LicenceTable
label={t('catalog.customHeading')}
rows={customFonts.map((font) => ({
key: font.id,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family, customEntries),
// A licence URL is optional — without one the label stands alone
// rather than becoming a link to nowhere.
licence: font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
),
}))}
/>
)}
</div>
);
}

View File

@ -4109,3 +4109,72 @@ ul[data-type='task_list'] li p:last-of-type {
border-radius: 8px;
color: var(--color-text-muted);
}
/* Site-Admin font management (issue #304). The layout stays a plain column so
the section reflows at 320px without its own rules (#301). */
.custom-fonts__list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.custom-fonts__item {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
}
.custom-fonts__name {
font-size: 1.25rem;
margin: 0;
}
.custom-fonts__meta {
color: var(--color-text-muted);
margin: var(--space-1) 0 var(--space-2);
}
.custom-fonts__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.custom-fonts__weights {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
margin: var(--space-3) 0;
min-width: 0;
}
.custom-fonts__weight {
border-top: 1px solid var(--color-border);
padding-top: var(--space-2);
margin-top: var(--space-2);
}
.custom-fonts__weight:first-of-type {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.custom-fonts__confirm,
.custom-fonts__add-weight {
border-top: 1px solid var(--color-border);
margin-top: var(--space-3);
padding-top: var(--space-3);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
/* A file input is as wide as its filename; without this it pushes the page
at 320px (issue #301's lesson, applied ahead of the fact). */
.custom-fonts input[type='file'] {
max-width: 100%;
}

View File

@ -114,5 +114,18 @@
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.",
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin)."
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin).",
"font_family_reserved": "Dieser Schriftname gehört zum mitgelieferten Katalog und kann nicht überschrieben werden.",
"font_family_exists": "Eine eigene Schrift mit diesem Namen existiert bereits.",
"font_family_unusable": "Aus diesem Schriftnamen lässt sich kein Adressname bilden — bitte lateinische Buchstaben oder Ziffern verwenden.",
"font_file_empty": "Die Schriftdatei ist leer.",
"font_file_too_large": "Die Schriftdatei ist zu groß.",
"font_file_not_a_font": "Diese Datei ist keine WOFF2-/WOFF-Schriftdatei.",
"font_woff2_missing": "Zu jedem Schnitt gehört eine WOFF2-Datei; eine WOFF allein genügt nicht.",
"font_no_weights": "Es wurde keine Schriftdatei ausgewählt.",
"font_too_many_weights": "Diese Schrift hat bereits die höchstmögliche Zahl an Schnitten.",
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
"font_weight_invalid": "Dieser Schnitt ist nicht zulässig.",
"font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.",
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld."
}

View File

@ -12,19 +12,66 @@
"save": "Schriften speichern",
"saved": "Gespeichert",
"catalogLink": "Schrift-Lizenzen",
"group": {
"bundled": "Mitgelieferte Schriften · {{category}}",
"custom": "Eigene Schriften · {{category}}"
},
"catalog": {
"title": "Schriftkatalog",
"intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO). Alle Schriften sind frei unter den angegebenen Lizenzen.",
"intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO).",
"family": "Schrift",
"category": "Stil",
"weights": "Schnitte",
"license": "Lizenz"
"license": "Lizenz",
"bundledHeading": "Mitgelieferte Schriften",
"bundledIntro": "Diese Schriften sind frei unter den angegebenen Lizenzen.",
"customHeading": "Eigene Schriften",
"customIntro": "Diese Schriften hat der Betreiber dieser Instanz hochgeladen; die Lizenzangabe stammt von ihm.",
"customEmpty": "Es wurden keine eigenen Schriften hochgeladen."
},
"category": {
"sans-serif": "Serifenlos",
"serif": "Serif",
"monospace": "Dicktengleich"
},
"admin": {
"title": "Eigene Schriften",
"intro": "Lade eigene, lizenzierte Schriften hoch. Sie stehen zusätzlich zu den mitgelieferten in allen Teichen zur Auswahl und erscheinen mit ihrer Lizenz auf der Seite „Schrift-Lizenzen“. Die Dateien werden gespeichert, aber nicht geöffnet oder ausgewertet.",
"installed": "Hochgeladene Schriften",
"empty": "Es wurden noch keine eigenen Schriften hochgeladen.",
"upload": {
"title": "Schrift hinzufügen",
"family": "Schriftname",
"familyHint": "Genau der Name, unter dem die Schrift ausgewählt werden soll. Ein Name aus dem mitgelieferten Katalog wird abgelehnt.",
"category": "Stil",
"licence": "Lizenz",
"licenceHint": "Freitext, z. B. „Desktop-Lizenz Foundry X, Rechnung 4711“. Erscheint auf der Lizenzseite.",
"licenceUrl": "Link zur Lizenz (optional)",
"weights": "Schnitte",
"formatHint": "Pro Schnitt eine WOFF2-Datei (Pflicht), optional zusätzlich WOFF. Andere Formate werden abgelehnt; höchstens {{max}} MiB je Datei.",
"weight": "Schnitt",
"woff2": "WOFF2-Datei für Schnitt {{weight}}",
"woff": "WOFF-Datei für Schnitt {{weight}} (optional)",
"removeWeight": "Schnitt {{weight}} wieder entfernen",
"addWeight": "Weiteren Schnitt hinzufügen",
"submit": "Schrift hochladen",
"uploading": "Die Schrift wird hochgeladen …",
"done": "Die Schrift wurde hochgeladen und steht jetzt zur Auswahl."
},
"addWeight": {
"toggle": "Schnitt ergänzen",
"submit": "Schnitt hochladen"
},
"delete": {
"start": "Schrift „{{family}}“ löschen",
"usage_one": "Ein Teich benutzt diese Schrift derzeit.",
"usage_other": "{{count}} Teiche benutzen diese Schrift derzeit.",
"usageLoading": "Es wird geprüft, wie viele Teiche diese Schrift benutzen …",
"consequence": "Nach dem Löschen fallen diese Teiche auf die Standard-Schrift zurück. Ihre Einstellung bleibt gespeichert: Wird dieselbe Schrift erneut hochgeladen, sehen sie wieder aus wie vorher.",
"confirm": "Endgültig löschen",
"cancel": "Abbrechen"
}
},
"pondTheme": {
"legend": "Akzentfarbe des Teichs",
"inherit": "Eigene Einstellung der Betrachtenden (Standard)",

View File

@ -114,5 +114,18 @@
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.",
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin)."
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin).",
"font_family_reserved": "This font name belongs to the bundled catalog and cannot be overridden.",
"font_family_exists": "A custom font with this name already exists.",
"font_family_unusable": "No address name can be derived from this font name — please use Latin letters or digits.",
"font_file_empty": "The font file is empty.",
"font_file_too_large": "The font file is too large.",
"font_file_not_a_font": "This file is not a WOFF2/WOFF font file.",
"font_woff2_missing": "Every weight needs a WOFF2 file; a WOFF alone is not enough.",
"font_no_weights": "No font file was selected.",
"font_too_many_weights": "This font already has the maximum number of weights.",
"font_weight_exists": "This weight already exists for this font.",
"font_weight_invalid": "This weight is not allowed.",
"font_one_weight_expected": "Only one weight can be added at a time.",
"font_unexpected_field": "The request contains an unexpected field."
}

View File

@ -12,19 +12,66 @@
"save": "Save fonts",
"saved": "Saved",
"catalogLink": "Font licenses",
"group": {
"bundled": "Bundled fonts · {{category}}",
"custom": "Custom fonts · {{category}}"
},
"catalog": {
"title": "Font catalog",
"intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR). Fonts are free/libre under the licenses shown.",
"intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR).",
"family": "Font",
"category": "Style",
"weights": "Weights",
"license": "License"
"license": "License",
"bundledHeading": "Bundled fonts",
"bundledIntro": "These fonts are free/libre under the licenses shown.",
"customHeading": "Custom fonts",
"customIntro": "These fonts were uploaded by this instance's operator; the licence details are theirs.",
"customEmpty": "No custom fonts have been uploaded."
},
"category": {
"sans-serif": "Sans-serif",
"serif": "Serif",
"monospace": "Monospace"
},
"admin": {
"title": "Custom fonts",
"intro": "Upload your own licensed font families. They become selectable in every pond alongside the bundled ones and are listed with their licence on the “Font licenses” page. The files are stored, never opened or parsed.",
"installed": "Uploaded fonts",
"empty": "No custom fonts have been uploaded yet.",
"upload": {
"title": "Add a font",
"family": "Font name",
"familyHint": "Exactly the name the font should be selectable under. A name from the bundled catalog is rejected.",
"category": "Style",
"licence": "Licence",
"licenceHint": "Free text, e.g. “Desktop licence, Foundry X, invoice 4711”. Shown on the licence page.",
"licenceUrl": "Link to the licence (optional)",
"weights": "Weights",
"formatHint": "One WOFF2 file per weight (required), optionally a WOFF as well. Other formats are rejected; at most {{max}} MiB per file.",
"weight": "Weight",
"woff2": "WOFF2 file for weight {{weight}}",
"woff": "WOFF file for weight {{weight}} (optional)",
"removeWeight": "Remove weight {{weight}} again",
"addWeight": "Add another weight",
"submit": "Upload font",
"uploading": "Uploading the font …",
"done": "The font was uploaded and can now be selected."
},
"addWeight": {
"toggle": "Add a weight",
"submit": "Upload weight"
},
"delete": {
"start": "Delete font “{{family}}”",
"usage_one": "One pond currently uses this font.",
"usage_other": "{{count}} ponds currently use this font.",
"usageLoading": "Checking how many ponds use this font …",
"consequence": "After deletion those ponds fall back to the default look. Their setting is kept: uploading the same font again restores their appearance.",
"confirm": "Delete permanently",
"cancel": "Cancel"
}
},
"pondTheme": {
"legend": "Pond accent color",
"inherit": "Each viewer's own setting (default)",