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.
136 lines
4.8 KiB
TypeScript
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();
|
|
}
|