chrysopedia/frontend/src/pages/CreatorDetail.tsx
jlightner baef500de6 feat: Created SortDropdown component and useSortPreference hook, integr…
- "frontend/src/components/SortDropdown.tsx"
- "frontend/src/hooks/useSortPreference.ts"
- "frontend/src/pages/SearchResults.tsx"
- "frontend/src/pages/SubTopicPage.tsx"
- "frontend/src/pages/CreatorDetail.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/App.css"

GSD-Task: S02/T02
2026-04-01 06:41:52 +00:00

197 lines
6.2 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.

/**
* Creator detail page.
*
* Shows creator info (name, genres, video/technique counts) and lists
* their technique pages with links. Handles loading and 404 states.
*/
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
fetchCreator,
fetchTechniques,
type CreatorDetailResponse,
type TechniqueListItem,
} from "../api/public-client";
import CreatorAvatar from "../components/CreatorAvatar";
import SortDropdown from "../components/SortDropdown";
import { catSlug } from "../utils/catSlug";
import TagList from "../components/TagList";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import { useSortPreference } from "../hooks/useSortPreference";
const CREATOR_SORT_OPTIONS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "alpha", label: "AZ" },
];
export default function CreatorDetail() {
const { slug } = useParams<{ slug: string }>();
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useSortPreference("newest");
useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia");
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
void (async () => {
try {
const [creatorData, techData] = await Promise.all([
fetchCreator(slug),
fetchTechniques({ creator_slug: slug, limit: 100, sort }),
]);
if (!cancelled) {
setCreator(creatorData);
setTechniques(techData.items);
}
} catch (err) {
if (!cancelled) {
if (err instanceof Error && err.message.includes("404")) {
setNotFound(true);
} else {
setError(
err instanceof Error ? err.message : "Failed to load creator",
);
}
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [slug, sort]);
if (loading) {
return <div className="loading">Loading creator</div>;
}
if (notFound) {
return (
<div className="technique-404">
<h2>Creator Not Found</h2>
<p>The creator "{slug}" doesn't exist.</p>
<Link to="/creators" className="btn">
Back to Creators
</Link>
</div>
);
}
if (error || !creator) {
return (
<div className="loading error-text">
Error: {error ?? "Unknown error"}
</div>
);
}
return (
<div className="creator-detail">
<Link to="/creators" className="back-link">
← Creators
</Link>
{/* Header */}
<header className="creator-detail__header">
<h1 className="creator-detail__name"><CreatorAvatar creatorId={creator.id} name={creator.name} size={48} /> {creator.name}</h1>
<div className="creator-detail__meta">
{creator.genres && creator.genres.length > 0 && (
<span className="creator-detail__genres">
{creator.genres.map((g) => (
<span key={g} className="pill">
{g}
</span>
))}
</span>
)}
<span className="creator-detail__stats">
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
{techniques.length > 0 && (
<>
<span className="queue-card__separator">·</span>
<span className="creator-detail__topic-pills">
{Object.entries(
techniques.reduce<Record<string, number>>((acc, t) => {
const cat = t.topic_category || "Uncategorized";
acc[cat] = (acc[cat] || 0) + 1;
return acc;
}, {}),
)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<span key={cat} className={`badge badge--cat-${catSlug(cat)}`}>
{cat}: {count}
</span>
))}
</span>
</>
)}
</span>
</div>
</header>
{/* Technique pages */}
<section className="creator-techniques">
<div className="creator-techniques__header">
<h2 className="creator-techniques__title">
Techniques ({techniques.length})
</h2>
<SortDropdown
options={CREATOR_SORT_OPTIONS}
value={sort}
onChange={setSort}
/>
</div>
{techniques.length === 0 ? (
<div className="empty-state">No techniques yet.</div>
) : (
<div className="creator-techniques__list">
{techniques.map((t, i) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="creator-technique-card card-stagger"
style={{ '--stagger-index': i } as React.CSSProperties}
>
<span className="creator-technique-card__title">
{t.title}
</span>
<span className="creator-technique-card__meta">
<span className="badge badge--category">
{t.topic_category}
</span>
{t.topic_tags && t.topic_tags.length > 0 && (
<span className="creator-technique-card__tags">
<TagList tags={t.topic_tags} />
</span>
)}
</span>
{t.summary && (
<span className="creator-technique-card__summary">
{t.summary.length > 120
? `${t.summary.slice(0, 120)}`
: t.summary}
</span>
)}
</Link>
))}
</div>
)}
</section>
</div>
);
}