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:
parent
3a7f10005b
commit
07e85e95d2
10 changed files with 1097 additions and 3 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
36
.gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json
Normal file
36
.gsd/milestones/M001/slices/S05/tasks/T03-VERIFY.json
Normal 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
|
||||
}
|
||||
93
.gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md
Normal file
93
.gsd/milestones/M001/slices/S05/tasks/T04-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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}` : ""}`,
|
||||
|
|
|
|||
160
frontend/src/pages/CreatorDetail.tsx
Normal file
160
frontend/src/pages/CreatorDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/pages/CreatorsBrowse.tsx
Normal file
185
frontend/src/pages/CreatorsBrowse.tsx
Normal 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: "A–Z" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
156
frontend/src/pages/TopicsBrowse.tsx
Normal file
156
frontend/src/pages/TopicsBrowse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue