196 lines
6 KiB
TypeScript
196 lines
6 KiB
TypeScript
/**
|
||
* 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/public-client";
|
||
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: "A–Z" },
|
||
{ 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>
|
||
);
|
||
}
|