Umbau-Projekt (SSR und Rest)

This commit is contained in:
2026-09-03 22:20:55 +02:00
parent 13363cb568
commit c66df283d1
37 changed files with 3719 additions and 344 deletions
+99 -6
View File
@@ -1,8 +1,16 @@
"use strict";
// Accept: application/json markiert diese Aufrufe als API-Zugriffe. Endpunkte,
// die auch ein Browser-Formular bedienen (siehe wantsJSON im Backend),
// antworten daraufhin mit JSON statt mit einem Redirect auf eine HTML-Seite.
const JSON_HEADERS = { Accept: "application/json" };
const api = {
async get(url) {
const r = await fetch(url, { credentials: "same-origin" });
const r = await fetch(url, {
credentials: "same-origin",
headers: JSON_HEADERS,
});
return r.json();
},
async post(url, formData) {
@@ -10,11 +18,25 @@ const api = {
method: "POST",
body: formData,
credentials: "same-origin",
headers: JSON_HEADERS,
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
},
};
// errMsg zieht die Nutzermeldung aus dem Fehlerformat der API:
// {"error": {"code", "message", "field", "fields"}}. Liegen mehrere
// Feldfehler vor (Registrierung), werden sie zu einem Satz zusammengezogen.
// Der code ist bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht
// für Menschen.
function errMsg(data, fallback) {
const e = data && data.error;
if (!e) return fallback;
const fields = e.fields ? Object.values(e.fields) : [];
if (fields.length) return fields.join(" ");
return e.message || fallback;
}
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
@@ -105,6 +127,15 @@ 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>
@@ -125,6 +156,7 @@ 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}`;
});
@@ -138,7 +170,7 @@ function setupVoting(el, pid) {
function render(state) {
if (state.error) {
counts.textContent = state.error;
counts.textContent = errMsg(state, "Fehler");
return;
}
counts.textContent = `${state.left} / ${state.right}`;
@@ -191,17 +223,73 @@ function setupBump(el, e) {
lock(res.data.retry_after || 86400);
} else {
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
btn.textContent = res.data.error || "Fehler";
btn.textContent = errMsg(res.data, "Fehler");
}
});
}
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
// Feld eingeklappt. Das Formular ist ein echtes method=post-Form, damit es
// später auch ohne JS funktioniert; hier fangen wir den Submit nur ab, 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");
});
}
// renderAd baut einen In-Feed-Werbeslot: das Banner (selbst gehostet, sonst
// würde die CSP default-src 'self' es blocken), darunter der Pflicht-Hinweis
// "Werbung" und der Link zur Eigenvermarktung. rel="sponsored nofollow" macht
// den bezahlten Charakter für Suchmaschinen kenntlich.
function renderAd() {
const el = document.createElement("aside");
el.className = "ad";
el.innerHTML = `
<a class="ad-banner" href="/werben" rel="sponsored nofollow noopener">
<img src="/static/ads/placeholder.png" alt="Werbung" loading="lazy">
</a>
<div class="ad-meta muted">
<span>Werbung</span>
<a href="/werben">Auf dem Kontrollverlust werben</a>
</div>
`;
return el;
}
// 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) {
// urlFor(page). opts.adEvery (>0) blendet nach je N Beiträgen einen
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds.
// Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
const adEvery = opts.adEvery || 0;
let page = 0;
let loading = false;
let done = false;
let shown = 0; // fortlaufende Beitragszahl über Seitengrenzen hinweg
async function loadMore() {
if (loading || done) return;
@@ -215,7 +303,11 @@ function createFeed(feedEl, sentinelEl, urlFor) {
end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end);
} else {
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
entries.forEach((e) => {
feedEl.appendChild(renderEntry(e));
shown++;
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
});
page++;
}
loading = false;
@@ -234,6 +326,7 @@ function createFeed(feedEl, sentinelEl, urlFor) {
function reset() {
page = 0;
done = false;
shown = 0;
feedEl.innerHTML = "";
loadMore();
}