/* ========================================================================== ADMIN: Inhalte, Nutzer, Kategorien und Tags. Nutzerkonten nur für Admins. ========================================================================== */ const BLANK_CAT = { label: "", parentId: "", sort: 0, isGroup: false, isSubst: false, tag: "", schema: "", blurb: "", image: "", }; function catPath(cats, id) { return BB.ancestors(cats, id).map((a) => a.label).concat( (BB.catById(cats, id) || {}).label || "" ).join(" → "); } function topCatId(cats, id) { const byId = Object.fromEntries(cats.map((cat) => [cat.id, cat])); let current = id; const seen = new Set(); while (current && byId[current] && !seen.has(current)) { seen.add(current); if (!byId[current].parentId) return current; current = byId[current].parentId; } return id || ""; } /** Flat, indented picker over the whole tree. */ function CatSelect({ cats, value, onChange, exclude = [], allowEmpty = "keine (oberste Ebene)" }) { const options = useMemo(() => { const out = []; const walk = (parentId, depth) => { BB.children(cats, parentId).forEach((c) => { if (!exclude.includes(c.id)) { out.push({ id: c.id, label: " ".repeat(depth * 3) + c.label }); walk(c.id, depth + 1); } }); }; walk(null, 0); return out; }, [cats, exclude.join(",")]); return ( ); } /* --- Kategorien ---------------------------------------------------------- */ function CategoryRow({ cat, cats, depth, selected, onSelect }) { const kids = BB.children(cats, cat.id); return ( <> {kids.map((k) => ( ))} ); } function CategoryEditor({ cats, onReload }) { const notify = useToast(); const [selected, setSelected] = useState(null); const [form, setForm] = useState(BLANK_CAT); const [creating, setCreating] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [reassign, setReassign] = useState(""); const current = selected ? BB.catById(cats, selected) : null; useEffect(() => { if (!current) return; setForm({ label: current.label, parentId: current.parentId || "", sort: current.sort, isGroup: current.isGroup, isSubst: current.isSubst, tag: current.tag || "", schema: current.schema || "", blurb: current.blurb || "", image: current.image || "", }); setCreating(false); setError(""); setReassign(""); }, [selected, current && current.label]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); function startCreate() { setSelected(null); setCreating(true); setForm({ ...BLANK_CAT, parentId: "" }); setError(""); } async function save(e) { e.preventDefault(); setBusy(true); setError(""); const payload = { ...form, sort: Number(form.sort) || 0, parentId: form.parentId || null }; try { if (creating) { const r = await BB.post("/admin/categories", payload); notify(`Kategorie „${form.label}“ angelegt`); await onReload(); setCreating(false); setSelected(r.id); } else { await BB.patch("/admin/categories/" + selected, payload); notify("Kategorie gespeichert"); await onReload(); } } catch (err) { setError(err.message); } finally { setBusy(false); } } async function remove() { if (!current) return; const hasContent = current.count > 0 || BB.children(cats, current.id).length > 0; if (hasContent && !reassign) { setError("Diese Kategorie ist nicht leer. Wähle unten ein Ziel zum Verschieben."); return; } if (!confirm(`„${current.label}“ wirklich löschen?`)) return; setBusy(true); setError(""); try { await BB.del("/admin/categories/" + current.id + (reassign ? "?reassign=" + encodeURIComponent(reassign) : "")); notify(`„${current.label}“ gelöscht`); setSelected(null); await onReload(); } catch (err) { setError(err.message); } finally { setBusy(false); } } const editing = creating || !!current; return (
{!editing ? ( Wähle links eine Kategorie zum Bearbeiten oder lege eine neue an. ) : (

{creating ? "Neue Kategorie" : current.label}

{!creating && {current.id}}
set("label", e.target.value)} maxLength={80} required />
set("parentId", v)} exclude={creating ? [] : [selected]} /> set("sort", e.target.value)} />
set("blurb", e.target.value)} maxLength={300} /> set("image", e.target.value)} placeholder="https://images.unsplash.com/…" />
set("tag", e.target.value)} maxLength={30} />
{error &&
⚠ {error}
}
{!creating && (
Löschen {(current.count > 0 || BB.children(cats, current.id).length > 0) && ( )}
)}
)}
); } /* --- Tags ---------------------------------------------------------------- */ function TagEditor() { const notify = useToast(); const [tags, setTags] = useState(null); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [filter, setFilter] = useState(""); const load = useCallback(() => { BB.get("/tags?limit=500").then(setTags).catch(() => setTags([])); }, []); useEffect(() => { load(); }, [load]); async function rename(tag) { if (!draft.trim() || draft.trim() === tag) { setEditing(null); return; } setBusy(true); try { const r = await BB.post("/admin/tags/rename", { old: tag, new: draft }); notify(r.merged ? `Zusammengeführt, ${r.merged} Doppelung(en) entfernt` : "Tag umbenannt"); setEditing(null); load(); } catch (err) { notify(err.message, "bad"); } finally { setBusy(false); } } async function remove(tag, count) { if (!confirm(`Tag „${tag}“ von ${count} Beiträgen entfernen?`)) return; setBusy(true); try { const r = await BB.del("/admin/tags/" + encodeURIComponent(tag)); notify(`„${tag}“ von ${r.removedFrom} Beiträgen entfernt`); load(); } catch (err) { notify(err.message, "bad"); } finally { setBusy(false); } } if (tags === null) return ; const shown = filter ? tags.filter((t) => t.tag.includes(filter.toLowerCase())) : tags; return (
{tags.length} Tags
e.preventDefault()}> ⌕ setFilter(e.target.value)} placeholder="Tag suchen …" aria-label="Tags filtern" />

