/** * Topics browse page (R008). * * Responsive card grid layout with 7 top-level categories. * Each card shows: category name, description, summary stats * (sub-topic count + total technique count), and an expand/collapse * toggle revealing sub-topics as links to search. * * Filter input narrows visible cards by category name + sub-topic names. */ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { fetchTopics, type TopicCategory } from "../api"; import { CATEGORY_ICON } from "../components/CategoryIcons"; import { catSlug } from "../utils/catSlug"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; export default function TopicsBrowse() { useDocumentTitle("Topics — Chrysopedia"); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [filter, setFilter] = useState(""); useEffect(() => { let cancelled = false; setLoading(true); setError(null); void (async () => { try { const data = await fetchTopics(); if (!cancelled) { setCategories(data.items); // Start collapsed setExpanded(new Set()); } } catch (err) { if (!cancelled) { setError( err instanceof Error ? err.message : "Failed to load topics", ); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, []); function toggleCategory(name: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); } // Apply filter: show categories whose name or sub-topics match const lowerFilter = filter.toLowerCase(); const filtered = filter ? (categories .map((cat) => { const catMatches = cat.name.toLowerCase().includes(lowerFilter); const matchingSubs = cat.sub_topics.filter((st) => st.name.toLowerCase().includes(lowerFilter), ); if (catMatches) return cat; // show full category if (matchingSubs.length > 0) { return { ...cat, sub_topics: matchingSubs }; } return null; }) .filter(Boolean) as TopicCategory[]) : categories; if (loading) { return
Loading topics…
; } if (error) { return
Error: {error}
; } return (

Topics

Browse techniques organized by category and sub-topic

{/* Filter */} setFilter(e.target.value)} aria-label="Filter topics" /> {filtered.length === 0 ? (
No topics matching “{filter}”
) : (
{filtered.map((cat, i) => { const slug = catSlug(cat.name); const isExpanded = expanded.has(cat.name); const totalTechniques = cat.sub_topics.reduce( (sum, st) => sum + st.technique_count, 0, ); return (

{(() => { const Ic = CATEGORY_ICON[cat.name] ?? CATEGORY_ICON["Workflow"]; return Ic ? : null; })()} {cat.name}

{cat.description}

{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""} · {totalTechniques} technique{totalTechniques !== 1 ? "s" : ""}
{cat.sub_topics.map((st) => { const stSlug = st.name.toLowerCase().replace(/\s+/g, "-"); if (st.technique_count === 0) { return ( {st.name} Coming soon ); } return ( {st.name} {st.technique_count} technique{st.technique_count !== 1 ? "s" : ""} · {st.creator_count} creator{st.creator_count !== 1 ? "s" : ""} ); })}
); })}
)}
); }