dorfteich/apps/web/src/lib/api.ts
Claude Opus 5 f8c241b11a
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 6m26s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#304: custom fonts in the pickers, an admin screen, and the licence page
The backend from #303 could store an operator's font but nothing could
choose one: no list endpoint outside the Site-Admin routes, no @font-face
rules for a family that only exists at runtime, and no management UI.

Found while wiring it up — a real defect in #303, invisible to its tests:
`fontStack` cannot tell an uploaded family from a deleted one, so the PDF
exporter embedded the face and then never named it. Every export of a pond
using an operator font rendered in the system font while the job reported
success. Both `fontStack` call sites now take the uploaded families
(`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the
regression from both sides. Verified against a real Gotenberg: with the
families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold —
that was the whole bug, in one diff of two PDFs.

- `GET /fonts/custom` is readable by any signed-in user, not Site Admins
  only: the pickers, the licence page and the injected `@font-face` rules
  all need it, and gating it would have forced a second, admin-only UI.
- Bundled and uploaded families are told apart by their `<optgroup>`, not
  by a badge — the grouping is then part of the control's semantics, so a
  screen reader announces it and the native mobile select keeps it. Within
  each source the catalog's category grouping is preserved.
- The delete confirmation names how many ponds use the family and what
  happens to them; focus moves to it and back on cancel. Deletion stays
  unblocked (the api's decision, #303) — the ponds degrade, they do not
  break.
- The licence page grew a second table. That is what makes an attribution
  obligation satisfiable: a commercial licence that requires naming the
  foundry needs a page to name it on.

Verified in the browser end to end (upload two weights → listed and
rendered in its own font → chosen in a pond → page renders in it → deleted
→ pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11
locally in both schemes, lint/typecheck/i18n:check green.
2026-08-01 18:32:46 +02:00

136 lines
4.8 KiB
TypeScript

import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared';
/**
* Typed fetch helper for the REST api. Non-2xx responses reject with an
* ApiError carrying the uniform body — callers translate `code` via the
* errors namespace and map `details` onto form fields.
*/
export class ApiError extends Error {
constructor(
readonly status: number,
readonly body: ApiErrorBody,
) {
super(body.message);
}
}
/**
* Fired once any api call answers 503 `maintenance_mode` (an in-app backup
* restore is running, issue #103) — App.tsx listens and swaps the UI for
* the maintenance screen, whatever the user was doing.
*/
export const MAINTENANCE_EVENT = 'dorfteich:maintenance';
function noteMaintenance(status: number, body: ApiErrorBody | null): void {
if (status === 503 && body?.code === 'maintenance_mode') {
window.dispatchEvent(new CustomEvent(MAINTENANCE_EVENT));
}
}
async function requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
noteMaintenance(response.status, parsed);
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
// 201/204 responses may carry no body at all.
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
export const apiPost = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('POST', path, body);
export const apiPut = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PUT', path, body);
export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PATCH', path, body);
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path);
/** Multipart upload (issue #27/#28) — a FormData body, unlike every other
* endpoint here, so it can't share `requestJson`'s JSON serialization; the
* error handling is kept identical. */
export async function apiUploadFile<T>(
path: string,
file: File,
fields: Record<string, string> = {},
): Promise<T> {
const form = new FormData();
form.append('file', file);
for (const [name, value] of Object.entries(fields)) form.append(name, value);
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 },
);
}
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');
}
/** Plain-text response (issue #30's Markdown export) — every other endpoint
* here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */
export async function apiGetText(path: string): Promise<string> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`);
} 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 },
);
}
return response.text();
}