Moderation entfernt, JSON-API unter /api

- Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt
  als eigenständiges Projekt
- alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405,
  Rate-Limit (429) und Panics (500)
- Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und
  Vote-Zählern liefern jetzt 500 statt Nullen
- /auth/headerbar entfernt (Frontend nutzt /api/user/info)
- Frontend auf /api und statusbasierte Auswertung umgestellt
- notes/api.md neu als Referenz der aktuellen API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:10:02 +02:00
co-authored by Claude Opus 5.5
parent 2714fdb1a5
commit 030a5bd943
21 changed files with 430 additions and 726 deletions
+3 -4
View File
@@ -4,16 +4,15 @@
let loggedIn = false;
async function refreshHeader() {
const h = await api.get("/auth/headerbar");
loggedIn = !!h.loggedin;
const me = await api.get("/user/info");
loggedIn = me.ok;
document.getElementById("auth-box").hidden = loggedIn;
document.getElementById("compose").hidden = !loggedIn;
const bar = document.getElementById("headerbar");
if (loggedIn) {
const me = await api.get("/user/info");
const user = encodeURIComponent(me.username);
const user = encodeURIComponent(me.data.username);
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
document.getElementById("logout-btn").addEventListener("click", async () => {
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
+7 -4
View File
@@ -23,13 +23,14 @@ replyForm.addEventListener("submit", async (e) => {
});
async function load() {
const data = await api.get(`/entry/${pid}/thread`);
const res = await api.get(`/entry/${pid}/thread`);
const focus = document.getElementById("focus");
if (data.error) {
focus.textContent = "Beitrag nicht gefunden";
if (!res.ok) {
focus.textContent = errMsg(res.data, "Beitrag konnte nicht geladen werden.");
return;
}
const data = res.data;
// Ahnenkette (Root zuerst), dann der fokussierte Beitrag.
const ancestors = document.getElementById("ancestors");
@@ -40,7 +41,9 @@ async function load() {
focus.appendChild(renderEntry(data.entry));
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen.
const me = await api.get("/user/info");
// 401 heißt nicht angemeldet -> me bleibt leer.
const meRes = await api.get("/user/info");
const me = meRes.ok ? meRes.data : {};
document.getElementById("reply-box").hidden = !me.uid;
if (me.uid === data.entry.uid && !data.entry.deleted) {
const editBtn = document.createElement("button");
+31 -57
View File
@@ -1,24 +1,28 @@
"use strict";
// Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit.
// Die API liegt unter /api und antwortet ausschließlich mit JSON. Ob ein Aufruf
// geklappt hat, sagt der HTTP-Status (ok/status); data ist bei Fehlern das
// Fehlerobjekt {"error": {...}}. Pfade werden ohne /api übergeben.
const JSON_HEADERS = { Accept: "application/json" };
async function apiResult(r) {
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
}
const api = {
async get(url) {
const r = await fetch(url, {
async get(path) {
return apiResult(await fetch(`/api${path}`, {
credentials: "same-origin",
headers: JSON_HEADERS,
});
return r.json();
}));
},
async post(url, formData) {
const r = await fetch(url, {
async post(path, formData) {
return apiResult(await fetch(`/api${path}`, {
method: "POST",
body: formData,
credentials: "same-origin",
headers: JSON_HEADERS,
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
}));
},
};
@@ -125,15 +129,6 @@ function renderEntry(e) {
<div class="muted byline">
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
<details class="report">
<summary>Melden</summary>
<form class="report-form" method="post" action="/entry/${e.pid}/report">
<label>Grund <span class="muted">(optional)</span>
<input type="text" name="reason" maxlength="500" autocomplete="off">
</label>
<button type="submit">Absenden</button>
</form>
</details>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
@@ -154,7 +149,6 @@ function renderEntry(e) {
`;
setupVoting(el, e.pid);
setupBump(el, e);
setupReport(el, e.pid);
el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
});
@@ -166,11 +160,12 @@ function setupVoting(el, pid) {
const counts = box.querySelector("span");
const buttons = box.querySelectorAll(".ghost");
function render(state) {
if (state.error) {
counts.textContent = errMsg(state, "Fehler");
function render(res) {
if (!res.ok) {
counts.textContent = errMsg(res.data, "Fehler");
return;
}
const state = res.data;
counts.textContent = `${state.left} / ${state.right}`;
buttons.forEach((b) =>
b.classList.toggle("selected", b.dataset.mode === state.selected)
@@ -181,8 +176,7 @@ function setupVoting(el, pid) {
b.addEventListener("click", async () => {
const fd = new FormData();
fd.append("mode", b.dataset.mode);
const res = await api.post(`/entry/${pid}/vote`, fd);
render(res.data);
render(await api.post(`/entry/${pid}/vote`, fd));
})
);
@@ -226,38 +220,6 @@ function setupBump(el, e) {
});
}
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
// Feld eingeklappt. Der Submit wird abgefangen, um Inline-Feedback zu geben
// statt die Seite neu zu laden. stopPropagation, weil
// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde.
function setupReport(el, pid) {
const details = el.querySelector("details.report");
if (!details) return;
details.addEventListener("click", (ev) => ev.stopPropagation());
const form = details.querySelector(".report-form");
const summary = details.querySelector("summary");
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const res = await api.post(`/entry/${pid}/report`, new FormData(form));
if (res.ok) {
form.remove();
details.open = false;
details.classList.add("reported");
summary.textContent = "Gemeldet – danke";
return;
}
let msg = form.querySelector(".msg");
if (!msg) {
msg = document.createElement("span");
msg.className = "msg";
form.append(msg);
}
msg.textContent = errMsg(res.data, "Fehler beim Melden");
});
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) {
@@ -269,7 +231,19 @@ function createFeed(feedEl, sentinelEl, urlFor) {
if (loading || done) return;
loading = true;
const entries = await api.get(urlFor(page));
const res = await api.get(urlFor(page));
if (!res.ok) {
// Fehler nicht als "Ende" verbuchen: Meldung zeigen und weiteres
// Nachladen stoppen, statt in einer Schleife neu anzufragen.
done = true;
const err = document.createElement("p");
err.className = "msg";
err.textContent = errMsg(res.data, "Feed konnte nicht geladen werden.");
feedEl.appendChild(err);
loading = false;
return;
}
const entries = res.data;
if (!entries.length) {
done = true;
const end = document.createElement("p");
+10 -2
View File
@@ -1,6 +1,6 @@
"use strict";
// Statistik-Seite: holt die Aggregate von /stats/detail und rendert Zahlen +
// Statistik-Seite: holt die Aggregate von /api/stats/detail und rendert Zahlen +
// einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden
// per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von
// der strikten CSP nicht betroffen.
@@ -63,7 +63,15 @@ function renderBars(container, items, formatLabel) {
}
async function render() {
const s = await api.get("/stats/detail");
const res = await api.get("/stats/detail");
if (!res.ok) {
const p = document.createElement("p");
p.className = "msg";
p.textContent = errMsg(res.data, "Statistik konnte nicht geladen werden.");
document.getElementById("stat-overview").append(p);
return;
}
const s = res.data;
// --- Auf einen Blick ---
const overview = document.getElementById("stat-overview");
+6 -4
View File
@@ -41,11 +41,13 @@ document.getElementById("delete-form").addEventListener("submit", async (e) => {
});
async function init() {
const info = await api.get(`/u/${encodeURIComponent(username)}/info`);
if (info.error) {
document.getElementById("profile-name").textContent = "Nutzer nicht gefunden";
const res = await api.get(`/u/${encodeURIComponent(username)}/info`);
if (!res.ok) {
document.getElementById("profile-name").textContent =
errMsg(res.data, "Profil konnte nicht geladen werden.");
return;
}
const info = res.data;
document.getElementById("profile-name").textContent = info.username;
document.getElementById("profile-avatar").src = avatarUrl(info.avatar);
@@ -54,7 +56,7 @@ async function init() {
// Eigene Seite? -> Einstellungen einblenden.
const me = await api.get("/user/info");
const isOwner = me.username === info.username;
const isOwner = me.ok && me.data.username === info.username;
document.getElementById("settings").hidden = !isOwner;
createFeed(