chrysopedia/frontend/src/pages/SubTopicPage.tsx
jlightner 39e169b4ce feat: Split 945-line public-client.ts into 10 domain API modules with s…
- "frontend/src/api/client.ts"
- "frontend/src/api/index.ts"
- "frontend/src/api/search.ts"
- "frontend/src/api/techniques.ts"
- "frontend/src/api/creators.ts"
- "frontend/src/api/topics.ts"
- "frontend/src/api/stats.ts"
- "frontend/src/api/reports.ts"

GSD-Task: S05/T01
2026-04-03 23:04:56 +00:00

192 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Sub-topic detail page.
*
* Shows techniques for a specific sub-topic within a category,
* grouped by creator. Breadcrumb navigation back to Topics.
*/
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
fetchSubTopicTechniques,
type TechniqueListItem,
} from "../api";
import { catSlug } from "../utils/catSlug";
import SortDropdown from "../components/SortDropdown";
import TagList from "../components/TagList";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import { useSortPreference } from "../hooks/useSortPreference";
const SUBTOPIC_SORT_OPTIONS = [
{ value: "alpha", label: "AZ" },
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "creator", label: "Creator" },
];
/** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */
function slugToDisplayName(slug: string): string {
return slug
.replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Group techniques by creator_name, preserving order of first appearance. */
function groupByCreator(
items: TechniqueListItem[],
): { creatorName: string; creatorSlug: string; techniques: TechniqueListItem[] }[] {
const map = new Map<string, { creatorName: string; creatorSlug: string; techniques: TechniqueListItem[] }>();
for (const item of items) {
const name = item.creator_name || "Unknown";
const existing = map.get(name);
if (existing) {
existing.techniques.push(item);
} else {
map.set(name, {
creatorName: name,
creatorSlug: item.creator_slug || "",
techniques: [item],
});
}
}
return Array.from(map.values());
}
export default function SubTopicPage() {
const { category, subtopic } = useParams<{ category: string; subtopic: string }>();
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useSortPreference("alpha");
const categoryDisplay = category ? slugToDisplayName(category) : "";
const subtopicDisplay = subtopic ? slugToDisplayName(subtopic) : "";
useDocumentTitle(
subtopicDisplay && categoryDisplay
? `${subtopicDisplay}${categoryDisplay} — Chrysopedia`
: "Chrysopedia",
);
useEffect(() => {
if (!category || !subtopic) return;
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100, sort });
if (!cancelled) {
setTechniques(data.items);
}
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load techniques",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [category, subtopic, sort]);
if (loading) {
return <div className="loading">Loading techniques</div>;
}
if (error) {
return (
<div className="loading error-text">
Error: {error}
</div>
);
}
const groups = groupByCreator(techniques);
const slug = category ? catSlug(categoryDisplay) : "";
return (
<div
className="subtopic-page"
style={slug ? { borderLeftColor: `var(--color-badge-cat-${slug}-text)` } : undefined}
>
{/* Breadcrumbs */}
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/topics" className="breadcrumbs__link">Topics</Link>
<span className="breadcrumbs__sep" aria-hidden="true"></span>
<span className="breadcrumbs__text">{categoryDisplay}</span>
<span className="breadcrumbs__sep" aria-hidden="true"></span>
<span className="breadcrumbs__current" aria-current="page">{subtopicDisplay}</span>
</nav>
<h1 className="subtopic-page__title">{subtopicDisplay}</h1>
<p className="subtopic-page__subtitle">
<span className={`badge badge--cat-${slug}`}>{categoryDisplay}</span>
<span className="subtopic-page__subtitle-sep">·</span>
{techniques.length} technique{techniques.length !== 1 ? "s" : ""}
</p>
<SortDropdown
options={SUBTOPIC_SORT_OPTIONS}
value={sort}
onChange={setSort}
/>
{techniques.length === 0 ? (
<div className="empty-state">
No techniques found for this sub-topic.
</div>
) : (
<div className="subtopic-groups">
{groups.map((group) => (
<section key={group.creatorName} className="subtopic-group">
<h3 className="subtopic-group__creator">
{group.creatorSlug ? (
<Link to={`/creators/${group.creatorSlug}`} className="subtopic-group__creator-link">
{group.creatorName}
</Link>
) : (
group.creatorName
)}
<span className="subtopic-group__count">
{group.techniques.length} technique{group.techniques.length !== 1 ? "s" : ""}
</span>
</h3>
<div className="subtopic-group__list">
{group.techniques.map((t, i) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="subtopic-technique-card card-stagger"
style={{ '--stagger-index': i } as React.CSSProperties}
>
<span className="subtopic-technique-card__title">{t.title}</span>
{t.topic_tags && t.topic_tags.length > 0 && (
<span className="subtopic-technique-card__tags">
<TagList tags={t.topic_tags} />
</span>
)}
{t.summary && (
<span className="subtopic-technique-card__summary">
{t.summary.length > 150
? `${t.summary.slice(0, 150)}`
: t.summary}
</span>
)}
</Link>
))}
</div>
</section>
))}
</div>
)}
</div>
);
}