/* ========================================================================== APP: shell, header, global search, routing table, footer. ========================================================================== */ const NAV = [ { to: "/wissen", label: "Wissen" }, { to: "/forum", label: "Forum" }, { to: "/datenbank", label: "Datenbank" }, { to: "/auswertung", label: "Analyse" }, { to: "/molekuelkarte", label: "Molekülkarte" }, ]; /* --- global search ------------------------------------------------------- */ function GlobalSearch({ variant = "header", onNavigate }) { const { go } = useRouter(); const [q, setQ] = useState(""); const [hits, setHits] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const input = useRef(null); const request = useRef(0); useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); input.current && input.current.focus(); } if (e.key === "Escape") setOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); useEffect(() => { const query = q.trim(); const version = ++request.current; if (query.length < 2) { setHits([]); setLoading(false); return; } setLoading(true); const id = setTimeout(() => { BB.get("/posts" + BB.qs({ q: query, limit: 7 })) .then((d) => { if (request.current === version) setHits(d.items); }) .catch(() => { if (request.current === version) setHits([]); }) .finally(() => { if (request.current === version) setLoading(false); }); }, 180); return () => clearTimeout(id); }, [q]); function submit(e) { e.preventDefault(); if (!q.trim()) return; setOpen(false); onNavigate && onNavigate(); go("/suche?q=" + encodeURIComponent(q.trim())); } function navigate(to) { setOpen(false); setQ(""); onNavigate && onNavigate(); go(to); } return (
{ setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 180)} placeholder={BB.t("Suchen …")} aria-label={BB.t("Website durchsuchen")} aria-expanded={open && q.trim().length >= 2} aria-controls={variant + "-search-results"} /> {open && q.trim().length >= 2 && (
{loading ? (
{BB.t("Suche läuft …")}
) : hits.length === 0 ? (
{BB.t("Nichts gefunden")}: {q}
) : ( hits.map((p) => ( { e.preventDefault(); navigate(BB.postHref(p)); }}>
{p.title}
{(BB.KINDS[p.kind] || {}).label || p.kind} {p.author ? " · " + p.author.displayName : ""}
↵ )) )} { e.preventDefault(); navigate("/suche?q=" + encodeURIComponent(q.trim())); }} > {BB.t("Alle Ergebnisse anzeigen")} ↵
)}
); } /* --- Sprachumschalter ----------------------------------------------------- */ const FLAG_ASSETS = { DE: "/static/assets/flags/de.svg", US: "/static/assets/flags/us.svg", GB: "/static/assets/flags/gb.svg", ES: "/static/assets/flags/es.svg", FR: "/static/assets/flags/fr.svg", SE: "/static/assets/flags/se.svg", }; function CountryFlag({ country }) { const code = String(country || "").toUpperCase(); const src = FLAG_ASSETS[code]; return src ? : null; } function AppearanceMenu() { const { locale, setLocale } = useLang(); const { path, go } = useRouter(); const [open, setOpen] = useState(false); const [tab, setTab] = useState("theme"); const [theme, setTheme] = useState(BB.theme); const [accent, setAccent] = useState(BB.accent); const box = useRef(null); const trigger = useRef(null); const currentLocale = BB.LOCALES[locale] || BB.LOCALES[BB.DEFAULT_LOCALE]; useEffect(() => { if (!open) return; const away = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false); }; const esc = (e) => { if (e.key === "Escape") { setOpen(false); trigger.current && trigger.current.focus(); } }; document.addEventListener("pointerdown", away); window.addEventListener("keydown", esc); return () => { document.removeEventListener("pointerdown", away); window.removeEventListener("keydown", esc); }; }, [open]); return (
{open && (
{[['theme', 'Theme'], ['accent', 'Akzent'], ['language', 'Sprache']].map(([id, label]) => ( ))}
{tab === "theme" && [['dark', 'Dunkel'], ['light', 'Hell']].map(([id, label]) => ( ))} {tab === "accent" && [['green', 'Grün'], ['pink', 'Pink']].map(([id, label]) => ( ))} {tab === "language" && Object.entries(BB.LOCALES).map(([code, info]) => ( ))}
)}
); } /* --- header -------------------------------------------------------------- */ function Header() { const { path, go } = useRouter(); const { user, ready, setUser } = useAuth(); const notify = useToast(); const [stuck, setStuck] = useState(false); const [menu, setMenu] = useState(false); const [accountOpen, setAccountOpen] = useState(false); useEffect(() => { const onScroll = () => setStuck(window.scrollY > 12); onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); useEffect(() => { setMenu(false); setAccountOpen(false); }, [path]); useEffect(() => { document.body.style.overflow = menu ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [menu]); useEffect(() => { const close = (event) => { if (!event.target.closest(".account-menu")) setAccountOpen(false); }; const escape = (event) => { if (event.key === "Escape") setAccountOpen(false); }; document.addEventListener("pointerdown", close); document.addEventListener("keydown", escape); return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", escape); }; }, []); const active = (to) => path === to || path.startsWith(to + "?") || path.startsWith(to + "/"); async function logout() { setAccountOpen(false); await BB.post("/auth/logout").catch(() => {}); setUser(null); notify(BB.t("Abgemeldet")); go("/"); } return (
Bodybuilding Bible {BB.t("Wissen & Community")}
{!ready ? null : user ? (
{BB.t("Mein Profil")} {BB.t("Einstellungen")}
) : (
{BB.t("Einloggen")} {BB.t("Profil erstellen")}
)}
{/* **Ausserhalb der Kopfzeile, sonst faellt es nach dem Scrollen zusammen.** `.hdr.stuck` traegt `backdrop-filter`, und ein Filter macht das Element zum Bezugsrahmen fuer `position: fixed` darin -- dieselbe Falle wie beim Vollbild der Molekuelkarte, nur eine Ebene hoeher. Am Seitenanfang stand die Klasse noch nicht, weiter unten schon: das Menue klappte dann auf die 76 Pixel der Leiste zusammen und war unsichtbar. Als Portal an `body` haengt es an keinem Vorfahren mehr. */} {menu && ReactDOM.createPortal((
setMenu(false)} /> {NAV.map((n) => ( {n.label} ))} {user ? ( <> {BB.t("Mein Profil")} {BB.t("Einstellungen")} {(user.role === "admin" || user.role === "mod") && {BB.t("Verwaltung")}} ) : ( <> {BB.t("Einloggen")} {BB.t("Profil erstellen")} )}
), document.body)}
); } /* --- footer -------------------------------------------------------------- */ function Footer({ categories }) { const tops = BB.children(categories, null).filter((c) => !c.forumOnly).slice(0, 6); return ( ); } /* --- noch gesperrte Bereiche --------------------------------------------- */ /* Forum und Registrierung sind waehrend des Aufbaus zu. Die Sperre gilt im Server, hier steht nur die Erklaerung: wer den Bereich aufruft, soll wissen warum er leer ist, statt in einen 403 zu laufen. */ function GesperrtPage({ title, message }) { return (
{BB.t("Zur Wissensbasis")}} > {BB.t(message)}
); } /* --- routing ------------------------------------------------------------- */ function Routes({ categories, stats, onReloadCategories }) { const { path } = useRouter(); const { user } = useAuth(); const isEditor = !!user && (user.role === "admin" || user.role === "mod"); const [pathname, search] = path.split("?"); const params = useMemo(() => new URLSearchParams(search || ""), [search]); const seg = pathname.split("/").filter(Boolean); /* Sammeltitel für jede Route. Ein einzelner Beitrag meldet seinen eigenen, sobald die Daten da sind, und behält dann Vorrang. Wo hier null steht, wartet der Titel genau darauf. */ useEffect(() => { const named = { wissen: seg[1] && seg[1] !== "alle" ? null : BB.t("Wissensbasis"), forum: BB.t("Forum"), datenbank: BB.t("Datenbank"), uebungen: BB.t("Datenbank"), beitrag: null, suche: BB.t("Suchergebnisse"), auswertung: BB.t("Split-Analyse"), molekuelkarte: BB.t("Molekülkarte"), neu: BB.t("Beitrag schreiben"), bearbeiten: BB.t("Bearbeiten"), u: null, login: BB.t("Einloggen"), registrieren: BB.t("Profil erstellen"), danke: BB.t("Danke."), "passwort-neu": BB.t("Neues Passwort setzen"), faq: "FAQ", impressum: "Impressum", agb: "AGB", einstellungen: BB.t("Einstellungen"), verwaltung: ({ inhalte: "Inhalte · Verwaltung", luna: "Luna-Queue · Verwaltung", kategorien: "Kategorien · Verwaltung", tags: "Tags · Verwaltung", matrix: "Matrix · Verwaltung", nutzer: "Nutzer · Verwaltung", wartung: "Wartung · Verwaltung", analytics: "Analytics · Verwaltung", })[seg[1]] || BB.t("Verwaltung"), }; const key = seg[0] || ""; const title = !key ? BB.t("Analysen, Wissen und Molekülkarte") : key in named ? named[key] : BB.t("Diese Seite gibt es nicht"); BB.setTitle(title, { fallback: true }); }, [pathname, search]); if (pathname === "/") return ; if (seg[0] === "wissen") { if (seg[1] === "alle") return ; if (seg[1]) return ; return (params.get("cat") || params.get("q") || params.get("unread")) ? : ; } if (seg[0] === "forum") { if (!BB.FLAGS.forumEnabled) { return ; } return ; } if (seg[0] === "datenbank") return ; // Alte Lesezeichen bleiben erreichbar und landen direkt im passenden // Datenbankbereich. Die öffentliche Navigation zeigt nur noch /datenbank. if (seg[0] === "uebungen") return ; if (seg[0] === "suche") return ; if (seg[0] === "auswertung") return ; if (seg[0] === "molekuelkarte") return ; if (seg[0] === "beitrag" && seg[1]) { if (!BB.FLAGS.forumEnabled) { return ; } return ; } if (seg[0] === "neu") { // Ohne offenes Forum kann nur die Redaktion in die Wissensbasis schreiben. if (!BB.FLAGS.forumEnabled && !isEditor) { return ; } return ; } if (seg[0] === "bearbeiten" && seg[1]) return ; if (seg[0] === "u" && seg[1]) return ; if (seg[0] === "login") return ; if (seg[0] === "registrieren") { if (!BB.FLAGS.registrationEnabled) { return ; } return ; } if (seg[0] === "passwort-neu") return ; if (seg[0] === "danke") return ; if (seg[0] === "faq") return ; if (seg[0] === "impressum") return ; if (seg[0] === "agb") return ; if (seg[0] === "datenschutz") return ; if (seg[0] === "einstellungen") return ; if (seg[0] === "verwaltung") return ; return (
{BB.t("Zur Startseite")}}> {BB.t("Vielleicht hilft die Suche oben weiter.")}
); } /* --- Fehlergrenze --------------------------------------------------------- */ /** React haengt bei einer Ausnahme im Render den gesamten Baum aus. Ohne diese Grenze wird aus einem Fehler in einer Ansicht eine schwarze Seite, und zwar ohne jeden Hinweis darauf, was los ist. Der haeufigste Fall in diesem Aufbau ohne Build-Schritt: eine offene Seite stammt von vor einer Aenderung und kennt eine neu hinzugekommene .jsx-Datei nicht. Dann ist die Komponente schlicht nicht definiert. Das faengt der Boundary ab und sagt genau das. */ class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, info) { console.error("Fehler in der Ansicht:", error, info && info.componentStack); } componentDidUpdate(prev) { // Beim Wechsel der Route neu versuchen, sonst bleibt die Meldung kleben. if (prev.resetKey !== this.props.resetKey && this.state.error) { this.setState({ error: null }); } } render() { const err = this.state.error; if (!err) return this.props.children; const meldung = String(err.message || err); const fehlt = (meldung.match(/^(\w+) is not defined/) || [])[1]; const bausteine = eingebundeneBausteine(); // Fehlt ein Bezeichner, gibt es genau zwei Ursachen, und sie fuehren zu // verschiedenen Schritten. Die Unterscheidung steht hier, damit sie nicht // erst in der Browser-Konsole gesucht werden muss. let hinweis = BB.t("Beim Aufbau ist ein Fehler aufgetreten. Die Einzelheiten stehen in der Konsole des Browsers."); if (fehlt) { const datei = bausteineFuer(fehlt, bausteine); hinweis = datei ? BB.t("Die Datei ist eingebunden, wurde aber nicht ausgeführt. Das ist ein Fehler im Browser, nicht auf dem Server. Bitte die Konsole öffnen, dort steht die Ursache.") : BB.t("Die geöffnete Seite stammt von vor der letzten Änderung. Ein Neuladen mit Strg+F5 behebt das."); } return (
!

{BB.t("Diese Ansicht konnte nicht geladen werden")}

{hinweis}

{meldung}

{BB.t("Eingebunden")}: {bausteine.join(", ") || "-"}

); } } /** Welche .jsx-Dateien stehen als Babel-Skript in der Seite? */ function eingebundeneBausteine() { try { return Array.from(document.querySelectorAll('script[type="text/babel"]')) .map((s) => (s.getAttribute("src") || "inline").split("/").pop()); } catch (e) { return []; } } /** Ist die Datei eingebunden, in der der fehlende Bezeichner stehen müsste? */ function bausteineFuer(name, bausteine) { const datei = name.replace(/Page$/, "").toLowerCase() + ".jsx"; return bausteine.find((b) => b.toLowerCase() === datei) || null; } /** Fades the main region on every route change, cheap continuity between views. */ function PageTransition({ children }) { const { path } = useRouter(); const ref = useRef(null); const key = path.split("?")[0]; useEffect(() => { const el = ref.current; if (!el || BB.reducedMotion() || typeof anime === "undefined") return; anime.remove(el); anime({ targets: el, opacity: [0, 1], translateY: [8, 0], duration: 420, easing: "easeOutQuad" }); }, [key]); return
{children}
; } /* --- root ---------------------------------------------------------------- */ function Shell() { const { path } = useRouter(); const [categories, setCategories] = useState([]); const [stats, setStats] = useState({ wiki: 0, community: 0, members: 0, categories: 0, comments: 0 }); const reloadCategories = useCallback( () => BB.get("/categories").then(setCategories).catch(() => {}), [] ); useEffect(() => { reloadCategories(); BB.get("/stats").then(setStats).catch(() => {}); }, [reloadCategories]); return ( <>