- "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
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
/**
|
||
* 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: "A–Z" },
|
||
{ 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>
|
||
);
|
||
}
|