chrysopedia/frontend/src/pages/CreatorDetail.tsx
jlightner ef52ef6967 feat: Featured technique card with gradient border and enriched recent-…
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/CreatorDetail.tsx"
- "frontend/src/App.css"

GSD-Task: S03/T02
2026-04-03 09:10:14 +00:00

261 lines
8.7 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,
type CreatorDetailResponse,
} from "../api/public-client";
import CreatorAvatar from "../components/CreatorAvatar";
import { SocialIcon } from "../components/SocialIcons";
import SortDropdown from "../components/SortDropdown";
import TagList from "../components/TagList";
import { catSlug } from "../utils/catSlug";
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 [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 = await fetchCreator(slug);
if (!cancelled) {
setCreator(creatorData);
}
} 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>
);
}
const techniques = [...creator.techniques].sort((a, b) => {
switch (sort) {
case "oldest":
return a.created_at.localeCompare(b.created_at);
case "alpha":
return a.title.localeCompare(b.title);
case "newest":
default:
return b.created_at.localeCompare(a.created_at);
}
});
return (
<div className="creator-detail">
<Link to="/creators" className="back-link">
← Creators
</Link>
{/* Hero */}
<header className="creator-hero">
<CreatorAvatar
creatorId={creator.id}
name={creator.name}
imageUrl={creator.avatar_url ?? undefined}
size={96}
/>
<div className="creator-hero__info">
<h1 className="creator-hero__name">{creator.name}</h1>
{creator.bio && (
<p className="creator-hero__bio">{creator.bio}</p>
)}
{creator.social_links && Object.keys(creator.social_links).length > 0 && (
<div className="creator-hero__socials">
{Object.entries(creator.social_links).map(([platform, url]) => (
<a key={platform} href={url} target="_blank" rel="noopener noreferrer"
className="creator-hero__social-link" title={platform}>
<SocialIcon platform={platform} />
</a>
))}
</div>
)}
{creator.genres && creator.genres.length > 0 && (
<div className="creator-hero__genres">
{creator.genres.map((g) => (
<span key={g} className="pill">{g}</span>
))}
</div>
)}
</div>
</header>
{/* Stats */}
<div className="creator-detail__stats-bar">
<span className="creator-detail__stats">
{creator.technique_count} technique{creator.technique_count !== 1 ? "s" : ""}
</span>
<span className="creator-detail__stats-sep">·</span>
<span className="creator-detail__stats">
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
</span>
<span className="creator-detail__stats-sep">·</span>
<span className="creator-detail__stats">
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
</span>
{Object.keys(creator.genre_breakdown).length > 0 && (
<span className="creator-detail__topic-pills">
{Object.entries(creator.genre_breakdown)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<span key={cat} className={`badge badge--cat-${catSlug(cat)}`}>
{cat}: {count}
</span>
))}
</span>
)}
</div>
{/* Technique pages */}
<section className="creator-techniques">
<div className="creator-techniques__header">
<h2 className="creator-techniques__title">
Techniques ({creator.technique_count})
</h2>
<SortDropdown
options={CREATOR_SORT_OPTIONS}
value={sort}
onChange={setSort}
/>
</div>
{techniques.length === 0 ? (
<div className="empty-state">No techniques yet.</div>
) : (
<>
{/* Featured technique — first in sorted order */}
{techniques[0] != null && (() => {
const featured = techniques[0];
return (
<Link
to={`/techniques/${featured.slug}`}
className="creator-featured"
>
<span className="creator-featured__label section-heading section-heading--accent">
Featured Technique
</span>
<span className="creator-featured__title">
{featured.title}
</span>
{featured.summary && (
<p className="creator-featured__summary">
{featured.summary}
</p>
)}
<span className="creator-featured__meta">
<span className={`badge badge--cat-${catSlug(featured.topic_category)}`}>
{featured.topic_category}
</span>
{featured.topic_tags && featured.topic_tags.length > 0 && (
<TagList tags={featured.topic_tags} max={4} pillClass="pill--tag" />
)}
</span>
<span className="creator-featured__moments">
{featured.key_moment_count} moment{featured.key_moment_count !== 1 ? "s" : ""}
</span>
</Link>
);
})()}
{/* Remaining techniques — recent-card grid */}
{techniques.length > 1 && (
<div className="creator-techniques__list">
{techniques.slice(1).map((t, i) => (
<Link
key={t.slug}
to={`/techniques/${t.slug}`}
className="recent-card card-stagger"
style={{ '--stagger-index': i } as React.CSSProperties}
>
<span className="recent-card__title">
{t.title}
</span>
{t.summary && (
<span className="recent-card__summary">
{t.summary}
</span>
)}
<span className="recent-card__meta">
<span className={`badge badge--cat-${catSlug(t.topic_category)}`}>
{t.topic_category}
</span>
{t.topic_tags && t.topic_tags.length > 0 && (
<TagList tags={t.topic_tags} max={3} pillClass="pill--tag" />
)}
</span>
<span className="recent-card__moments">
{t.key_moment_count} moment{t.key_moment_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
)}
</>
)}
</section>
</div>
);
}