chrysopedia/frontend/src/pages/CreatorsBrowse.tsx
jlightner 39e169b4ce feat: Split 945-line public-client.ts into 10 domain API modules with s…
- "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
2026-04-03 23:04:56 +00:00

196 lines
6 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.

/**
* Creators browse page (R007, R014).
*
* - Default sort: random (creator equity — no featured/highlighted creators)
* - Genre filter pills from canonical taxonomy
* - Type-to-narrow client-side name filter
* - Sort toggle: Random | Alphabetical | Views
* - Click row → /creators/{slug}
*/
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
fetchCreators,
type CreatorBrowseItem,
} from "../api";
import CreatorAvatar from "../components/CreatorAvatar";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
const GENRES = [
"Bass music",
"Drum & bass",
"Dubstep",
"Halftime",
"House",
"Techno",
"IDM",
"Glitch",
"Downtempo",
"Neuro",
"Ambient",
"Experimental",
"Cinematic",
];
type SortMode = "random" | "alpha" | "views";
const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "random", label: "Random" },
{ value: "alpha", label: "AZ" },
{ value: "views", label: "Views" },
];
export default function CreatorsBrowse() {
useDocumentTitle("Creators — Chrysopedia");
const [creators, setCreators] = useState<CreatorBrowseItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useState<SortMode>("random");
const [genreFilter, setGenreFilter] = useState<string | null>(null);
const [nameFilter, setNameFilter] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const res = await fetchCreators({
sort,
genre: genreFilter ?? undefined,
limit: 100,
});
if (!cancelled) setCreators(res.items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load creators",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [sort, genreFilter]);
// Client-side name filtering
const displayed = nameFilter
? creators.filter((c) =>
c.name.toLowerCase().includes(nameFilter.toLowerCase()),
)
: creators;
return (
<div className="creators-browse">
<h1 className="creators-browse__title">Creators</h1>
<p className="creators-browse__subtitle">
Discover creators and their technique libraries
</p>
{/* Controls row */}
<div className="creators-controls">
{/* Sort toggle */}
<div className="sort-toggle" role="group" aria-label="Sort creators">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sort-toggle__btn${sort === opt.value ? " sort-toggle__btn--active" : ""}`}
onClick={() => setSort(opt.value)}
aria-pressed={sort === opt.value}
>
{opt.label}
</button>
))}
</div>
{/* Name filter */}
<input
type="search"
className="creators-filter-input"
placeholder="Filter by name…"
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
aria-label="Filter creators by name"
/>
</div>
{/* Genre pills */}
<div className="genre-pills" role="group" aria-label="Filter by genre">
<button
className={`genre-pill${genreFilter === null ? " genre-pill--active" : ""}`}
onClick={() => setGenreFilter(null)}
>
All
</button>
{GENRES.map((g) => (
<button
key={g}
className={`genre-pill${genreFilter === g ? " genre-pill--active" : ""}`}
onClick={() => setGenreFilter(genreFilter === g ? null : g)}
>
{g}
</button>
))}
</div>
{/* Content */}
{loading ? (
<div className="loading">Loading creators</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : displayed.length === 0 ? (
<div className="empty-state">
{nameFilter
? `No creators matching "${nameFilter}"`
: "No creators found."}
</div>
) : (
<div className="creators-list">
{displayed.map((creator) => (
<Link
key={creator.id}
to={`/creators/${creator.slug}`}
className="creator-row"
>
<span className="creator-row__name"><CreatorAvatar creatorId={creator.id} name={creator.name} size={28} /> {creator.name}</span>
<span className="creator-row__genres">
{creator.genres?.map((g) => (
<span key={g} className="pill">
{g}
</span>
))}
</span>
<span className="creator-row__stats">
<span className="creator-row__stat">
{creator.technique_count} technique{creator.technique_count !== 1 ? "s" : ""}
</span>
<span className="creator-row__separator">·</span>
<span className="creator-row__stat">
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
</span>
<span className="creator-row__separator">·</span>
<span className="creator-row__stat">
{creator.view_count.toLocaleString()} views
</span>
{creator.last_technique_at && (
<>
<span className="creator-row__separator">·</span>
<span className="creator-row__stat creator-row__updated">
Last updated: {new Date(creator.last_technique_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</>
)}
</span>
</Link>
))}
</div>
)}
</div>
);
}