"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",
headers: JSON_HEADERS,
});
return r.json();
},
async post(url, formData) {
const r = await fetch(url, {
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.
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
function avatarUrl(path) {
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
}
function linkify(s) {
const escaped = escapeHtml(s);
const url = /https?:\/\/[^\s<]+/g;
return escaped
.replace(url, (m) => `${m}`)
.replace(/\n/g, "
");
}
// setupClipboardPaste erlaubt Strg+V eines Bildes aus der Zwischenablage direkt
// im Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
// wir wirklich ein Bild gegriffen haben. msgEl (optional) zeigt den Hinweis.
function setupClipboardPaste(form, msgEl) {
form.addEventListener("paste", (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/")
);
if (!item) return;
const original = item.getAsFile();
if (!original) return;
e.preventDefault();
// Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu
// verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = form.querySelector("input[name='file']");
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
if (msgEl) msgEl.textContent = "Bild aus Zwischenablage übernommen.";
});
}
function renderEntry(e) {
const el = document.createElement("article");
el.className = "card";
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
el.innerHTML = `
`;
return el;
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// 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;
loading = true;
const entries = await api.get(urlFor(page));
if (!entries.length) {
done = true;
const end = document.createElement("p");
end.className = "muted";
end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end);
} else {
entries.forEach((e) => {
feedEl.appendChild(renderEntry(e));
shown++;
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
});
page++;
}
loading = false;
// Der IntersectionObserver feuert nur bei Zustandswechseln. Füllt die
// gerade geladene Seite den Viewport nicht über den Sentinel hinaus, gäbe
// es keinen Wechsel und nie ein Nachladen -> hier aktiv weiterladen, bis
// der Sentinel aus dem Sichtbereich wandert oder das Ende erreicht ist.
if (!done && sentinelVisible()) loadMore();
}
function sentinelVisible() {
return sentinelEl.getBoundingClientRect().top <= window.innerHeight;
}
function reset() {
page = 0;
done = false;
shown = 0;
feedEl.innerHTML = "";
loadMore();
}
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach (flüssiger und
// robust gegen den Fall eines 0-hohen Sentinels exakt am Dokumentende).
const observer = new IntersectionObserver(
(items) => {
if (items.some((i) => i.isIntersecting)) loadMore();
},
{ rootMargin: "300px" }
);
observer.observe(sentinelEl);
loadMore();
return { reset };
}