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
+63
View File
@@ -302,6 +302,53 @@ img.avatar-lg { width: 72px; height: 72px; }
.ghost.selected { font-weight: bold; }
.msg { color: red; }
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen).
margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
dann absenden) verhindert versehentliche Meldungen; nativ (details/summary +
echtes Formular), damit es später auch ohne JS funktioniert. */
details.report {
margin-left: auto;
position: relative;
font-size: 0.9rem;
}
/* summary wie ein Button, aber zurückhaltend (grau), da Moderationshilfe. */
details.report > summary {
list-style: none;
user-select: none;
cursor: pointer;
padding: 2px 8px;
border: 1px solid #ccc;
color: gray;
background-color: white;
}
details.report > summary::-webkit-details-marker { display: none; }
details.report > summary:hover { text-decoration: underline; }
details.report[open] > summary { color: #a00; border-color: #a00; }
details.report.reported > summary {
color: green;
border-color: green;
cursor: default;
text-decoration: none;
}
/* Aufgeklapptes Grund-Feld schwebt unter dem Button, damit die Meta-Zeile nicht
springt. Über dem Inhalt (z-index) und rechtsbündig ausgerichtet. */
.report-form {
position: absolute;
right: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
margin-top: 0.25rem;
padding: 0.5rem;
background-color: white;
border: 1px solid #ccc;
white-space: nowrap;
}
.report-form input { font-family: inherit; font-size: 0.9rem; }
p.warning {
background-color: #faa;
padding: 10px 15px;
@@ -393,3 +440,19 @@ hr {
flex-basis: 13rem;
white-space: normal;
}
/* Blätternavigation des serverseitig gerenderten Feeds. Ohne JavaScript gibt
es kein Nachladen beim Scrollen -- stattdessen Zurück/Weiter am Seitenende.
space-between hält die Seitenanzeige mittig, auch wenn auf der ersten bzw.
letzten Seite einer der beiden Links fehlt. */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 1.5rem 0;
}
.pager > span {
flex: 1;
text-align: center;
}
+7
View File
@@ -20,6 +20,13 @@
</header>
<main>
<noscript>
<p class="msg">Diese Startseite braucht derzeit noch JavaScript. Ohne JS
nutzbar ist bereits alles Wesentliche – Lesen, Schreiben, Antworten,
Abstimmen und Melden: <a href="/feed">zum Feed</a> ·
<a href="/auth/login">Login</a> · <a href="/auth/register">Registrieren</a>.</p>
</noscript>
<!-- Login / Registrierung -->
<section id="auth-box">
<form id="login-form">
+8 -7
View File
@@ -16,7 +16,8 @@ async function refreshHeader() {
const user = encodeURIComponent(me.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 () => {
await api.get("/auth/logout");
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
await api.post("/auth/logout", new FormData());
await refreshHeader();
});
} else {
@@ -51,7 +52,7 @@ loginForm.addEventListener("submit", async (e) => {
if (res.ok) {
await refreshHeader();
} else {
authMsg.textContent = res.data.error || "Login fehlgeschlagen.";
authMsg.textContent = errMsg(res.data, "Login fehlgeschlagen.");
}
});
@@ -60,9 +61,8 @@ registerForm.addEventListener("submit", async (e) => {
const data = new FormData(e.target);
const res = await api.post("/auth/newuser", data);
if (!res.ok) {
// newuser liefert eine Fehlerliste ({"errors": [...]}).
const errors = res.data.errors || [res.data.error || "Registrierung fehlgeschlagen."];
authMsg.textContent = errors.join(" ");
// newuser meldet alle Feldfehler auf einmal (error.fields).
authMsg.textContent = errMsg(res.data, "Registrierung fehlgeschlagen.");
return;
}
// Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist.
@@ -78,7 +78,8 @@ registerForm.addEventListener("submit", async (e) => {
const feed = createFeed(
document.getElementById("feed"),
document.getElementById("sentinel"),
(page) => `/entry/feed/${page}`
(page) => `/entry/feed/${page}`,
{ adEvery: 5 }
);
// --- Neuer Beitrag ---
@@ -92,7 +93,7 @@ document.getElementById("entry-form").addEventListener("submit", async (e) => {
e.target.reset();
feed.reset();
} else {
msg.textContent = res.data.error || "Beitrag fehlgeschlagen.";
msg.textContent = errMsg(res.data, "Beitrag fehlgeschlagen.");
}
});
+3 -3
View File
@@ -18,7 +18,7 @@ replyForm.addEventListener("submit", async (e) => {
load();
} else {
document.getElementById("reply-msg").textContent =
res.data.error || "Antwort fehlgeschlagen.";
errMsg(res.data, "Antwort fehlgeschlagen.");
}
});
@@ -59,7 +59,7 @@ async function load() {
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
if (res.ok) load();
else form.querySelector(".msg").textContent =
res.data.error || "Bearbeiten fehlgeschlagen.";
errMsg(res.data, "Bearbeiten fehlgeschlagen.");
});
form.querySelector(".cancel").addEventListener("click", load);
focus.appendChild(form);
@@ -71,7 +71,7 @@ async function load() {
if (!confirm("Beitrag wirklich löschen?")) return;
const res = await api.post(`/entry/${pid}/delete`, new FormData());
if (res.ok) load();
else alert(res.data.error || "Löschen fehlgeschlagen.");
else alert(errMsg(res.data, "Löschen fehlgeschlagen."));
});
focus.appendChild(editBtn);
+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();
}
+3 -3
View File
@@ -10,7 +10,7 @@ document.getElementById("rename-form").addEventListener("submit", async (e) => {
location.href = `/u/${encodeURIComponent(res.data.username)}`;
} else {
document.getElementById("rename-msg").textContent =
res.data.error || "Umbenennen fehlgeschlagen.";
errMsg(res.data, "Umbenennen fehlgeschlagen.");
}
});
@@ -24,7 +24,7 @@ document.getElementById("avatar-form").addEventListener("submit", async (e) => {
document.getElementById("avatar-msg").textContent = "";
} else {
document.getElementById("avatar-msg").textContent =
res.data.error || "Hochladen fehlgeschlagen.";
errMsg(res.data, "Hochladen fehlgeschlagen.");
}
});
@@ -36,7 +36,7 @@ document.getElementById("delete-form").addEventListener("submit", async (e) => {
location.href = "/";
} else {
document.getElementById("delete-msg").textContent =
res.data.error || "Löschen fehlgeschlagen.";
errMsg(res.data, "Löschen fehlgeschlagen.");
}
});