178 lines
5.4 KiB
TypeScript
178 lines
5.4 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";
|
|
import CreatorAvatar from "../components/CreatorAvatar";
|
|
|
|
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"><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>
|
|
{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], i) => (
|
|
<span key={cat} className="creator-detail__topic-stat">
|
|
{i > 0 && <span className="queue-card__separator">·</span>}
|
|
{cat}: {count}
|
|
</span>
|
|
))}
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|