/* ==========================================================================
POST: reader for both curated wiki entries and community posts.
========================================================================== */
/* --- cross-link peek ----------------------------------------------------- */
function peekStyle(at) {
return {
left: Math.min(at.x, window.innerWidth - 316),
top: at.y + 10 > window.innerHeight - 120 ? at.y - 110 : at.y + 10,
};
}
function Peek({ at, entry }) {
return (
, die
// Bedienbarkeit kommt von hier, damit das Artikel-HTML sauber bleibt.
const sourceOf = (el) => (refs || []).find((r, i) => String(r.n || i + 1) === el.dataset.ref);
const jumpToSource = (el) => {
const row = document.getElementById("quelle-" + el.dataset.ref);
if (!row) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
row.scrollIntoView({ behavior: still ? "auto" : "smooth", block: "center" });
row.classList.add("hit");
setTimeout(() => row.classList.remove("hit"), 2500);
};
useEffect(() => {
const root = ref.current;
if (!root) return;
const find = (e) => e.target.closest && e.target.closest(".auto-link");
const findCite = (e) => e.target.closest && e.target.closest(".cite");
root.querySelectorAll(".cite").forEach((el) => {
const src = sourceOf(el);
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", BB.t("Quelle") + " " + el.dataset.ref + (src ? ": " + src.text : ""));
});
const onClick = (e) => {
const c = findCite(e);
if (c) {
e.preventDefault();
setCited(null);
jumpToSource(c);
return;
}
const a = find(e);
if (!a) return;
e.preventDefault();
setPeek(null);
go("/wissen/" + a.dataset.slug);
};
const onKey = (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const c = findCite(e);
if (!c) return;
e.preventDefault();
jumpToSource(c);
};
const onOver = (e) => {
const c = findCite(e);
if (c) {
const src = sourceOf(c);
if (!src) return;
const r = c.getBoundingClientRect();
setCited({ at: { x: r.left, y: r.bottom }, source: { ...src, n: c.dataset.ref } });
return;
}
const a = find(e);
if (!a) return;
const entry = index.find((x) => x.slug === a.dataset.slug);
if (!entry) return;
const r = a.getBoundingClientRect();
setPeek({ at: { x: r.left, y: r.bottom }, entry });
};
const onOut = (e) => {
if (findCite(e)) setCited(null);
if (find(e)) setPeek(null);
};
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKey);
root.addEventListener("mouseover", onOver);
root.addEventListener("mouseout", onOut);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKey);
root.removeEventListener("mouseover", onOver);
root.removeEventListener("mouseout", onOut);
};
}, [html, index, go, refs]);
return (
<>
{/* show-todo blendet die Bildplatzhalter ein, die dem Leser verborgen bleiben */}
{peek && }
{cited && }
>
);
}
/* --- kind-specific side panels ------------------------------------------- */
function WikiPanel({ post, categories }) {
const d = post.data || {};
const schema = BB.schemaFor(categories, post.categoryId);
const cat = BB.catById(categories, post.categoryId);
return (
<>
{(d.molecule || d.moleculeNote) && (
{BB.t("Strukturformel")}
{d.molecule && d.formula && (
{d.formula}
)}
)}
{schema && d.bars && (
)}
{BB.t("Steckbrief")}
{cat && <>{BB.t("Kategorie")} {cat.label} >}
{d.formula && <>{BB.t("Formel")} {d.formula} >}
{d.aliases && d.aliases.length > 0 && <>{BB.t("Auch bekannt als")} {d.aliases.join(", ")} >}
{BB.t("Aufrufe")} {BB.num(post.views)}
>
);
}
function StackPanel({ post }) {
const d = post.data || {};
const compounds = Array.isArray(d.compounds) ? d.compounds.filter((c) => c && c.name) : [];
if (!compounds.length && !d.weeks && !d.goal) return null;
return (
{BB.t("Protokoll")}
{d.goal && <>{BB.t("Ziel")} {d.goal} >}
{d.weeks && <>{BB.t("Dauer")} {d.weeks} Wochen >}
{d.experience && <>{BB.t("Erfahrung")} {d.experience} >}
{compounds.length > 0 && (
<>
{BB.t("Wirkstoffe")}
{compounds.map((c, i) => (
{c.name}
{[c.dose, c.freq].filter(Boolean).join(" · ")}
))}
>
)}
);
}
function TransformationPanel({ post }) {
const d = post.data || {};
const pairs = [
["Gewicht", d.weightBefore, d.weightAfter, "kg"],
["Körperfett", d.bfBefore, d.bfAfter, "%"],
].filter(([, a, b]) => a || b);
if (!pairs.length && !d.months && !d.natural) return null;
return (
{BB.t("Zahlen")}
{pairs.map(([label, a, b, unit]) => (
{label}
{a || "k. A."}{unit} → {b || "k. A."}{unit}
))}
{d.months && <>{BB.t("Zeitraum")} {d.months} Monate >}
{d.natural !== undefined && <>{BB.t("Natural")} {d.natural ? "Ja" : "Nein"} >}
{d.beforeUrl &&
}
{d.afterUrl &&
}
);
}
function RecipePanel({ post }) {
const d = post.data || {};
const ingredients = Array.isArray(d.ingredients) ? d.ingredients.filter((c) => c && c.name) : [];
const macros = [
["kcal", BB.t("Kalorien"), "kcal"],
["protein", BB.t("Eiweiß"), "g"],
["carbs", BB.t("Kohlenhydrate"), "g"],
["fat", "Fett", "g"],
["fiber", BB.t("Ballaststoffe"), "g"],
].filter(([key]) => d[key]);
if (!ingredients.length && !macros.length && !d.servings && !d.minutes) return null;
return (
<>
{BB.t("Rezept")}
{d.servings && <>{BB.t("Portionen")} {d.servings} >}
{d.minutes && <>{BB.t("Zubereitungszeit")} {d.minutes} {BB.t("Minuten")} >}
{d.difficulty && <>{BB.t("Aufwand")} {d.difficulty} >}
{macros.length > 0 && (
<>
{BB.t("Nährwerte")} ({BB.t("pro Portion")})
{macros.map(([key, label, unit]) => (
{d[key]}{unit}
{label}
))}
>
)}
{ingredients.length > 0 && (
{BB.t("Zutaten")}
{ingredients.map((c, i) => (
{c.amount || ""}
{c.name}
))}
)}
>
);
}
/* --- Redaktionsstand ------------------------------------------------------ */
/** Markiert einen Beitrag als geprüft oder überarbeitungsbedürftig. Sichtbar
nur für Admins und Mods, direkt beim Lesen. */
function ReviewBox({ post, onChange }) {
const notify = useToast();
const [open, setOpen] = useState(false);
const [note, setNote] = useState(post.reviewNote || "");
const [busy, setBusy] = useState(false);
const state = post.review || "";
useEffect(() => { setNote(post.reviewNote || ""); }, [post.reviewNote]);
async function mark(next, nextNote) {
setBusy(true);
try {
const r = await BB.post("/admin/posts/" + post.id + "/review", {
review: next,
note: nextNote === undefined ? note : nextNote,
});
onChange(r);
notify(next ? "Markiert: " + BB.REVIEWS[next].label : "Markierung entfernt");
setOpen(false);
} catch (err) {
notify(err.message, "bad");
} finally {
setBusy(false);
}
}
return (
{BB.t("Redaktion")}
{Object.entries(BB.REVIEWS).map(([key, r]) => (
mark(state === key ? "" : key)}
>
{r.mark} {r.label}
))}
setOpen((o) => !o)}>
{post.reviewNote ? "Notiz bearbeiten" : "Notiz"}
{post.reviewAt && (
{BB.when(post.reviewAt)}
)}
{!open && post.reviewNote &&
{post.reviewNote}
}
{open && (
)}
);
}
/* --- comments ------------------------------------------------------------ */
function CommentForm({ postId, parentId, onDone, onCancel, autoFocus }) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const notify = useToast();
async function submit(e) {
e.preventDefault();
if (!text.trim() || busy) return;
setBusy(true);
try {
await BB.post(`/posts/${postId}/comments`, { body: text, parentId: parentId || null });
setText("");
onDone();
} catch (err) {
notify(err.message, "bad");
} finally {
setBusy(false);
}
}
return (
);
}
function Comment({ comment, replies, postId, onChange, depth = 0 }) {
const { user } = useAuth();
const [replying, setReplying] = useState(false);
const notify = useToast();
const mine = user && (user.id === comment.authorId || user.role === "admin" || user.role === "mod");
async function remove() {
if (!confirm("Kommentar löschen?")) return;
try {
await BB.del("/comments/" + comment.id);
onChange();
} catch (err) {
notify(err.message, "bad");
}
}
return (
<>
0 ? " reply" : "")}>
{comment.author.displayName}
{comment.author.role === "admin" && {BB.t("Admin")} }
{BB.when(comment.createdAt)}
{comment.body}
{user && depth === 0 && setReplying((r) => !r)}>{BB.t("Antworten")} }
{mine && {BB.t("Löschen")} }
{replying && (
{ setReplying(false); onChange(); }}
onCancel={() => setReplying(false)}
/>
)}
{replies.map((r) => (
))}
>
);
}
function Comments({ postId }) {
const { user } = useAuth();
const [list, setList] = useState(null);
const load = useCallback(() => {
BB.get(`/posts/${postId}/comments`).then(setList).catch(() => setList([]));
}, [postId]);
useEffect(() => { load(); }, [load]);
const { roots, byParent } = useMemo(() => {
const all = list || [];
const byParent = new Map();
all.forEach((c) => {
if (c.parentId) {
if (!byParent.has(c.parentId)) byParent.set(c.parentId, []);
byParent.get(c.parentId).push(c);
}
});
return { roots: all.filter((c) => !c.parentId), byParent };
}, [list]);
return (
{list ? `${list.length} ${BB.t(list.length === 1 ? "Kommentar" : "Kommentare")}` : BB.t("Kommentare")}
{user ? (
) : (
{BB.t("Melde dich an, um mitzudiskutieren.")}
{BB.t("Einloggen")}
)}
{list === null ? (
) : roots.length === 0 ? (
{BB.t("Noch keine Kommentare.")}
) : (
roots.map((c) => (
))
)}
);
}
/* --- page ---------------------------------------------------------------- */
/* ==========================================================================
EVIDENZMATRIX: welche Studie traegt welche Aussage, und wie gut.
Die Quellenliste darueber sagt, *dass* der Artikel Belege hat. Sie sagt
nicht, welcher Satz an welcher Arbeit haengt. Genau da lebt das
Autoritaetsargument: eine PMID hinter einer Behauptung sieht aus wie ein
Beleg, auch wenn dahinter zwoelf Ratten ueber zehn Tage stehen.
Spaltenreihenfolge ist Absicht: erst der Score, dann die Arbeit, dann die
Aussage, dann die Merkmale, die den Score begruenden. Eine Zeile je Paar aus
Studie und Aussage -- dieselbe Arbeit steht mehrfach da, wenn sie mehreres
traegt, und das ist die Auskunft und keine Dopplung.
========================================================================== */
function evidenzStufe(score) {
if (score >= 70) return "gut";
if (score >= 55) return "ok";
if (score >= 38) return "mittel";
if (score >= 20) return "schwach";
return "kaum";
}
const EV_RICHTUNG = { stuetzt: "stützt", teilweise: "teilweise", widerspricht: "widerspricht" };
function EvidenzZeile({ beleg, aussage, offen, onToggle }) {
const st = beleg.studie || {};
const merkmale = [
st.design,
// Wo gemessen wurde und wie übertragbar das ist, sind zwei Angaben. Die
// Rattenstudie am konservierten Rezeptor trägt eine Mechanismusaussage
// direkt und eine Humandosis-Aussage gar nicht.
st.modell,
st.n ? "n = " + BB.num(st.n) : null,
st.dauer_tage ? Math.round(st.dauer_tage / 7) + " Wochen" : null,
st.randomisiert ? BB.t("randomisiert") : null,
st.verblindung ? BB.t("Verblindung") + ": " + st.verblindung : null,
st.kontrolle ? BB.t("Kontrolle") + ": " + st.kontrolle : null,
st.studienzahl ? st.studienzahl + " " + BB.t("Arbeiten") : null,
st.interessen ? BB.t("Interessen") + ": " + st.interessen : null,
beleg.uebertragbar ? BB.t("übertragbar") + ": " + beleg.uebertragbar : null,
].filter(Boolean);
const grund = beleg.uebertragbar_grund || beleg.uebertragbarGrund || "";
return (
<>
{Math.round(beleg.score)}
{st.pmid
? {st.quelle}
: st.quelle}
{beleg.richtung && beleg.richtung !== "stuetzt" && (
{BB.t(EV_RICHTUNG[beleg.richtung] || beleg.richtung)}
)}
{aussage}
{merkmale.join(" · ")}
{offen && (
{(beleg.herleitung || []).map((h, i) => (
{h.punkte > 0 ? "+" : ""}{h.punkte}
{BB.t(h.was)}{h.wert !== undefined ? ": " + h.wert : ""}
))}
{beleg.beleg && {beleg.beleg}
}
{grund && (
{BB.t("Warum übertragbar")}: {grund}
)}
{st.limitationen && (
{BB.t("Grenzen")}: {st.limitationen}
)}
)}
>
);
}
function Evidenzmatrix({ slug }) {
const [daten, setDaten] = useState(null);
const [offen, setOffen] = useState("");
const [alle, setAlle] = useState(false);
useEffect(() => {
let aktiv = true;
BB.get("/evidenz/" + encodeURIComponent(slug))
.then((d) => { if (aktiv) setDaten(d); })
.catch(() => {});
return () => { aktiv = false; };
}, [slug]);
if (!daten || !(daten.aussagen || []).length) return null;
// Eine Zeile je Paar, staerkster Beleg zuerst. Nicht nach Aussage gruppiert:
// die Frage "was traegt hier am meisten" kommt vor "was steht wo im Text".
const zeilen = [];
daten.aussagen.forEach((a) => {
(a.belege || []).forEach((b) => zeilen.push({ beleg: b, aussage: a.aussage }));
});
zeilen.sort((x, y) => y.beleg.score - x.beleg.score);
const sichtbar = alle ? zeilen : zeilen.slice(0, 8);
const b = daten.bilanz || {};
return (
{BB.t("Evidenzmatrix")}
{BB.t("Welche Arbeit trägt welche Aussage, und wie gut. Der Wert links fasst Bauart, Teilnehmerzahl, Dauer, Verblindung, Kontrolle, Interessenlage und Übertragbarkeit zusammen; ein Klick darauf zeigt jeden Summanden.")}
{b.aussagen} {BB.t("Aussagen")} · {b.belege} {BB.t("Belege")}
{Object.entries(b.je_stufe || {}).filter(([, n]) => n)
.map(([wort, n]) => " · " + n + " " + BB.t(wort))}
{b.ohne_beleg ? " · " + b.ohne_beleg + " " + BB.t("ohne Beleg") : ""}
{BB.t("Güte")}
{BB.t("Studie")}
{BB.t("Aussage")}
{BB.t("Merkmale")}
{sichtbar.map((z, i) => (
setOffen(offen === String(i) ? "" : String(i))} />
))}
{zeilen.length > sichtbar.length && (
setAlle(true)}>
{BB.t("alle Belege zeigen")} ({zeilen.length})
)}
);
}
function PostPage({ slug, categories, onReloadCategories }) {
const { go } = useRouter();
const { user } = useAuth();
const notify = useToast();
// Der Server hat den Beitrag beim ersten Aufruf schon mitgeschickt
const [post, setPost] = useState(() => BB.takePost(slug));
const [missing, setMissing] = useState(false);
const [draftBlocked, setDraftBlocked] = useState(false);
const [index, setIndex] = useState([]);
// only curated entries get cross-links, so only they pay for the index
useEffect(() => {
if (post && post.kind === "wiki") {
let alive = true;
BB.loadIndex().then((i) => { if (alive) setIndex(i); });
return () => { alive = false; };
}
}, [post && post.kind]);
useEffect(() => {
let alive = true;
// Liegt der Beitrag schon vorgerendert vor, wäre ein zweiter Aufruf nur ein
// Tausch gegen dasselbe, mit Ladeanzeige dazwischen. Der Server hat ihn in
// dem Fall auch bereits als Aufruf gezählt.
const needsAdmin = post && (
post.status !== "published" ||
post.needsReview ||
(Object.prototype.hasOwnProperty.call(post, "review") && post.review !== "ok")
);
if (post && post.slug === slug && (!needsAdmin || BB.isAdmin(user))) return;
setPost(null);
setMissing(false);
setDraftBlocked(false);
BB.get("/posts/" + encodeURIComponent(slug))
.then((p) => {
if (!alive) return;
setPost(p);
// opening marks it read on the server, so the progress counters moved
if (p.read && onReloadCategories) onReloadCategories();
})
.catch((err) => {
if (!alive) return;
if (err && err.status === 403) {
setDraftBlocked(true);
setMissing(false);
} else {
setMissing(true);
setDraftBlocked(false);
}
});
return () => { alive = false; };
}, [slug, onReloadCategories, user && user.id, user && user.role]);
useEffect(() => {
if (post) BB.setTitle(post.title, { desc: post.summary });
else if (draftBlocked) BB.setTitle(BB.t("Noch in Arbeit"));
else if (missing) BB.setTitle(BB.t("Beitrag nicht gefunden"));
}, [post, draftBlocked, missing]);
async function toggleRead() {
try {
const r = await BB.post(`/posts/${post.id}/read`);
setPost((p) => ({ ...p, read: r.read }));
notify(r.read ? "Als gelesen markiert" : "Als ungelesen markiert");
if (onReloadCategories) onReloadCategories();
} catch (err) {
notify(err.message, "bad");
}
}
async function fruehzugang(tage) {
try {
const r = await BB.post(`/posts/${post.id}/fruehzugang`, { tage });
setPost((p) => ({ ...p, earlyUntil: r.earlyUntil }));
} catch (e) { /* steht nur der Redaktion offen */ }
}
async function anpinnen() {
try {
const r = await BB.post(`/posts/${post.id}/anpinnen`);
setPost((p) => ({ ...p, pinned: r.pinned }));
} catch (e) { /* Der Knopf steht nur der Redaktion offen. */ }
}
async function vote() {
if (!user) { go("/login"); return; }
try {
const r = await BB.post(`/posts/${post.id}/vote`);
setPost((p) => ({ ...p, votes: r.votes, voted: r.voted }));
} catch (err) {
notify(err.message, "bad");
}
}
async function remove() {
if (!confirm(`"${post.title}" wirklich löschen?`)) return;
try {
const bestaetigung = BB.isWiki(post) ? BB.qs({ confirm: post.slug }) : "";
await BB.del("/posts/" + post.id + bestaetigung);
notify("Beitrag gelöscht");
go(BB.isWiki(post) ? "/wissen" : "/forum");
} catch (err) {
notify(err.message, "bad");
}
}
if (missing) {
return (
{BB.t("Zur Startseite")}}>
{BB.t("Der Link zeigt ins Leere. Vielleicht wurde der Beitrag gelöscht oder umbenannt.")}
);
}
if (draftBlocked) {
return (
{BB.t("Zur Wissensbasis")}}>
{BB.t("Dieser Artikel ist noch in Arbeit und noch nicht freigegeben.")}
);
}
if (!post) return
;
const cat = BB.catById(categories, post.categoryId);
const isWiki = BB.isWiki(post);
const d = post.data || {};
return (
{isWiki ? BB.t("Wissensbasis") : BB.t("Forum")}
{cat && (
<>
/
{/* Der Kategorienbaum gehört zu /wissen. Ein Forumsbeitrag zeigt
seine Kategorie deshalb an, verlinkt sie aber nicht: dort
stünde er nicht in der Liste. */}
{isWiki
? {cat.label}
: {cat.label} }
>
)}
{post.title}
{post.summary && {post.summary}
}
{post.cover && (
{ e.target.style.display = "none"; }}
/>
)}
{user && (
{post.read ? "✓ " + BB.t("gelesen") : BB.t("als gelesen markieren")}
)}
{/* Nur im Forum. Ein Wissensartikel wird nicht gewählt, er wird
belegt: die Evidenzmatrix am Ende sagt, was er trägt. */}
{/* Frühzugang: neue Forschungssubstanzen lesen Premium zuerst.
Das Datum läuft von selbst ab, eine Freigabe von Hand wird
vergessen. */}
{isWiki && user && (user.role === "admin" || user.role === "mod") && (
fruehzugang(post.earlyUntil ? 0 : 14)}
title={BB.t("Neue Artikel zuerst für Premium")}>
{post.earlyUntil
? BB.t("Frühzugang bis") + " " + post.earlyUntil
: BB.t("Frühzugang 14 Tage")}
)}
{!isWiki && user && (user.role === "admin" || user.role === "mod") && (
📌 {post.pinned ? BB.t("angepinnt") : BB.t("anpinnen")}
)}
{!isWiki && (
▲ {post.votes || 0}
)}
{post.tags.length > 0 && }
{post.kind === "tierlist" && (
)}
{isWiki && Array.isArray(d.refs) && d.refs.length > 0 && (
)}
{isWiki &&
}
{/* Transparenzhinweis nach Artikel 50 der EU-Verordnung 2024/1689.
Nur die Wissensbasis: Forumsbeiträge schreiben Nutzer selbst. */}
{isWiki && (
{BB.t("Dieser Artikel wurde unter Einsatz von KI-Systemen erstellt.")}{" "}
{BB.t("Mehr dazu")}
)}
{post.canReview && (
setPost((p) => ({ ...p, ...r }))}
/>
)}
{post.canEdit && (
{BB.t("Bearbeiten")}
{BB.t("Löschen")}
)}
);
}
Object.assign(window, { PostPage, Comments, ReviewBox });