/* ================================================================== Admin → Topics ------------------------------------------------------------------ Curate the topic catalog teachers pick from when authoring a test. Topics are nested under chapters: pick class → subject → chapter on the left, then manage that chapter's topic list on the right. No FK from tests → topics today (tests store the topic name as TEXT), so deletes are unconditional. IIFE wrap — see the matching note in screen-admin-chapters.jsx. babel-standalone transpiles `const` to `var` in the global scope, so without this wrapper helpers like `ErrBanner` collide with the identically-named locals in chapters + taxonomy. ================================================================== */ (() => { const { Icon: TIcon } = window.AppUI; const TopicsScreen = () => { const [subjects, setSubjects] = React.useState([]); const [subjectsErr, setSubjectsErr] = React.useState(null); const [classLevels, setClassLevels] = React.useState([]); const [classErr, setClassErr] = React.useState(null); // Three-step picker: class → subject → chapter. Each narrows the next. const [pickedClass, setPickedClass] = React.useState(""); const [pickedSubjectId, setPickedSubjectId] = React.useState(""); const [chapters, setChapters] = React.useState([]); const [chaptersErr, setChaptersErr] = React.useState(null); const [chaptersLoading, setChaptersLoading] = React.useState(false); const [pickedChapterId, setPickedChapterId] = React.useState(""); // Bootstrap — subjects + distinct class levels. React.useEffect(() => { setSubjectsErr(null); window.AppApi.get("/admin/subjects").then(setSubjects) .catch((e) => setSubjectsErr(String(e.message || e))); }, []); React.useEffect(() => { setClassErr(null); window.AppApi.get("/admin/class-sections") .then((sections) => { const uniq = Array.from(new Set((sections || []).map((s) => s.class_level).filter(Boolean))).sort(); setClassLevels(uniq); }) .catch((e) => setClassErr(String(e.message || e))); }, []); // Whenever the (class × subject) pair becomes resolvable, fetch the chapter // list it contains. Clears the previously-picked chapter if it no longer // appears in the new list so the right panel doesn't show stale topics. React.useEffect(() => { if (!pickedClass || !pickedSubjectId) { setChapters([]); setPickedChapterId(""); return; } setChaptersLoading(true); setChaptersErr(null); const q = new URLSearchParams({ subject_id: pickedSubjectId, class_level: pickedClass }); window.AppApi.get(`/admin/chapters?${q.toString()}`) .then((rows) => { setChapters(rows); if (pickedChapterId && !rows.some((r) => r.id === pickedChapterId)) setPickedChapterId(""); }) .catch((e) => setChaptersErr(String(e.message || e))) .finally(() => setChaptersLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pickedClass, pickedSubjectId]); return (
Admin · Catalog

Topics

Topics are the finer-grained tag under each chapter — what a teacher reaches for when "Chapter 4: Stoichiometry" is too broad. Pick a class, subject and chapter on the left to manage that chapter's topics on the right.

c.id === pickedChapterId) || null}/>
); }; const ErrBanner = ({ err, onClear }) => err ? (
{err} {onClear && }
) : null; const PickerPanel = ({ classLevels, classErr, subjects, subjectsErr, chapters, chaptersErr, chaptersLoading, pickedClass, onPickClass, pickedSubjectId, onPickSubject, pickedChapterId, onPickChapter, }) => (
Pick a chapter
); const TopicsPanel = ({ chapter }) => { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(null); const [actionErr, setActionErr] = React.useState(null); const [adding, setAdding] = React.useState(false); const [editingId, setEditingId] = React.useState(null); const refresh = React.useCallback(async () => { if (!chapter) { setItems([]); return; } setLoading(true); setErr(null); try { setItems(await window.AppApi.get(`/admin/topics?chapter_id=${encodeURIComponent(chapter.id)}`)); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [chapter]); React.useEffect(() => { refresh(); }, [refresh]); const create = async (draft) => { setActionErr(null); try { await window.AppApi.post("/admin/topics", { chapter_id: chapter.id, ...draft }); setAdding(false); await refresh(); } catch (e) { setActionErr(String(e.message || e)); } }; const update = async (id, draft) => { setActionErr(null); try { await window.AppApi.patch(`/admin/topics/${id}`, draft); setEditingId(null); await refresh(); } catch (e) { setActionErr(String(e.message || e)); } }; const remove = async (row) => { if (!window.confirm(`Delete topic "${row.topic_number}. ${row.topic_name}"?`)) return; setActionErr(null); try { await window.AppApi.del(`/admin/topics/${row.id}`); await refresh(); } catch (e) { setActionErr(String(e.message || e)); } }; if (!chapter) { return (
Topics
Pick a chapter on the left to manage its topics.
); } return (
Topics · Ch {chapter.chapter_number}. {chapter.chapter_name} {items.length}
setActionErr(null)}/> {adding && ( setAdding(false)} onSave={create} suggestedNumber={(items[items.length - 1]?.topic_number ?? 0) + 1}/> )} {loading &&
Loading…
} {!loading && items.length === 0 && !adding && (
No topics under this chapter yet.
)} {items.map((row) => editingId === row.id ? setEditingId(null)} onSave={(d) => update(row.id, d)}/> : setEditingId(row.id)} onDelete={() => remove(row)}/> )}
); }; const TopicRow = ({ row, onEdit, onDelete }) => (
{row.topic_number}.
{row.topic_name}
); const TopicEditor = ({ initial, suggestedNumber, onCancel, onSave }) => { const [topicNumber, setTopicNumber] = React.useState( String(initial?.topic_number ?? suggestedNumber ?? 1), ); const [topicName, setTopicName] = React.useState(initial?.topic_name || ""); const [busy, setBusy] = React.useState(false); const submit = async () => { const n = parseInt(topicNumber, 10); if (!Number.isFinite(n) || n < 1) return; if (!topicName.trim()) return; setBusy(true); try { await onSave({ topic_number: n, topic_name: topicName.trim() }); } finally { setBusy(false); } }; return (
setTopicNumber(e.target.value)} style={{ width: "100%" }}/>
setTopicName(e.target.value)} placeholder="e.g. Mole concept" style={{ width: "100%" }}/>
); }; window.TopicsScreen = TopicsScreen; })();