chrysopedia/frontend/src/pages/CreatorDetail.tsx
jlightner edeb3fdc44 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

160 lines
4.7 KiB
TypeScript

/**
* 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";
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);
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 }),
]);
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]);
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">{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" : ""}
<span className="queue-card__separator">·</span>
{creator.view_count.toLocaleString()} views
</span>
</div>
</header>
{/* Technique pages */}
<section className="creator-techniques">
<h2 className="creator-techniques__title">
Techniques ({techniques.length})
</h2>
{techniques.length === 0 ? (
<div className="empty-state">No techniques yet.</div>
) : (
<div className="creator-techniques__list">
{techniques.map((t) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="creator-technique-card"
>
<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">
{t.topic_tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</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>
);
}