cad1fa5c5c
Der IntersectionObserver feuert nur bei Zustandswechseln. Füllte die erste Feed-Seite den Viewport nicht über den Sentinel hinaus (Hauptfeed mit 198 Roots), kam es nie zu einem Übergang und damit nie zum Nachladen. loadMore prüft jetzt nach jedem Fetch, ob der Sentinel noch sichtbar ist, und lädt aktiv weiter; rootMargin 300px lädt zudem vorausschauend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
4.6 KiB
JavaScript
162 lines
4.6 KiB
JavaScript
"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;
|
||
}
|
||
|
||
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">[deleted] · ${when}</div>
|
||
<div class="content">[deleted]</div>
|
||
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></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"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
||
<div class="content">${linkify(e.content)}</div>
|
||
${media}
|
||
<div class="interactions">
|
||
<div class="muted replies"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
||
<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>
|
||
`;
|
||
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 };
|
||
}
|