/* ========================================================================== COMPOSE: create/edit a community post. Markdown in, live preview beside it. ========================================================================== */ // format wird vom Editor nur gelesen, nie gesetzt: Neue Beiträge legt der Server // grundsätzlich als Markdown an, die redaktionellen HTML-Artikel behalten beim // Bearbeiten ihr Format. Der Editor muss es trotzdem kennen, sonst zeigt er für // einen HTML-Artikel eine falsche Vorschau. const BLANK = { kind: "article", collection: "forum", title: "", summary: "", body: "", categoryId: "", cover: "", tags: [], data: {}, status: "published", authorUsername: "", format: "md", }; /* --- kind-specific extra fields ------------------------------------------ */ function StackFields({ data, set }) { const compounds = Array.isArray(data.compounds) ? data.compounds : [{ name: "", dose: "", freq: "" }]; const patch = (i, key, value) => { const next = compounds.map((c, j) => (i === j ? { ...c, [key]: value } : c)); set("compounds", next); }; return ( <>
set("goal", e.target.value)} placeholder="z. B. Lean Bulk" /> set("weeks", e.target.value)} />
{compounds.map((c, i) => (
patch(i, "name", e.target.value)} placeholder="Wirkstoff" /> patch(i, "dose", e.target.value)} placeholder="500 mg" /> patch(i, "freq", e.target.value)} placeholder="pro Woche" />
))}
); } function TransformationFields({ data, set }) { return ( <>
set("weightBefore", e.target.value)} /> set("weightAfter", e.target.value)} /> set("bfBefore", e.target.value)} /> set("bfAfter", e.target.value)} /> set("months", e.target.value)} />
set("beforeUrl", v)} /> set("afterUrl", v)} />
); } function RecipeFields({ data, set }) { const ingredients = Array.isArray(data.ingredients) && data.ingredients.length ? data.ingredients : [{ amount: "", name: "" }]; const patch = (i, key, value) => set("ingredients", ingredients.map((c, j) => (i === j ? { ...c, [key]: value } : c))); return ( <>
set("servings", e.target.value)} placeholder="2" /> set("minutes", e.target.value)} placeholder="25" />
{[["kcal", "kcal"], ["protein", "Eiweiß (g)"], ["carbs", "Kohlenhydrate (g)"], ["fat", "Fett (g)"], ["fiber", "Ballaststoffe (g)"]].map( ([key, label]) => ( set(key, e.target.value)} placeholder={label} aria-label={label} /> ) )}
{ingredients.map((c, i) => (
patch(i, "amount", e.target.value)} placeholder="200 g" /> patch(i, "name", e.target.value)} placeholder="Magerquark" />
))}
); } /* --- Wissensartikel ------------------------------------------------------ */ /** Alles, was beim Wissensartikel in der Seitenleiste landet: Kennzahlen, Steckbrief, Strukturformel und Quellen. Diese Felder waren bis hierher nur über db_articles.json zu ändern, im Editor fehlten sie ersatzlos. */ function WikiFields({ data, set, categories, categoryId }) { const schema = BB.schemaFor(categories, categoryId); const bars = data.bars || {}; const aliases = Array.isArray(data.aliases) ? data.aliases : []; const refs = Array.isArray(data.refs) ? data.refs : []; const setBar = (key, raw) => { const next = { ...bars }; if (raw === "") delete next[key]; else next[key] = Number(raw); set("bars", next); }; const patchRef = (i, key, value) => set("refs", refs.map((r, j) => (j === i ? { ...r, [key]: value } : r))); return ( <> {/* Welche Kennzahlen es gibt, hängt am Schema der Kategorie. Ohne Kategorie gibt es kein Schema und damit nichts einzustellen. */} {schema ? (
{schema.map((m) => (
{BB.t(m.label)} {m.inverse && ↓} setBar(m.key, e.target.value)} placeholder={"0 bis " + m.max} aria-label={BB.t(m.label)} /> {m.unit || "/ " + m.max}
))}
{Object.keys(bars).length > 0 && (
So sieht es im Artikel aus
)}
) : ( Für diese Kategorie ist kein Schema hinterlegt. )}
set("formula", e.target.value)} placeholder="C₅H₁₁NO₂" /> set("aliases", e.target.value.split(",").map((s) => s.trim()).filter(Boolean))} placeholder="TMG, Trimethylglycin" />
set("molecule", e.target.value.trim() || null)} placeholder="astaxanthin" /> set("pubchemCid", e.target.value ? Number(e.target.value) : null)} placeholder="PubChem-CID" aria-label="PubChem-CID" />
set("moleculeNote", e.target.value)} placeholder="Begründung, falls keine Struktur: Proteingemisch, Element …" />
{refs.map((r, i) => (
patchRef(i, "n", Number(e.target.value))} aria-label="Nummer" /> patchRef(i, "text", e.target.value)} placeholder="Autor X et al. Titel. Journal. Jahr." />
patchRef(i, "pmid", e.target.value.trim())} placeholder="PMID" /> patchRef(i, "doi", e.target.value.trim())} placeholder="DOI" /> patchRef(i, "url", e.target.value.trim())} placeholder="Adresse" />
))}
); } /* --- Wissensartikel: Bildplatzhalter ------------------------------------ */ /* Die redaktionellen HTML-Artikel tragen die Bildaufträge bewusst im Body. So bleiben Motiv, Zweck und Beschriftung beim Export nach db_articles.json erhalten. Der Editor setzt nur das Bild und die daraus abgeleiteten Frontend-Labels ein; der Auftrag selbst wird nicht weggeworfen. */ const WIKI_FIGURE_RE = /]*>[\s\S]*?<\/figure>/gi; function figureInner(block, tag, className) { const re = new RegExp( `<${tag}\\b[^>]*class="[^"]*\\b${className}\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i", ); const match = block.match(re); return match ? match[1].trim() : ""; } function figureCaption(block) { const match = block.match(/]*>([\s\S]*?)<\/figcaption>/i); return match ? match[1].trim() : ""; } function figurePlain(html) { const holder = document.createElement("div"); holder.innerHTML = html || ""; return (holder.textContent || holder.innerText || "").replace(/\s+/g, " ").trim(); } function figureEsc(value) { return String(value === undefined || value === null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function figurePercent(value, fallback) { if (value === "" || value === null || value === undefined) return fallback; const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(0, Math.min(100, Math.round(number * 10) / 10)); } function figureLabelsFromBrief(brief) { const plain = figurePlain(brief); const match = plain.match(/Beschriftung:\s*(.*?)(?:\s+Format:|$)/i); if (!match) return []; return match[1] .replace(/[„“\"]/g, "") .split(/[,;]/) .map((text) => text.trim().replace(/[.!?]+$/, "")) .filter((text) => text && !/^(am|an|auf|daneben|im|in|jeweils|links|mittig|oben|rechts|unten|keine weiteren wörter)/i.test(text)) .map((text, index) => ({ text, left: 18 + (index % 4) * 22, top: 18 + Math.floor(index / 4) * 18, frei: false, })); } function figureLabelsFromBlock(block) { const spans = block.match(/]*class="[^"]*\bfig-label\b[^"]*"[^>]*>[\s\S]*?<\/span>/gi) || []; return spans.map((span, index) => { const style = (span.match(/\bstyle="([^"]*)"/i) || [])[1] || ""; const inner = (span.match(/]*>([\s\S]*?)<\/span>/i) || [])[1] || ""; const left = (style.match(/left\s*:\s*([\d.]+)%/i) || [])[1]; const top = (style.match(/top\s*:\s*([\d.]+)%/i) || [])[1]; const klasse = (span.match(/class="([^"]*)"/i) || [])[1] || ""; return { text: figurePlain(inner), left: figurePercent(left, 18 + (index % 4) * 22), top: figurePercent(top, 18 + Math.floor(index / 4) * 18), frei: /\b frei\b/i.test(" " + klasse), }; }).filter((label) => label.text); } function wikiFigureInfo(body) { const blocks = String(body || "").match(WIKI_FIGURE_RE) || []; return blocks.reduce((figures, raw, index) => { const opening = (raw.match(/^]*>/i) || [""])[0]; const todo = /class="[^"]*\bfig-todo\b[^"]*"/i.test(opening); const bild = /class="[^"]*\bfig-bild\b[^"]*"/i.test(opening); if (!todo && !bild) return figures; const titleHtml = figureInner(raw, "div", "fig-title"); const briefHtml = figureInner(raw, "p", "fig-brief"); const captionHtml = figureCaption(raw); const image = raw.match(/]*\bsrc="([^"]+)"/i); const labels = bild ? figureLabelsFromBlock(raw) : figureLabelsFromBrief(briefHtml); figures.push({ key: (todo ? "todo" : "bild") + ":" + index + ":" + raw, raw, kind: todo ? "todo" : "bild", titleHtml, title: figurePlain(titleHtml) || "Ohne Titel", briefHtml, brief: figurePlain(briefHtml), captionHtml, src: image ? image[1] : "", labels, }); return figures; }, []); } function wikiFigureHtml(figure, url, labels) { const metadata = figure.briefHtml ? `
Bildbeschreibung

${figure.briefHtml}

\n` : ""; const labelHtml = (labels || []) .filter((label) => label && String(label.text || "").trim()) .map((label) => { const klasse = label.frei ? "fig-label frei" : "fig-label"; const left = figurePercent(label.left, 50); const top = figurePercent(label.top, 50); return `${figureEsc(String(label.text).trim())}`; }) .join("\n"); const caption = figure.captionHtml ? `\n
${figure.captionHtml}
` : ""; return `
\n` + (figure.titleHtml ? `
${figure.titleHtml}
\n` : "") + metadata + `
\n\n` + (labelHtml ? labelHtml + "\n" : "") + `
${caption}\n
`; } function WikiFigureManager({ body, setBody }) { const figures = useMemo(() => wikiFigureInfo(body), [body]); const [drafts, setDrafts] = useState({}); if (!figures.length) return null; const draftFor = (figure) => { const draft = drafts[figure.key] || {}; return { url: draft.url === undefined ? figure.src : draft.url, labels: Array.isArray(draft.labels) ? draft.labels : figure.labels, }; }; const patchDraft = (figure, patch) => { setDrafts((old) => ({ ...old, [figure.key]: { ...(old[figure.key] || {}), ...patch }, })); }; const patchLabel = (figure, index, patch) => { const draft = draftFor(figure); patchDraft(figure, { labels: draft.labels.map((label, i) => (i === index ? { ...label, ...patch } : label)), }); }; const replace = (figure) => { const draft = draftFor(figure); if (!draft.url) return; const next = String(body || "").replace( figure.raw, wikiFigureHtml(figure, draft.url, draft.labels), ); if (next !== body) { setBody(next); setDrafts((old) => { const rest = { ...old }; delete rest[figure.key]; return rest; }); } }; return (
{figures.map((figure) => { const draft = draftFor(figure); return (
{figure.kind === "todo" ? "Bildplatzhalter" : "Vorhandene Abbildung"}

{figure.title}

{figure.labels.length} Beschriftungen
{figure.brief && (
Info aus dem Platzhalter

{figure.brief}

)}
patchDraft(figure, { url })} /> {draft.url && (
{draft.labels.map((label, index) => ( {label.text || "…"} ))}
Beschriftungen · Position in Prozent
Textlinksoben
{draft.labels.map((label, index) => (
patchLabel(figure, index, { text: e.target.value })} /> patchLabel(figure, index, { left: e.target.value })} /> patchLabel(figure, index, { top: e.target.value })} />
))}
)}
); })}
); } /* --- page ---------------------------------------------------------------- */ function Compose({ categories, slug, params }) { const { go } = useRouter(); const { user, ready } = useAuth(); const notify = useToast(); const editing = !!slug; const initialKind = params.get("kind") || "article"; const [form, setForm] = useState(() => ({ ...BLANK, kind: initialKind, // ?in=wiki legt den Beitrag gleich in der Wissensbasis an, ein Wissensartikel // kann ohnehin nirgendwo anders stehen collection: params.get("in") === "wiki" || initialKind === "wiki" ? "wiki" : "forum", // ?as=bible öffnet den Editor direkt im Namen des Redaktionskontos authorUsername: params.get("as") || "", data: initialKind === "tierlist" ? emptyTierlist() : {}, })); const [tagDraft, setTagDraft] = useState(""); const [preview, setPreview] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [postId, setPostId] = useState(null); const [loading, setLoading] = useState(editing); const [authors, setAuthors] = useState([]); const [currentAuthor, setCurrentAuthor] = useState(null); const isEditor = user && (user.role === "admin" || user.role === "mod"); const editorialMode = isEditor && ( params.get("in") === "wiki" || (editing && form.collection === "wiki") ); useEffect(() => { if (ready && !user) go("/login?next=" + encodeURIComponent(window.location.pathname)); }, [ready, user, go]); useEffect(() => { if (!isEditor) return; let alive = true; BB.get("/admin/authors").then((a) => { if (alive) setAuthors(a); }).catch(() => {}); return () => { alive = false; }; }, [isEditor]); useEffect(() => { if (!editing) return; let alive = true; BB.get("/posts/" + encodeURIComponent(slug)) .then((p) => { if (!alive) return; if (!p.canEdit) { notify("Du darfst diesen Beitrag nicht bearbeiten.", "bad"); go(BB.postHref(p)); return; } setPostId(p.id); setCurrentAuthor(p.author); setForm({ kind: p.kind, collection: BB.collectionOf(p), title: p.title, summary: p.summary, body: p.body, categoryId: p.categoryId || "", cover: p.cover, tags: p.tags, data: p.data || {}, status: p.status, authorUsername: "", format: p.format || "md", }); setLoading(false); }) .catch(() => { if (alive) { setError("Beitrag nicht gefunden."); setLoading(false); } }); return () => { alive = false; }; }, [slug, editing, go, notify]); const set = (key, value) => setForm((f) => ({ ...f, [key]: value })); const setData = (key, value) => setForm((f) => ({ ...f, data: { ...f.data, [key]: value } })); function addTag(raw) { const t = raw.trim().toLowerCase(); if (!t || form.tags.includes(t) || form.tags.length >= 12) return; set("tags", form.tags.concat(t)); setTagDraft(""); } async function save(status) { if (!form.title.trim()) { setError("Gib deinem Beitrag einen Titel."); return; } setBusy(true); setError(""); const payload = { ...form, status, categoryId: form.categoryId || null }; try { const res = editing ? await BB.patch("/posts/" + postId, payload) : await BB.post("/posts", payload); notify(status === "draft" ? "Als Entwurf gespeichert" : editing ? "Änderungen gespeichert" : "Beitrag veröffentlicht"); go(BB.postHref({ collection: form.collection, slug: res.slug })); } catch (err) { setError(err.message); setBusy(false); } } // only leaf categories accept posts const options = useMemo( () => categories .filter((c) => !c.isGroup) .map((c) => ({ id: c.id, label: BB.ancestors(categories, c.id).map((a) => a.label).concat(c.label).join(" → ") })) .sort((a, b) => a.label.localeCompare(b.label, "de")), [categories] ); if (!ready || loading) return
; if (!user) return null; return (
{editing ? "Bearbeiten" : "Neuer Beitrag"}

{editing ? form.title || "Beitrag bearbeiten" : "Was willst du teilen?"}

{!editing && (
{/* Wissensartikel werden ausschließlich vom Verwaltungstab aus angelegt. */} {(editorialMode ? ["wiki"].concat(BB.USER_KINDS) : BB.USER_KINDS).map((k) => ( ))}
)}
{ e.preventDefault(); save("published"); }}> set("title", e.target.value)} placeholder="Titel" maxLength={200} aria-label="Titel" /> set("summary", e.target.value)} maxLength={400} placeholder="Worum geht es?" /> {/* Wo der Beitrag steht, ist unabhängig von seiner Art. Nur die Redaktion darf in die Wissensbasis schreiben, deshalb sieht ein Mitglied den Umschalter gar nicht erst. */} {editorialMode && ( {form.kind === "wiki" ? (
{BB.COLLECTIONS.wiki.label}
) : ( set("collection", v)} options={Object.entries(BB.COLLECTIONS).map(([value, c]) => ({ value, label: c.label }))} /> )}
)}
set("cover", v)} />
{isEditor && authors.length > 0 && ( )} {form.kind === "wiki" && ( <> {form.format === "html" && ( set("body", body)} /> )} )} {form.kind === "stack" && } {form.kind === "transformation" && } {form.kind === "recipe" && } {form.kind === "tierlist" && ( set("data", v)} categories={categories} /> )}
{form.tags.map((t) => ( ))}
setTagDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addTag(tagDraft); } if (e.key === "Backspace" && !tagDraft && form.tags.length) set("tags", form.tags.slice(0, -1)); }} placeholder="bulk, cut, anfänger …" />
, Querverweise Text." : "Markdown: # Abschnitt, ## Unterabschnitt, **fett**, - Liste, > Einordnung, [^7] als Beleg. Wissensartikel unterstützen zusätzlich ```figure``` mit geprüftem Inline-SVG."} >
setPreview(v === "preview")} options={[{ value: "write", label: "Schreiben" }, { value: "preview", label: "Vorschau" }]} /> {BB.readingTime(form.body)} Min.
{preview ? ( // Ein redaktioneller Artikel liegt als HTML vor. Ihn durch den // Markdown-Renderer zu schicken zerlegt ihn, deshalb die Fallunterscheidung.
Noch nichts zu sehen.

", }} /> ) : (