/** * Topics browse page (R008). * * Two-level hierarchy: 6 top-level categories with expandable/collapsible * sub-topics. Each sub-topic shows technique_count and creator_count. * Filter input narrows categories and sub-topics. * Click sub-topic → search results filtered to that topic. */ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { fetchTopics, type TopicCategory } from "../api/public-client"; export default function TopicsBrowse() { 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); // All expanded by default setExpanded(new Set(data.map((c) => c.name))); } } 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) => (
{expanded.has(cat.name) && (
{cat.sub_topics.map((st) => ( {st.name} {st.technique_count} technique{st.technique_count !== 1 ? "s" : ""} · {st.creator_count} creator{st.creator_count !== 1 ? "s" : ""} ))}
)}
))}
)}
); }