Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user