- "frontend/src/hooks/useDocumentTitle.ts" - "frontend/src/pages/Home.tsx" - "frontend/src/pages/TopicsBrowse.tsx" - "frontend/src/pages/SubTopicPage.tsx" - "frontend/src/pages/CreatorsBrowse.tsx" - "frontend/src/pages/CreatorDetail.tsx" - "frontend/src/pages/TechniquePage.tsx" - "frontend/src/pages/SearchResults.tsx" GSD-Task: S04/T02
201 lines
6.9 KiB
TypeScript
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/public-client";
|
|
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);
|
|
// 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 “{filter}”
|
|
</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>
|
|
);
|
|
}
|