From 07e85e95d2ea9a62d50a535b0e8de37c9614ac2c Mon Sep 17 00:00:00 2001 From: jlightner Date: Mon, 30 Mar 2026 00:13:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20CreatorsBrowse=20(randomized=20?= =?UTF-8?q?default=20sort,=20genre=20filter,=20name=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- .gsd/milestones/M001/slices/S05/S05-PLAN.md | 2 +- .../M001/slices/S05/tasks/T03-VERIFY.json | 36 ++ .../M001/slices/S05/tasks/T04-SUMMARY.md | 93 ++++ frontend/src/App.css | 456 +++++++++++++++++- frontend/src/App.tsx | 8 + frontend/src/api/public-client.ts | 2 + frontend/src/pages/CreatorDetail.tsx | 160 ++++++ frontend/src/pages/CreatorsBrowse.tsx | 185 +++++++ frontend/src/pages/TopicsBrowse.tsx | 156 ++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 1097 insertions(+), 3 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json create mode 100644 .gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md create mode 100644 frontend/src/pages/CreatorDetail.tsx create mode 100644 frontend/src/pages/CreatorsBrowse.tsx create mode 100644 frontend/src/pages/TopicsBrowse.tsx diff --git a/.gsd/milestones/M001/slices/S05/S05-PLAN.md b/.gsd/milestones/M001/slices/S05/S05-PLAN.md index ef6db6c..1cdfb19 100644 --- a/.gsd/milestones/M001/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M001/slices/S05/S05-PLAN.md @@ -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. diff --git a/.gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json b/.gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json new file mode 100644 index 0000000..a4a6be6 --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json @@ -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 +} diff --git a/.gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md b/.gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md new file mode 100644 index 0000000..4e56fc9 --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md @@ -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. diff --git a/frontend/src/App.css b/frontend/src/App.css index 8114de8..721fc4e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0747ae..8b7bcd4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + {/* Browse routes */} + } /> + } /> + } /> + {/* Admin routes */} } /> } /> diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index e511d9c..e540fc8 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -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( `${BASE}/techniques${query ? `?${query}` : ""}`, diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx new file mode 100644 index 0000000..0c0c6d2 --- /dev/null +++ b/frontend/src/pages/CreatorDetail.tsx @@ -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(null); + const [techniques, setTechniques] = useState([]); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(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
Loading creator…
; + } + + if (notFound) { + return ( +
+

Creator Not Found

+

The creator "{slug}" doesn't exist.

+ + Back to Creators + +
+ ); + } + + if (error || !creator) { + return ( +
+ Error: {error ?? "Unknown error"} +
+ ); + } + + return ( +
+ + ← Creators + + + {/* Header */} +
+

{creator.name}

+
+ {creator.genres && creator.genres.length > 0 && ( + + {creator.genres.map((g) => ( + + {g} + + ))} + + )} + + {creator.video_count} video{creator.video_count !== 1 ? "s" : ""} + · + {creator.view_count.toLocaleString()} views + +
+
+ + {/* Technique pages */} +
+

+ Techniques ({techniques.length}) +

+ {techniques.length === 0 ? ( +
No techniques yet.
+ ) : ( +
+ {techniques.map((t) => ( + + + {t.title} + + + + {t.topic_category} + + {t.topic_tags && t.topic_tags.length > 0 && ( + + {t.topic_tags.map((tag) => ( + + {tag} + + ))} + + )} + + {t.summary && ( + + {t.summary.length > 120 + ? `${t.summary.slice(0, 120)}…` + : t.summary} + + )} + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/CreatorsBrowse.tsx b/frontend/src/pages/CreatorsBrowse.tsx new file mode 100644 index 0000000..0dc4d1c --- /dev/null +++ b/frontend/src/pages/CreatorsBrowse.tsx @@ -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: "A–Z" }, + { value: "views", label: "Views" }, +]; + +export default function CreatorsBrowse() { + const [creators, setCreators] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sort, setSort] = useState("random"); + const [genreFilter, setGenreFilter] = useState(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 ( +
+

Creators

+

+ Discover creators and their technique libraries +

+ + {/* Controls row */} +
+ {/* Sort toggle */} +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Name filter */} + setNameFilter(e.target.value)} + aria-label="Filter creators by name" + /> +
+ + {/* Genre pills */} +
+ + {GENRES.map((g) => ( + + ))} +
+ + {/* Content */} + {loading ? ( +
Loading creators…
+ ) : error ? ( +
Error: {error}
+ ) : displayed.length === 0 ? ( +
+ {nameFilter + ? `No creators matching "${nameFilter}"` + : "No creators found."} +
+ ) : ( +
+ {displayed.map((creator) => ( + + {creator.name} + + {creator.genres?.map((g) => ( + + {g} + + ))} + + + + {creator.technique_count} technique{creator.technique_count !== 1 ? "s" : ""} + + · + + {creator.video_count} video{creator.video_count !== 1 ? "s" : ""} + + · + + {creator.view_count.toLocaleString()} views + + + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/TopicsBrowse.tsx b/frontend/src/pages/TopicsBrowse.tsx new file mode 100644 index 0000000..a6addb0 --- /dev/null +++ b/frontend/src/pages/TopicsBrowse.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(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
Loading topics…
; + } + + if (error) { + return
Error: {error}
; + } + + return ( +
+

Topics

+

+ Browse techniques organized by category and sub-topic +

+ + {/* Filter */} + setFilter(e.target.value)} + aria-label="Filter topics" + /> + + {filtered.length === 0 ? ( +
+ No topics matching "{filter}" +
+ ) : ( +
+ {filtered.map((cat) => ( +
+ + + {expanded.has(cat.name) && ( +
+ {cat.sub_topics.map((st) => ( + + {st.name} + + + {st.technique_count} technique{st.technique_count !== 1 ? "s" : ""} + + · + + {st.creator_count} creator{st.creator_count !== 1 ? "s" : ""} + + + + ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index abb36cf..38e7477 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file