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:
co-authored by
Claude Opus 5.5
parent
2714fdb1a5
commit
030a5bd943
+3
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user