Beim Umbenennen auf einen bereits vorhandenen Namen werden beide Tags zusammengeführt.

{shown.map((t) => (
{editing === t.tag ? ( setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); rename(t.tag); } if (e.key === "Escape") setEditing(null); }} style={{ maxWidth: 280 }} /> ) : (
{t.tag} {t.count} Beiträge
)}
{editing === t.tag ? ( <> ) : ( <> )}
))} {shown.length === 0 && }
); } /* --- Inhalte: schreiben und markieren ------------------------------------ */ const PAGE_ADMIN = 60; const REVIEW_FILTERS = [ { value: "", label: "Alle" }, { value: "revise", label: "Überarbeiten" }, { value: "none", label: "Ungeprüft" }, { value: "ok", label: "Aktuell" }, ]; const LUNA_PROVIDERS = { codex: "Codex mit ChatGPT-Anmeldung", api: "OpenAI API-Guthaben", }; const LUNA_WORKFLOWS = { luna: { label: "Luna: Allgemeiner Wissensartikel", hint: "Flexible Erklärung nach dem aktuellen Luna-Standard.", }, scientific: { label: "Wissenschaftlicher Autorenworkflow", hint: "docs/ARTIKEL.md mit PubMed-Evidenzpaket, Quellenprüfung und Audit.", }, /* Fasst den Artikel nicht an: liest die zitierten Arbeiten und trägt ihre Merkmale in die Evidenzmatrix nach. Der Text, die Prüfmarkierung und db_articles.json bleiben unberührt. */ evidenz: { label: "Evidenzmatrix nacharbeiten", hint: "Liest die zitierten Arbeiten und trägt ihre Merkmale nach. Der Artikeltext bleibt unberührt.", }, }; const LUNA_STATUSES = { queued: "Wartet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", }; const LUNA_PHASES = { queued: "Eingereiht", preparing: "Vorbereitung", research: "Recherche", "source-check": "Quellenprüfung", "source-selection": "Quellenauswahl", evidence: "Evidenzmatrix", draft: "Entwurf", audit: "Audit", revision: "Überarbeitung", validation: "Schlussprüfung", repair: "Formale Korrektur", apply: "Übernahme", completed: "Abgeschlossen", failed: "Fehlgeschlagen", cancelled: "Abgebrochen", }; /** Eine Zeile der Inhaltsübersicht: Markierung setzen, Notiz schreiben. */ function ContentRow({ item, cats, onMark, allowLuna = false, activeJob = null, defaultProvider = "codex", apiAvailable = false, onQueued }) { const notify = useToast(); const [editing, setEditing] = useState(false); const [handoff, setHandoff] = useState(false); const [note, setNote] = useState(item.reviewNote || ""); const [instruction, setInstruction] = useState(item.reviewNote || ""); const [provider, setProvider] = useState(defaultProvider); const [workflow, setWorkflow] = useState("luna"); const [busy, setBusy] = useState(false); const cat = BB.catById(cats, item.categoryId); const sensitiveRoot = ["stoffe", "kontext", "gesundheit"].includes( topCatId(cats, item.categoryId) ); const isWiki = BB.collectionOf(item) === "wiki"; useEffect(() => { setNote(item.reviewNote || ""); }, [item.reviewNote]); useEffect(() => { if (!handoff) setInstruction(item.reviewNote || ""); }, [item.reviewNote, handoff]); useEffect(() => { if (!handoff) setProvider( defaultProvider === "api" && !apiAvailable ? "codex" : (defaultProvider || "codex") ); }, [defaultProvider, apiAvailable, handoff]); async function mark(next, keepNote) { setBusy(true); try { const r = await BB.post("/admin/posts/" + item.id + "/review", { review: next, note: keepNote === undefined ? note : keepNote, }); onMark(item.id, r); setEditing(false); } catch (err) { notify(err.message, "bad"); } finally { setBusy(false); } } async function enqueue() { setBusy(true); try { const job = await BB.post("/admin/luna-queue", { postId: item.id, provider, workflow, instruction: instruction.trim(), }); if (onQueued) onQueued(job); setHandoff(false); notify(`„${item.title}“ ist in der Luna-Queue`); } catch (err) { notify(err.message, "bad"); } finally { setBusy(false); } } const href = BB.postHref(item); return (
{item.title} {item.status === "draft" && Entwurf} {item.figTodo && ◫ Bild fehlt} {item.graphicTodo && ⌁ Grafik fehlt}
{(BB.COLLECTIONS[BB.collectionOf(item)] || BB.COLLECTIONS.forum).label} ·{" "} {cat ? cat.label : "ohne Kategorie"} · {item.author} {item.reviewBy ? " · markiert von " + item.reviewBy : ""} {item.reviewAt ? " · " + BB.when(item.reviewAt) : ""}
{!editing && item.reviewNote &&

{item.reviewNote}

} {editing && (