chrysopedia/frontend/src/pages/CreatorsBrowse.tsx
jlightner 07e85e95d2 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

185 lines
5.4 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/public-client";
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() {
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: 200,
});
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">
<h2 className="creators-browse__title">Creators</h2>
<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">{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>
</span>
</Link>
))}
</div>
)}
</div>
);
}