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
This commit is contained in:
jlightner 2026-03-30 00:13:11 +00:00
parent 3a7f10005b
commit 07e85e95d2
10 changed files with 1097 additions and 3 deletions

View file

@ -211,7 +211,7 @@ The frontend uses React 18 + Vite + TypeScript with strict mode (`noUnusedLocals
- Estimate: 2h
- Files: frontend/src/api/public-client.ts, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx, frontend/src/pages/TechniquePage.tsx, frontend/src/App.tsx, frontend/src/App.css
- Verify: cd frontend && npx tsc -b && npm run build && echo 'Frontend build OK'
- [ ] **T04: Build frontend browse pages (creators, topics) and verify full build** — ## Description
- [x] **T04: Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds** — ## Description
Build the remaining browse pages: CreatorsBrowse (R007, R014 creator equity with randomized default sort), CreatorDetail, and TopicsBrowse (R008 two-level hierarchy). Then run final verification to confirm the full frontend builds cleanly and all requirements are covered.

View file

@ -0,0 +1,36 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M001/S05/T03",
"timestamp": 1774829348695,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx tsc -b",
"exitCode": 1,
"durationMs": 851,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 90,
"verdict": "fail"
},
{
"command": "echo 'Frontend build OK'",
"exitCode": 0,
"durationMs": 5,
"verdict": "pass"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,93 @@
---
id: T04
parent: S05
milestone: M001
provides: []
requires: []
affects: []
key_files: ["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"]
key_decisions: ["Added creator_slug param to TechniqueListParams and fetchTechniques to support filtering techniques by creator on the detail page", "Hardcoded genre list from canonical_tags.yaml rather than fetching dynamically", "All topic categories expanded by default for discoverability"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "TypeScript compilation (npx tsc -b) passes with zero errors. Production build (npm run build) succeeds in 792ms with 43 modules. All 3 page files exist. 9 routes registered in App.tsx. All 5 slice-level verification checks pass (search_service import, search/techniques/topics routers have routes, routers mounted in app)."
completed_at: 2026-03-30T00:12:57.277Z
blocker_discovered: false
---
# T04: Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds
> Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds
## What Happened
---
id: T04
parent: S05
milestone: M001
key_files:
- 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
key_decisions:
- Added creator_slug param to TechniqueListParams and fetchTechniques to support filtering techniques by creator on the detail page
- Hardcoded genre list from canonical_tags.yaml rather than fetching dynamically
- All topic categories expanded by default for discoverability
duration: ""
verification_result: passed
completed_at: 2026-03-30T00:12:57.278Z
blocker_discovered: false
---
# T04: Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds
**Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds**
## What Happened
Created three new page components: CreatorsBrowse with randomized default sort (R014 creator equity), genre filter pills, type-to-narrow name filter, and sort toggle (Random/A-Z/Views); CreatorDetail with creator info header and technique list fetched by creator_slug; TopicsBrowse with two-level hierarchy (6 categories with expandable sub-topics showing technique_count and creator_count). Updated App.tsx with 3 new routes and added comprehensive CSS for all browse pages. Added creator_slug param to fetchTechniques in public-client.ts to support the CreatorDetail page.
## Verification
TypeScript compilation (npx tsc -b) passes with zero errors. Production build (npm run build) succeeds in 792ms with 43 modules. All 3 page files exist. 9 routes registered in App.tsx. All 5 slice-level verification checks pass (search_service import, search/techniques/topics routers have routes, routers mounted in app).
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx tsc -b` | 0 | ✅ pass | 2800ms |
| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 2300ms |
| 3 | `test -f frontend/src/pages/CreatorsBrowse.tsx && test -f frontend/src/pages/CreatorDetail.tsx && test -f frontend/src/pages/TopicsBrowse.tsx` | 0 | ✅ pass | 50ms |
| 4 | `cd backend && python -c "from search_service import SearchService; print('OK')"` | 0 | ✅ pass | 400ms |
| 5 | `cd backend && python -c "from routers.search import router; print(router.routes)"` | 0 | ✅ pass | 400ms |
| 6 | `cd backend && python -c "from routers.techniques import router; print(router.routes)"` | 0 | ✅ pass | 400ms |
| 7 | `cd backend && python -c "from routers.topics import router; print(router.routes)"` | 0 | ✅ pass | 400ms |
| 8 | `cd backend && python -c "from main import app; routes=[r.path for r in app.routes]; assert any('search' in str(r.path) for r in app.routes); print('Mounted')"` | 0 | ✅ pass | 400ms |
## Deviations
Added creator_slug to TechniqueListParams in public-client.ts — not in original plan but required for CreatorDetail to fetch techniques filtered by creator.
## Known Issues
None.
## Files Created/Modified
- `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`
## Deviations
Added creator_slug to TechniqueListParams in public-client.ts — not in original plan but required for CreatorDetail to fetch techniques filtered by creator.
## Known Issues
None.

View file

@ -1286,7 +1286,432 @@ body {
margin-left: 0.375rem;
}
/* ── Public responsive ────────────────────────────────────────────────────── */
/*
CREATORS BROWSE
*/
.creators-browse {
max-width: 56rem;
}
.creators-browse__title {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.25rem;
}
.creators-browse__subtitle {
font-size: 0.9375rem;
color: #6b7280;
margin-bottom: 1.25rem;
}
/* ── Controls row ─────────────────────────────────────────────────────────── */
.creators-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.sort-toggle {
display: inline-flex;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
overflow: hidden;
}
.sort-toggle__btn {
padding: 0.375rem 0.75rem;
border: none;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sort-toggle__btn + .sort-toggle__btn {
border-left: 1px solid #d1d5db;
}
.sort-toggle__btn:hover {
background: #f3f4f6;
color: #374151;
}
.sort-toggle__btn--active {
background: #1a1a2e;
color: #fff;
}
.sort-toggle__btn--active:hover {
background: #2d2d4e;
}
.creators-filter-input {
flex: 1;
min-width: 12rem;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: inherit;
color: #374151;
background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.creators-filter-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
/* ── Genre pills ──────────────────────────────────────────────────────────── */
.genre-pills {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1.25rem;
}
.genre-pill {
display: inline-block;
padding: 0.25rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: #fff;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.genre-pill:hover {
border-color: #a5b4fc;
background: #eef2ff;
}
.genre-pill--active {
background: #1a1a2e;
color: #fff;
border-color: #1a1a2e;
}
.genre-pill--active:hover {
background: #2d2d4e;
}
/* ── Creator list ─────────────────────────────────────────────────────────── */
.creators-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.creator-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: #fff;
border: 1px solid #e2e2e8;
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: border-color 0.15s, box-shadow 0.15s;
flex-wrap: wrap;
}
.creator-row:hover {
border-color: #a5b4fc;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.creator-row__name {
font-size: 0.9375rem;
font-weight: 600;
min-width: 10rem;
}
.creator-row__genres {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.creator-row__stats {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
}
.creator-row__stat {
font-variant-numeric: tabular-nums;
}
.creator-row__separator {
color: #d1d5db;
}
/*
CREATOR DETAIL
*/
.creator-detail {
max-width: 48rem;
}
.creator-detail__header {
margin-bottom: 1.5rem;
}
.creator-detail__name {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
line-height: 1.2;
}
.creator-detail__meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.creator-detail__genres {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.creator-detail__stats {
font-size: 0.875rem;
color: #6b7280;
}
.creator-techniques {
margin-top: 1.5rem;
}
.creator-techniques__title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.creator-techniques__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.creator-technique-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
background: #fff;
border: 1px solid #e2e2e8;
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: border-color 0.15s, box-shadow 0.15s;
}
.creator-technique-card:hover {
border-color: #a5b4fc;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.creator-technique-card__title {
font-size: 0.9375rem;
font-weight: 600;
}
.creator-technique-card__meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.creator-technique-card__tags {
display: inline-flex;
gap: 0.25rem;
}
.creator-technique-card__summary {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
/*
TOPICS BROWSE
*/
.topics-browse {
max-width: 56rem;
}
.topics-browse__title {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.25rem;
}
.topics-browse__subtitle {
font-size: 0.9375rem;
color: #6b7280;
margin-bottom: 1.25rem;
}
.topics-filter-input {
width: 100%;
max-width: 24rem;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: inherit;
color: #374151;
background: #fff;
margin-bottom: 1.25rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.topics-filter-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
/* ── Topics hierarchy ─────────────────────────────────────────────────────── */
.topics-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.topic-category {
background: #fff;
border: 1px solid #e2e2e8;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.topic-category__header {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.875rem 1.25rem;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-family: inherit;
transition: background 0.15s;
}
.topic-category__header:hover {
background: #f9fafb;
}
.topic-category__chevron {
font-size: 0.625rem;
color: #9ca3af;
flex-shrink: 0;
width: 0.75rem;
}
.topic-category__name {
font-size: 1rem;
font-weight: 700;
color: #1a1a2e;
}
.topic-category__desc {
font-size: 0.8125rem;
color: #6b7280;
flex: 1;
}
.topic-category__count {
font-size: 0.75rem;
color: #9ca3af;
white-space: nowrap;
margin-left: auto;
}
/* ── Sub-topics ───────────────────────────────────────────────────────────── */
.topic-subtopics {
border-top: 1px solid #e2e2e8;
}
.topic-subtopic {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem 0.625rem 2.75rem;
text-decoration: none;
color: inherit;
font-size: 0.875rem;
transition: background 0.1s;
}
.topic-subtopic:hover {
background: #f3f4f6;
}
.topic-subtopic + .topic-subtopic {
border-top: 1px solid #f3f4f6;
}
.topic-subtopic__name {
font-weight: 500;
color: #374151;
text-transform: capitalize;
}
.topic-subtopic__counts {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #9ca3af;
}
.topic-subtopic__count {
font-variant-numeric: tabular-nums;
}
.topic-subtopic__separator {
color: #d1d5db;
}
/* ── Public responsive (extended) ─────────────────────────────────────────── */
@media (max-width: 640px) {
.home-hero__title {
@ -1313,4 +1738,33 @@ body {
gap: 0.75rem;
font-size: 0.8125rem;
}
.creators-controls {
flex-direction: column;
align-items: stretch;
}
.creator-row {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.creator-row__stats {
margin-left: 0;
}
.creators-browse__title,
.topics-browse__title,
.creator-detail__name {
font-size: 1.375rem;
}
.topic-category__desc {
display: none;
}
.topic-subtopic {
padding-left: 2rem;
}
}

View file

@ -2,6 +2,9 @@ import { Link, Navigate, Route, Routes } from "react-router-dom";
import Home from "./pages/Home";
import SearchResults from "./pages/SearchResults";
import TechniquePage from "./pages/TechniquePage";
import CreatorsBrowse from "./pages/CreatorsBrowse";
import CreatorDetail from "./pages/CreatorDetail";
import TopicsBrowse from "./pages/TopicsBrowse";
import ReviewQueue from "./pages/ReviewQueue";
import MomentDetail from "./pages/MomentDetail";
import ModeToggle from "./components/ModeToggle";
@ -31,6 +34,11 @@ export default function App() {
<Route path="/search" element={<SearchResults />} />
<Route path="/techniques/:slug" element={<TechniquePage />} />
{/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} />
<Route path="/creators/:slug" element={<CreatorDetail />} />
<Route path="/topics" element={<TopicsBrowse />} />
{/* Admin routes */}
<Route path="/admin/review" element={<ReviewQueue />} />
<Route path="/admin/review/:momentId" element={<MomentDetail />} />

View file

@ -193,6 +193,7 @@ export interface TechniqueListParams {
limit?: number;
offset?: number;
category?: string;
creator_slug?: string;
}
export async function fetchTechniques(
@ -202,6 +203,7 @@ export async function fetchTechniques(
if (params.limit !== undefined) qs.set("limit", String(params.limit));
if (params.offset !== undefined) qs.set("offset", String(params.offset));
if (params.category) qs.set("category", params.category);
if (params.creator_slug) qs.set("creator_slug", params.creator_slug);
const query = qs.toString();
return request<TechniqueListResponse>(
`${BASE}/techniques${query ? `?${query}` : ""}`,

View file

@ -0,0 +1,160 @@
/**
* 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";
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">{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" : ""}
<span className="queue-card__separator">·</span>
{creator.view_count.toLocaleString()} views
</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>
);
}

View file

@ -0,0 +1,185 @@
/**
* 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>
);
}

View file

@ -0,0 +1,156 @@
/**
* Topics browse page (R008).
*
* Two-level hierarchy: 6 top-level categories with expandable/collapsible
* sub-topics. Each sub-topic shows technique_count and creator_count.
* Filter input narrows categories and sub-topics.
* Click sub-topic search results filtered to that topic.
*/
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchTopics, type TopicCategory } from "../api/public-client";
export default function TopicsBrowse() {
const [categories, setCategories] = useState<TopicCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const data = await fetchTopics();
if (!cancelled) {
setCategories(data);
// All expanded by default
setExpanded(new Set(data.map((c) => c.name)));
}
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load topics",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
function toggleCategory(name: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}
// Apply filter: show categories whose name or sub-topics match
const lowerFilter = filter.toLowerCase();
const filtered = filter
? categories
.map((cat) => {
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
const matchingSubs = cat.sub_topics.filter((st) =>
st.name.toLowerCase().includes(lowerFilter),
);
if (catMatches) return cat; // show full category
if (matchingSubs.length > 0) {
return { ...cat, sub_topics: matchingSubs };
}
return null;
})
.filter(Boolean) as TopicCategory[]
: categories;
if (loading) {
return <div className="loading">Loading topics</div>;
}
if (error) {
return <div className="loading error-text">Error: {error}</div>;
}
return (
<div className="topics-browse">
<h2 className="topics-browse__title">Topics</h2>
<p className="topics-browse__subtitle">
Browse techniques organized by category and sub-topic
</p>
{/* Filter */}
<input
type="search"
className="topics-filter-input"
placeholder="Filter topics…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter topics"
/>
{filtered.length === 0 ? (
<div className="empty-state">
No topics matching "{filter}"
</div>
) : (
<div className="topics-list">
{filtered.map((cat) => (
<div key={cat.name} className="topic-category">
<button
className="topic-category__header"
onClick={() => toggleCategory(cat.name)}
aria-expanded={expanded.has(cat.name)}
>
<span className="topic-category__chevron">
{expanded.has(cat.name) ? "▼" : "▶"}
</span>
<span className="topic-category__name">{cat.name}</span>
<span className="topic-category__desc">{cat.description}</span>
<span className="topic-category__count">
{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}
</span>
</button>
{expanded.has(cat.name) && (
<div className="topic-subtopics">
{cat.sub_topics.map((st) => (
<Link
key={st.name}
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
className="topic-subtopic"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="topic-subtopic__count">
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
</span>
<span className="topic-subtopic__separator">·</span>
<span className="topic-subtopic__count">
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
</span>
</span>
</Link>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/public-client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/Home.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx","./src/pages/SearchResults.tsx","./src/pages/TechniquePage.tsx"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/public-client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx","./src/pages/SearchResults.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx"],"version":"5.6.3"}