chrysopedia/frontend/src/pages/TopicsBrowse.tsx
jlightner dbc4afcf42 feat: Normalized /topics and /videos endpoints from bare lists to pagin…
- "backend/schemas.py"
- "backend/routers/topics.py"
- "backend/routers/videos.py"
- "frontend/src/api/topics.ts"
- "frontend/src/pages/TopicsBrowse.tsx"
- "frontend/src/pages/Home.tsx"

GSD-Task: S05/T03
2026-04-03 23:09:33 +00:00

201 lines
6.9 KiB
TypeScript

/**
* 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<TopicCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(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 <div className="loading">Loading topics</div>;
}
if (error) {
return <div className="loading error-text">Error: {error}</div>;
}
return (
<div className="topics-browse">
<h1 className="topics-browse__title">Topics</h1>
<p className="topics-browse__subtitle">
Browse techniques organized by category and sub-topic
</p>
{/* Filter */}
<input
type="search"
className="topics-filter-input"
placeholder="Filter topics…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter topics"
/>
{filtered.length === 0 ? (
<div className="empty-state">
No topics matching &ldquo;{filter}&rdquo;
</div>
) : (
<div className="topics-grid">
{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 (
<div
key={cat.name}
className="topic-card card-stagger"
style={{
borderLeftColor: `var(--color-badge-cat-${slug}-text)`,
'--stagger-index': i,
} as React.CSSProperties}
>
<div className="topic-card__body">
<h3 className="topic-card__name">
<span className="topic-card__glyph">{(() => { const Ic = CATEGORY_ICON[cat.name] ?? CATEGORY_ICON["Workflow"]; return Ic ? <Ic /> : null; })()}</span>
{cat.name}
</h3>
<p className="topic-card__desc">{cat.description}</p>
<div className="topic-card__stats">
<span>{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}</span>
<span className="topic-card__stats-sep">·</span>
<span>{totalTechniques} technique{totalTechniques !== 1 ? "s" : ""}</span>
</div>
<button
className="topic-card__toggle"
onClick={() => toggleCategory(cat.name)}
aria-expanded={isExpanded}
>
{isExpanded ? "Hide sub-topics ▲" : "Show sub-topics ▼"}
</button>
</div>
<div className="topic-subtopics-wrapper" data-expanded={isExpanded}>
<div className="topic-subtopics">
{cat.sub_topics.map((st) => {
const stSlug = st.name.toLowerCase().replace(/\s+/g, "-");
if (st.technique_count === 0) {
return (
<span
key={st.name}
className="topic-subtopic topic-subtopic--empty"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="pill pill--coming-soon">Coming soon</span>
</span>
</span>
);
}
return (
<Link
key={st.name}
to={`/topics/${slug}/${stSlug}`}
className="topic-subtopic"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="topic-subtopic__count">
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
</span>
<span className="topic-subtopic__separator">·</span>
<span className="topic-subtopic__count">
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
</span>
</span>
</Link>
);
})}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}