/* ==========================================================================
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)} />
set("experience", e.target.value)}>
bitte wählen
Erster Zyklus
Fortgeschritten
Erfahren
set("compounds", compounds.concat({ name: "", dose: "", freq: "" }))}>
+ Wirkstoff
>
);
}
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)} />
set("natural", e.target.checked)} />
Natural (ohne unterstützende Substanzen)
>
);
}
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" />
set("difficulty", e.target.value)}>
bitte wählen
Einfach
Mittel
Aufwendig
{[["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}
/>
)
)}
set("ingredients", ingredients.concat({ amount: "", name: "" }))}>
+ Zutat
>
);
}
/* --- 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." />
set("refs", refs.filter((_, j) => j !== i))}>✕
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" />
))}
set("refs", refs.concat({ n: refs.length + 1, text: "" }))}>
+ Quelle
>
);
}
/* --- 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
Text links oben
{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 })}
/>
patchDraft(figure, { labels: draft.labels.filter((_, i) => i !== index) })}
>
✕
))}
patchDraft(figure, { labels: draft.labels.concat({ text: "", left: 50, top: 50, frei: false }) })}
>
+ Beschriftung
replace(figure)}>
{figure.kind === "todo" ? "Bild einsetzen" : "Bild und Beschriftungen übernehmen"}
)}
);
})}
);
}
/* --- 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 && (
{/* Wissensartikel werden ausschließlich vom Verwaltungstab aus angelegt. */}
{(editorialMode ? ["wiki"].concat(BB.USER_KINDS) : BB.USER_KINDS).map((k) => (
setForm((f) => ({
...f,
kind: k,
// ein Wissensartikel steht immer in der Wissensbasis
collection: k === "wiki" ? "wiki" : f.collection,
// beim Wechsel auf eine Tierlist die leeren Reihen vorbereiten
data: k === "tierlist" && !Array.isArray(f.data.tiers) ? emptyTierlist() : f.data,
}))}
>
{BB.KINDS[k].icon}
{BB.KINDS[k].label}
{BB.KINDS[k].blurb}
))}
)}