Files
Kontrollverlust/web/js/feed.js
T
irrlicht ad2c08d93a Thread-Layout: Strich als Flex-Geschwister, mittig unter dem Avatar
Strich neben dem Content wird über ein eigenes Flex-Element mit
align-self: stretch gezeichnet statt über eine Border am Content –
dadurch exakt so hoch wie der Inhalt, kein Über-/Unterstehen mehr.
Vorfahren und Antworten teilen denselben Mechanismus (Antworten ohne
Border). Strichposition und -dicke über CSS-Variablen an die halbe
Avatargröße gekoppelt, sodass die Linie mittig unter dem Byline-Avatar
sitzt. Antworten-Label in die Byline verschoben, vertikaler Abstand
unter den .body-Container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 06:49:56 +02:00

178 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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(() => ({})) };
},
};
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
// 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) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
.replace(/\n/g, "<br>");
}
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`;
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
el.innerHTML = `
<div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span></div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">[deleted]</div>
</div>
</div>
`;
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 = `<img src="/${e.filepath}" alt="" loading="lazy">`;
}
el.innerHTML = `
<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} · <a href="/e/${e.pid}">${replyLabel}</a></span>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">${linkify(e.content)}</div>
${media}
<div class="interactions">
<div class="row">
<button class="ghost" data-mode="left">Links</button>
<span class="muted"> / </span>
<button class="ghost" data-mode="right">Rechts</button>
</div>
</div>
</div>
</div>
`;
setupVoting(el, e.pid);
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);
}
// 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 };
}