"use strict"; const api = { async get(url) { const r = await fetch(url, { credentials: "same-origin" }); return r.json(); }, async post(url, formData) { const r = await fetch(url, { method: "POST", body: formData, credentials: "same-origin", }); return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }; }, }; // 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, "
"); } function renderEntry(e) { const el = document.createElement("article"); el.className = "card"; const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); const replyLabel = e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; const repostLabel = e.bump_count === 1 ? "1 Repost" : `${e.bump_count} Reposts`; // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. if (e.deleted) { el.innerHTML = `
[deleted] · ${when} · ${replyLabel} · ${repostLabel}
[deleted]
`; el.addEventListener("click", (ev) => { if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; }); return el; } const user = encodeURIComponent(e.username); let media = ""; if (e.filepath) { media = ``; } el.innerHTML = `
${escapeHtml(e.username)} · ${when} · ${replyLabel} · ${repostLabel}
${linkify(e.content)}
${media}
– / –
`; setupVoting(el, e.pid); setupBump(el, e); el.addEventListener("click", (ev) => { if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; }); return el; } function setupVoting(el, pid) { const box = el.querySelector(".row"); const counts = box.querySelector("span"); const buttons = box.querySelectorAll(".ghost"); function render(state) { if (state.error) { counts.textContent = state.error; return; } counts.textContent = `${state.left} / ${state.right}`; buttons.forEach((b) => b.classList.toggle("selected", b.dataset.mode === state.selected) ); } buttons.forEach((b) => 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); }) ); api.get(`/entry/${pid}/votes`).then(render); } // setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear // (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine // Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den // Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks. function setupBump(el, e) { const btn = el.querySelector(".bump"); if (!btn) return; // sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten. const nowSec = () => Math.floor(Date.now() / 1000); // lock sperrt den Button und zeigt die grobe Restzeit (auf Tage aufgerundet, // mindestens 1 -> nie "0 Tage", solange noch Restzeit übrig ist). function lock(retryAfter) { btn.disabled = true; const days = Math.max(1, Math.ceil(retryAfter / 86400)); btn.textContent = `Repost (${days} ${days === 1 ? "Tag" : "Tage"})`; } const remaining = e.last_bump + e.bump_count * 86400 - nowSec(); if (e.bump_count > 0 && remaining > 0) lock(remaining); btn.addEventListener("click", async () => { const res = await api.post(`/entry/${e.pid}/bump`, new FormData()); if (res.ok) { btn.disabled = true; btn.textContent = "repostet"; } else if (res.status === 429) { lock(res.data.retry_after || 86400); } else { // 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen. btn.textContent = res.data.error || "Fehler"; } }); } // 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) { let page = 0; let loading = false; let done = false; 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))); 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; 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 }; }