chrysopedia/frontend/src/pages/TopicsBrowse.tsx
jlightner 07e85e95d2 feat: Built CreatorsBrowse (randomized default sort, genre filter, name…
- "frontend/src/pages/CreatorsBrowse.tsx"
- "frontend/src/pages/CreatorDetail.tsx"
- "frontend/src/pages/TopicsBrowse.tsx"
- "frontend/src/App.tsx"
- "frontend/src/App.css"
- "frontend/src/api/public-client.ts"

GSD-Task: S05/T04
2026-03-30 00:13:11 +00:00

156 lines
5 KiB
TypeScript

/**
* 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<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);
// 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 <div className="loading">Loading topics</div>;
}
if (error) {
return <div className="loading error-text">Error: {error}</div>;
}
return (
<div className="topics-browse">
<h2 className="topics-browse__title">Topics</h2>
<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 "{filter}"
</div>
) : (
<div className="topics-list">
{filtered.map((cat) => (
<div key={cat.name} className="topic-category">
<button
className="topic-category__header"
onClick={() => toggleCategory(cat.name)}
aria-expanded={expanded.has(cat.name)}
>
<span className="topic-category__chevron">
{expanded.has(cat.name) ? "▼" : "▶"}
</span>
<span className="topic-category__name">{cat.name}</span>
<span className="topic-category__desc">{cat.description}</span>
<span className="topic-category__count">
{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}
</span>
</button>
{expanded.has(cat.name) && (
<div className="topic-subtopics">
{cat.sub_topics.map((st) => (
<Link
key={st.name}
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
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>
);
}