Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

85
web/html/js/api.js Normal file
View File

@@ -0,0 +1,85 @@
// Kommunikation mit der API. Einziger Ort, an dem fetch() aufgerufen wird.
"use strict";
export class ApiError extends Error {
constructor(status, detail) {
super(detail);
this.name = "ApiError";
this.status = status;
}
}
export class OfflineError extends Error {
constructor() {
super("Keine Verbindung zum Server.");
this.name = "OfflineError";
}
}
/** Das CSRF-Token steht in einem lesbaren Cookie. Das Session-Cookie ist
* HttpOnly und für JavaScript unsichtbar - genau so soll es sein. */
function csrfToken() {
const hit = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("ea_csrf="));
return hit ? decodeURIComponent(hit.slice("ea_csrf=".length)) : "";
}
/** Übersetzt die Fehlerform von FastAPI in einen Satz für Menschen.
* Bei Validierungsfehlern ist "detail" eine Liste, keine Zeichenkette. */
function describe(status, data) {
const detail = data && data.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail) && detail.length) {
const first = detail[0];
const field = Array.isArray(first.loc) ? first.loc[first.loc.length - 1] : "";
return field ? `Ungültige Eingabe bei "${field}": ${first.msg}` : first.msg;
}
if (status === 401) return "Nicht angemeldet.";
if (status === 429) return "Zu viele Versuche. Bitte einen Moment warten.";
if (status >= 500) return "Der Server hat einen Fehler gemeldet.";
return `Unerwarteter Fehler (HTTP ${status}).`;
}
export async function api(path, { method = "GET", body } = {}) {
const headers = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET" && method !== "HEAD") headers["X-CSRF-Token"] = csrfToken();
let response;
try {
response = await fetch(path, {
method,
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
// Netzwerkfehler von HTTP-Fehlern trennen: In Phase 4 entscheidet
// genau das, ob eine Änderung in die Outbox wandert.
throw new OfflineError();
}
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
throw new ApiError(
response.status,
`Unerwartete Antwort vom Server (HTTP ${response.status}).`
);
}
}
if (!response.ok) throw new ApiError(response.status, describe(response.status, data));
return data;
}
export const get = (path) => api(path);
export const post = (path, body) => api(path, { method: "POST", body });
export const patch = (path, body) => api(path, { method: "PATCH", body });
export const put = (path, body) => api(path, { method: "PUT", body });
export const del = (path) => api(path, { method: "DELETE" });