// Documentation shell — DocsPage, sidebar, TOC. // // Content is ALWAYS pulled from the database via /docs/api.php — the same // endpoint (and help-* tables) that power the public /docs/ site. There is no // static fallback: if the API is unreachable the page shows an error state. const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React; // --------------------------------------------------------------------------- // Data source // ?action=tree → whole sidebar (categories → subcategories) // ?action=page&cat=&sub= → one subcategory's articles (HTMLPurifier-sanitised) // --------------------------------------------------------------------------- const DOCS_API = (typeof window !== "undefined" && window.DOCS_API_BASE) || "docs/api.php"; function useDocsTree() { const [state, setState] = useStateD({ status: "loading", tree: null }); useEffectD(() => { let alive = true; fetch(DOCS_API + "?action=tree", { headers: { Accept: "application/json" } }) .then(r => (r.ok ? r.json() : Promise.reject(r.status))) .then(j => { if (!alive) return; if (j && j.ok && j.data && Array.isArray(j.data.categories) && j.data.categories.length) { setState({ status: "ready", tree: j.data.categories }); } else { setState({ status: "error", tree: null }); } }) .catch(() => { if (alive) setState({ status: "error", tree: null }); }); return () => { alive = false; }; }, []); return state; } // Build the sidebar nav shape ({section, items:[{id,label}]}) from the DB tree. // item id is "/". function buildNavFromTree(tree) { return tree.map(c => ({ section: c.name, items: c.subcategories.map(s => ({ id: c.urlpath + "/" + s.urlpath, label: s.name })), })); } // Fetches + renders one subcategory "page" (its articles) from the API. function DbArticle({ slug, onReady }) { const [state, setState] = useStateD({ status: "loading", data: null }); const ref = useRefD(null); useEffectD(() => { let alive = true; setState({ status: "loading", data: null }); const [cat, sub] = slug.split("/"); fetch(DOCS_API + "?action=page&cat=" + encodeURIComponent(cat) + "&sub=" + encodeURIComponent(sub)) .then(r => (r.ok ? r.json() : Promise.reject(r.status))) .then(j => { if (!alive) return; if (j && j.ok) setState({ status: "ok", data: j.data }); else setState({ status: "error", data: null }); }) .catch(() => { if (alive) setState({ status: "error", data: null }); }); return () => { alive = false; }; }, [slug]); // After content renders, give every heading a stable id so the TOC + deep // links work regardless of whether the stored HTML included ids. useEffectD(() => { if (state.status !== "ok" || !ref.current) return; const used = {}; ref.current.querySelectorAll("h2, h3").forEach(h => { let base = (h.textContent || "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "section"; let id = base; let n = 2; while (used[id]) { id = base + "-" + n++; } used[id] = true; h.id = id; }); onReady && onReady(); }, [state.status, slug]); if (state.status === "loading") { return (
); } if (state.status === "error") { return ( <>

Article unavailable

We couldn't load this article from the documentation database. Please try again shortly.

); } const d = state.data; const arts = d.articles || []; return (

{d.subcategory.name}

{d.subcategory.description ?

{d.subcategory.description}

: null} {arts.length === 0 &&

This section is being written — check back soon.

} {arts.map((a, i) => (
{arts.length > 1 &&

{a.name}

}
))}
); } // Right-side TOC — extracts h2/h3 headings from current content function DocsTOC({ pageId, signal }) { const [active, setActive] = useStateD(""); const [items, setItems] = useStateD([]); useEffectD(() => { const grab = () => { const root = document.querySelector(".docs-content"); if (!root) return; const hs = [...root.querySelectorAll("h2[id], h3[id]")]; setItems(hs.map(h => ({ id: h.id, label: h.textContent, level: h.tagName === "H2" ? 2 : 3 }))); }; const t = setTimeout(grab, 0); return () => clearTimeout(t); }, [pageId, signal]); useEffectD(() => { const onScroll = () => { const root = document.querySelector(".docs-content"); if (!root) return; const hs = [...root.querySelectorAll("h2[id], h3[id]")]; let current = ""; for (const h of hs) { if (h.getBoundingClientRect().top < 120) current = h.id; else break; } setActive(current); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [pageId, items, signal]); if (!items.length) return
; return (
On this page
{items.map(it => ( { e.preventDefault(); const target = document.getElementById(it.id); if (target) { const top = target.getBoundingClientRect().top + window.scrollY - 80; window.scrollTo({ top, behavior: "smooth" }); } }} >{it.label} ))}
); } function DocsSidebar({ nav, active, onPick, query, onQuery }) { // Filter sections by query const q = (query || "").toLowerCase().trim(); const filtered = !q ? nav : nav .map(sec => ({ ...sec, items: sec.items.filter(it => it.label.toLowerCase().includes(q) || sec.section.toLowerCase().includes(q)) })) .filter(sec => sec.items.length); return ( ); } // Full-page shell used for the loading + error states (keeps layout stable). function DocsStateShell({ children }) { return (
{children}
); } function DocsPage({ pageId, onNavigate }) { const [query, setQuery] = useStateD(""); const [tocSignal, setTocSignal] = useStateD(0); const { status, tree } = useDocsTree(); // While the tree request is in flight, show a light shell (no content flash). if (status === "loading") { return (
); } // No static fallback — if the DB/API can't be reached, say so. if (status === "error") { return (

Documentation unavailable

We couldn't reach the documentation service right now. Please try again shortly.

); } const nav = buildNavFromTree(tree); const order = nav.flatMap(s => s.items.map(i => i.id)); const first = order[0]; const valid = order.includes(pageId) ? pageId : first; const idx = order.indexOf(valid); const prev = idx > 0 ? order[idx - 1] : null; const next = idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null; const labelOf = (id) => nav.flatMap(s => s.items).find(i => i.id === id)?.label || id; const sectionOf = (id) => nav.find(s => s.items.some(i => i.id === id))?.section || ""; const go = (id) => { window.location.hash = "/docs/" + id; window.scrollTo({ top: 0, behavior: "instant" }); }; return (
); } Object.assign(window, { DocsPage });