228 lines
7.3 KiB
JavaScript
228 lines
7.3 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(() => ({})) };
|
||
},
|
||
};
|
||
|
||
// 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, """)
|
||
.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) => `<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`;
|
||
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 = `
|
||
<div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a> · ${repostLabel}</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">`;
|
||
}
|
||
|
||
// Antwort- und Repost-Zahl wandern in die Buttons unten; in der Byline bleibt
|
||
// nur Name, Zeit und (im Cooldown) der Hinweis bis zum nächsten Repost.
|
||
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}<span class="cooldown"></span></span>
|
||
</div>
|
||
<div class="threadline-wrapper">
|
||
<div class="threadline"></div>
|
||
<div class="body">
|
||
<div class="content">${linkify(e.content)}</div>
|
||
${media}
|
||
<div class="interactions">
|
||
<a class="button reply" href="/e/${e.pid}">Antworten (${e.reply_count})</a>
|
||
<div class="row">
|
||
<button class="ghost" data-mode="left">Links</button>
|
||
<span class="muted">– / –</span>
|
||
<button class="ghost" data-mode="right">Rechts</button>
|
||
</div>
|
||
<button class="bump" type="button">Repost (${e.bump_count})</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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;
|
||
const cooldown = el.querySelector(".cooldown");
|
||
|
||
// sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten.
|
||
const nowSec = () => Math.floor(Date.now() / 1000);
|
||
|
||
// lock sperrt den Button; die Restzeit steht jetzt oben in der Byline, auf
|
||
// Stunden aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
|
||
function lock(retryAfter) {
|
||
btn.disabled = true;
|
||
const hours = Math.max(1, Math.ceil(retryAfter / 3600));
|
||
if (cooldown) cooldown.textContent = ` · Repost in ${hours}h`;
|
||
}
|
||
|
||
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.textContent = `Repost (${res.data.bump_count})`;
|
||
lock(res.data.retry_after || 86400);
|
||
} 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 };
|
||
}
|