`;
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 };
}