diff --git a/.gsd/milestones/M001/slices/S05/S05-PLAN.md b/.gsd/milestones/M001/slices/S05/S05-PLAN.md index ac7e919..ef6db6c 100644 --- a/.gsd/milestones/M001/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M001/slices/S05/S05-PLAN.md @@ -141,7 +141,7 @@ Write integration tests for all new S05 backend endpoints: search (with mocked e - Estimate: 1.5h - Files: backend/tests/test_search.py, backend/tests/test_public_api.py, backend/tests/conftest.py - Verify: cd backend && python -m pytest tests/test_search.py tests/test_public_api.py -v && python -m pytest tests/ -v -- [ ] **T03: Build frontend search flow: landing page, search results, and technique page** — ## Description +- [x] **T03: Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links** — ## Description Build the primary user flow: landing page with search bar → search results page → technique page detail. This is the R005/R006/R015 critical path. Includes the new typed API client for public endpoints, App.tsx routing with both admin and public routes, and 3 new page components with CSS. diff --git a/.gsd/milestones/M001/slices/S05/tasks/T02-VERIFY.json b/.gsd/milestones/M001/slices/S05/tasks/T02-VERIFY.json new file mode 100644 index 0000000..723f5fe --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T02-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M001/S05/T02", + "timestamp": 1774828892142, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 3, + "verdict": "pass" + }, + { + "command": "python -m pytest tests/test_search.py tests/test_public_api.py -v", + "exitCode": 4, + "durationMs": 240, + "verdict": "fail" + }, + { + "command": "python -m pytest tests/ -v", + "exitCode": 5, + "durationMs": 227, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M001/slices/S05/tasks/T03-SUMMARY.md b/.gsd/milestones/M001/slices/S05/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..75632bb --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T03-SUMMARY.md @@ -0,0 +1,90 @@ +--- +id: T03 +parent: S05 +milestone: M001 +provides: [] +requires: [] +affects: [] +key_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"] +key_decisions: ["Duplicated request helper in public-client.ts rather than extracting shared module to avoid coupling public and admin API clients", "Used controlled form + URL search params for search results so query state is shareable via URL", "Rendered body_sections JSONB gracefully handling both string and nested object values"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "TypeScript strict compilation passes with zero errors. Production build succeeds (40 modules, 191KB JS). All 4 expected page files exist. All 5 slice verification checks pass. Backend tests: 18/18 search+public API tests pass, 58/58 full suite passes with zero regressions." +completed_at: 2026-03-30T00:08:56.398Z +blocker_discovered: false +--- + +# T03: Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links + +> Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links + +## What Happened +--- +id: T03 +parent: S05 +milestone: M001 +key_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 +key_decisions: + - Duplicated request helper in public-client.ts rather than extracting shared module to avoid coupling public and admin API clients + - Used controlled form + URL search params for search results so query state is shareable via URL + - Rendered body_sections JSONB gracefully handling both string and nested object values +duration: "" +verification_result: passed +completed_at: 2026-03-30T00:08:56.398Z +blocker_discovered: false +--- + +# T03: Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links + +**Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links** + +## What Happened + +Created public-client.ts with typed interfaces for all backend schemas and 6 endpoint functions. Built Home.tsx with auto-focused search bar, 300ms debounced typeahead (top 5 after 2+ chars), nav cards for Topics/Creators, and Recently Added section. Built SearchResults.tsx with URL param-driven search, type-grouped results (techniques first, then key moments), and keyword fallback banner. Built TechniquePage.tsx with full detail rendering: header with badges/tags/creator link, amber banner for unstructured content, body_sections prose, key moments index, signal chains, plugins pill list, related techniques. Updated App.tsx with public + admin routes and new navigation. Extended App.css with ~500 lines for all new components. + +## Verification + +TypeScript strict compilation passes with zero errors. Production build succeeds (40 modules, 191KB JS). All 4 expected page files exist. All 5 slice verification checks pass. Backend tests: 18/18 search+public API tests pass, 58/58 full suite passes with zero regressions. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc -b` | 0 | ✅ pass | 2500ms | +| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3400ms | +| 3 | `test -f frontend/src/api/public-client.ts && test -f frontend/src/pages/Home.tsx && test -f frontend/src/pages/SearchResults.tsx && test -f frontend/src/pages/TechniquePage.tsx` | 0 | ✅ pass | 50ms | +| 4 | `cd backend && python -m pytest tests/test_search.py tests/test_public_api.py -v` | 0 | ✅ pass | 10600ms | +| 5 | `cd backend && python -m pytest tests/ -v` | 0 | ✅ pass | 142900ms | + + +## Deviations + +The verification failures that triggered this auto-fix attempt were caused by running test commands from the project root instead of from the backend/ directory. The test files exist at backend/tests/ and pass correctly. + +## Known Issues + +None. + +## Files Created/Modified + +- `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` + + +## Deviations +The verification failures that triggered this auto-fix attempt were caused by running test commands from the project root instead of from the backend/ directory. The test files exist at backend/tests/ and pass correctly. + +## Known Issues +None. diff --git a/frontend/src/App.css b/frontend/src/App.css index bdab000..8114de8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -618,3 +618,699 @@ body { justify-content: space-between; } } + +/* ══════════════════════════════════════════════════════════════════════════════ + PUBLIC PAGES + ══════════════════════════════════════════════════════════════════════════════ */ + +/* ── Header brand link ────────────────────────────────────────────────────── */ + +.app-header__brand { + text-decoration: none; + color: inherit; +} + +.app-nav { + display: flex; + align-items: center; + gap: 1rem; +} + +.app-nav a { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.15s; +} + +.app-nav a:hover { + color: #fff; +} + +/* ── Home / Hero ──────────────────────────────────────────────────────────── */ + +.home-hero { + text-align: center; + padding: 3rem 1rem 2rem; +} + +.home-hero__title { + font-size: 2.25rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.375rem; +} + +.home-hero__subtitle { + font-size: 1rem; + color: #6b7280; + margin-bottom: 1.5rem; +} + +/* ── Search form ──────────────────────────────────────────────────────────── */ + +.search-container { + position: relative; + max-width: 36rem; + margin: 0 auto; +} + +.search-form { + display: flex; + gap: 0.5rem; +} + +.search-form--hero { + justify-content: center; +} + +.search-form--inline { + margin-bottom: 1.25rem; +} + +.search-input { + flex: 1; + padding: 0.625rem 1rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + font-size: 0.9375rem; + font-family: inherit; + color: #374151; + background: #fff; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.search-input--hero { + padding: 0.75rem 1.25rem; + font-size: 1.0625rem; + border-radius: 0.625rem; +} + +.btn--search { + background: #1a1a2e; + color: #fff; + border-color: #1a1a2e; + border-radius: 0.5rem; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.btn--search:hover { + background: #2d2d4e; +} + +/* ── Typeahead dropdown ───────────────────────────────────────────────────── */ + +.typeahead-dropdown { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + background: #fff; + border: 1px solid #e2e2e8; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 50; + overflow: hidden; +} + +.typeahead-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1rem; + text-decoration: none; + color: inherit; + transition: background 0.1s; +} + +.typeahead-item:hover { + background: #f3f4f6; +} + +.typeahead-item__title { + font-size: 0.875rem; + font-weight: 500; +} + +.typeahead-item__meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: #6b7280; +} + +.typeahead-item__type { + padding: 0.0625rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.typeahead-item__type--technique_page { + background: #dbeafe; + color: #1e40af; +} + +.typeahead-item__type--key_moment { + background: #fef3c7; + color: #92400e; +} + +.typeahead-see-all { + display: block; + padding: 0.5rem 1rem; + text-align: center; + font-size: 0.8125rem; + color: #6366f1; + text-decoration: none; + border-top: 1px solid #e2e2e8; + transition: background 0.1s; +} + +.typeahead-see-all:hover { + background: #f3f4f6; +} + +/* ── Navigation cards ─────────────────────────────────────────────────────── */ + +.nav-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + max-width: 36rem; + margin: 0 auto 2rem; +} + +.nav-card { + display: block; + padding: 1.5rem; + background: #fff; + border: 1px solid #e2e2e8; + border-radius: 0.625rem; + 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, transform 0.15s; +} + +.nav-card:hover { + border-color: #a5b4fc; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1); + transform: translateY(-1px); +} + +.nav-card__title { + font-size: 1.0625rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.nav-card__desc { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; +} + +/* ── Recently Added section ───────────────────────────────────────────────── */ + +.recent-section { + max-width: 36rem; + margin: 0 auto 2rem; +} + +.recent-section__title { + font-size: 1.125rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.recent-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.recent-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; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.recent-card:hover { + border-color: #a5b4fc; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1); +} + +.recent-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.recent-card__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.recent-card__summary { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; +} + +/* ── Search results page ──────────────────────────────────────────────────── */ + +.search-results-page { + max-width: 48rem; +} + +.search-fallback-banner { + padding: 0.5rem 0.75rem; + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 0.375rem; + font-size: 0.8125rem; + color: #92400e; + margin-bottom: 1rem; +} + +.search-group { + margin-bottom: 1.5rem; +} + +.search-group__title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: #374151; +} + +.search-group__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.search-result-card { + display: block; + padding: 1rem 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; +} + +.search-result-card:hover { + border-color: #a5b4fc; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1); +} + +.search-result-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; +} + +.search-result-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.search-result-card__summary { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; + margin-bottom: 0.375rem; +} + +.search-result-card__meta { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: #9ca3af; + flex-wrap: wrap; +} + +.search-result-card__tags { + display: inline-flex; + gap: 0.25rem; + margin-left: 0.25rem; +} + +/* ── Pills / tags ─────────────────────────────────────────────────────────── */ + +.pill { + display: inline-block; + padding: 0.0625rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 500; + background: #f3f4f6; + color: #374151; +} + +.pill--plugin { + background: #ede9fe; + color: #5b21b6; +} + +.pill-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.badge--category { + background: #dbeafe; + color: #1e40af; +} + +.badge--type { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.badge--type-technique_page { + background: #dbeafe; + color: #1e40af; +} + +.badge--type-key_moment { + background: #fef3c7; + color: #92400e; +} + +.badge--content-type { + background: #f3f4f6; + color: #374151; + font-size: 0.6875rem; +} + +.badge--quality { + font-size: 0.6875rem; + text-transform: capitalize; +} + +.badge--quality-structured { + background: #d1fae5; + color: #065f46; +} + +.badge--quality-unstructured { + background: #fef3c7; + color: #92400e; +} + +/* ── Technique page ───────────────────────────────────────────────────────── */ + +.technique-page { + max-width: 48rem; +} + +.technique-404 { + text-align: center; + padding: 3rem 1rem; +} + +.technique-404 h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-404 p { + color: #6b7280; + margin-bottom: 1.5rem; +} + +.technique-banner { + padding: 0.625rem 1rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + margin-bottom: 1rem; +} + +.technique-banner--amber { + background: #fef3c7; + border: 1px solid #fcd34d; + color: #92400e; +} + +.technique-header { + margin-bottom: 1.5rem; +} + +.technique-header__title { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.5rem; + line-height: 1.2; +} + +.technique-header__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.technique-header__tags { + display: inline-flex; + gap: 0.25rem; +} + +.technique-header__creator { + font-size: 0.875rem; + color: #6366f1; + text-decoration: none; +} + +.technique-header__creator:hover { + text-decoration: underline; +} + +/* ── Technique prose / sections ───────────────────────────────────────────── */ + +.technique-summary { + margin-bottom: 1.5rem; +} + +.technique-summary p { + font-size: 1rem; + color: #374151; + line-height: 1.6; +} + +.technique-prose { + margin-bottom: 2rem; +} + +.technique-prose__section { + margin-bottom: 1.5rem; +} + +.technique-prose__section h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-prose__section p { + font-size: 0.9375rem; + color: #374151; + line-height: 1.7; +} + +.technique-prose__json { + background: #f9fafb; + padding: 0.75rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + overflow-x: auto; + line-height: 1.5; +} + +/* ── Key moments list ─────────────────────────────────────────────────────── */ + +.technique-moments { + margin-bottom: 2rem; +} + +.technique-moments h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.technique-moments__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.technique-moment { + padding: 0.875rem 1rem; + background: #fff; + border: 1px solid #e2e2e8; + border-radius: 0.5rem; +} + +.technique-moment__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + flex-wrap: wrap; +} + +.technique-moment__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.technique-moment__time { + font-size: 0.75rem; + color: #6b7280; + font-variant-numeric: tabular-nums; +} + +.technique-moment__summary { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.5; +} + +/* ── Signal chains ────────────────────────────────────────────────────────── */ + +.technique-chains { + margin-bottom: 2rem; +} + +.technique-chains h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.technique-chain { + margin-bottom: 1rem; + padding: 1rem; + background: #fff; + border: 1px solid #e2e2e8; + border-radius: 0.5rem; +} + +.technique-chain h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.technique-chain__steps { + padding-left: 1.25rem; + font-size: 0.875rem; + line-height: 1.6; + color: #374151; +} + +/* ── Plugins ──────────────────────────────────────────────────────────────── */ + +.technique-plugins { + margin-bottom: 2rem; +} + +.technique-plugins h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +/* ── Related techniques ───────────────────────────────────────────────────── */ + +.technique-related { + margin-bottom: 2rem; +} + +.technique-related h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-related__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.technique-related__list a { + color: #6366f1; + text-decoration: none; + font-size: 0.9375rem; +} + +.technique-related__list a:hover { + text-decoration: underline; +} + +.technique-related__rel { + font-size: 0.75rem; + color: #9ca3af; + margin-left: 0.375rem; +} + +/* ── Public responsive ────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .home-hero__title { + font-size: 1.75rem; + } + + .nav-cards { + grid-template-columns: 1fr; + } + + .technique-header__title { + font-size: 1.375rem; + } + + .search-form { + flex-direction: column; + } + + .search-input--hero { + width: 100%; + } + + .app-nav { + gap: 0.75rem; + font-size: 0.8125rem; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2979145..a0747ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,7 @@ -import { Navigate, Route, Routes } from "react-router-dom"; +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 ReviewQueue from "./pages/ReviewQueue"; import MomentDetail from "./pages/MomentDetail"; import ModeToggle from "./components/ModeToggle"; @@ -7,20 +10,33 @@ export default function App() { return (
-

Chrysopedia Admin

+ +

Chrysopedia

+
- -
+ {/* Public routes */} + } /> + } /> + } /> + + {/* Admin routes */} } /> } /> - } /> + + {/* Fallback */} + } />
diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts new file mode 100644 index 0000000..e511d9c --- /dev/null +++ b/frontend/src/api/public-client.ts @@ -0,0 +1,250 @@ +/** + * Typed API client for Chrysopedia public endpoints. + * + * Mirrors backend schemas: SearchResponse, TechniquePageDetail, TopicCategory, CreatorBrowseItem. + * Uses the same request pattern as client.ts. + */ + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface SearchResultItem { + title: string; + slug: string; + type: string; + score: number; + summary: string; + creator_name: string; + creator_slug: string; + topic_category: string; + topic_tags: string[]; +} + +export interface SearchResponse { + items: SearchResultItem[]; + total: number; + query: string; + fallback_used: boolean; +} + +export interface KeyMomentSummary { + id: string; + title: string; + summary: string; + start_time: number; + end_time: number; + content_type: string; + plugins: string[] | null; +} + +export interface CreatorInfo { + name: string; + slug: string; + genres: string[] | null; +} + +export interface RelatedLinkItem { + target_title: string; + target_slug: string; + relationship: string; +} + +export interface TechniquePageDetail { + id: string; + title: string; + slug: string; + topic_category: string; + topic_tags: string[] | null; + summary: string | null; + body_sections: Record | null; + signal_chains: unknown[] | null; + plugins: string[] | null; + creator_id: string; + source_quality: string | null; + view_count: number; + review_status: string; + created_at: string; + updated_at: string; + key_moments: KeyMomentSummary[]; + creator_info: CreatorInfo | null; + related_links: RelatedLinkItem[]; +} + +export interface TechniqueListItem { + id: string; + title: string; + slug: string; + topic_category: string; + topic_tags: string[] | null; + summary: string | null; + creator_id: string; + source_quality: string | null; + view_count: number; + review_status: string; + created_at: string; + updated_at: string; +} + +export interface TechniqueListResponse { + items: TechniqueListItem[]; + total: number; + offset: number; + limit: number; +} + +export interface TopicSubTopic { + name: string; + technique_count: number; + creator_count: number; +} + +export interface TopicCategory { + name: string; + description: string; + sub_topics: TopicSubTopic[]; +} + +export interface CreatorBrowseItem { + id: string; + name: string; + slug: string; + genres: string[] | null; + folder_name: string; + view_count: number; + created_at: string; + updated_at: string; + technique_count: number; + video_count: number; +} + +export interface CreatorBrowseResponse { + items: CreatorBrowseItem[]; + total: number; + offset: number; + limit: number; +} + +export interface CreatorDetailResponse { + id: string; + name: string; + slug: string; + genres: string[] | null; + folder_name: string; + view_count: number; + created_at: string; + updated_at: string; + video_count: number; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const BASE = "/api/v1"; + +class ApiError extends Error { + constructor( + public status: number, + public detail: string, + ) { + super(`API ${status}: ${detail}`); + this.name = "ApiError"; + } +} + +async function request(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); + + if (!res.ok) { + let detail = res.statusText; + try { + const body: unknown = await res.json(); + if (typeof body === "object" && body !== null && "detail" in body) { + detail = String((body as { detail: unknown }).detail); + } + } catch { + // body not JSON — keep statusText + } + throw new ApiError(res.status, detail); + } + + return res.json() as Promise; +} + +// ── Search ─────────────────────────────────────────────────────────────────── + +export async function searchApi( + q: string, + scope?: string, + limit?: number, +): Promise { + const qs = new URLSearchParams({ q }); + if (scope) qs.set("scope", scope); + if (limit !== undefined) qs.set("limit", String(limit)); + return request(`${BASE}/search?${qs.toString()}`); +} + +// ── Techniques ─────────────────────────────────────────────────────────────── + +export interface TechniqueListParams { + limit?: number; + offset?: number; + category?: string; +} + +export async function fetchTechniques( + params: TechniqueListParams = {}, +): Promise { + const qs = new URLSearchParams(); + 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); + const query = qs.toString(); + return request( + `${BASE}/techniques${query ? `?${query}` : ""}`, + ); +} + +export async function fetchTechnique( + slug: string, +): Promise { + return request(`${BASE}/techniques/${slug}`); +} + +// ── Topics ─────────────────────────────────────────────────────────────────── + +export async function fetchTopics(): Promise { + return request(`${BASE}/topics`); +} + +// ── Creators ───────────────────────────────────────────────────────────────── + +export interface CreatorListParams { + sort?: string; + genre?: string; + limit?: number; + offset?: number; +} + +export async function fetchCreators( + params: CreatorListParams = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.sort) qs.set("sort", params.sort); + if (params.genre) qs.set("genre", params.genre); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + const query = qs.toString(); + return request( + `${BASE}/creators${query ? `?${query}` : ""}`, + ); +} + +export async function fetchCreator( + slug: string, +): Promise { + return request(`${BASE}/creators/${slug}`); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..90bcade --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,222 @@ +/** + * Home / landing page. + * + * Prominent search bar with 300ms debounced typeahead (top 5 results after 2+ chars), + * navigation cards for Topics and Creators, and a "Recently Added" section. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { + searchApi, + fetchTechniques, + type SearchResultItem, + type TechniqueListItem, +} from "../api/public-client"; + +export default function Home() { + const [query, setQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + const [recent, setRecent] = useState([]); + const [recentLoading, setRecentLoading] = useState(true); + const navigate = useNavigate(); + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + const dropdownRef = useRef(null); + + // Auto-focus search on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Load recently added techniques + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetchTechniques({ limit: 5 }); + if (!cancelled) setRecent(res.items); + } catch { + // silently ignore — not critical + } finally { + if (!cancelled) setRecentLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Close dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setShowDropdown(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + // Debounced typeahead + const handleInputChange = useCallback( + (value: string) => { + setQuery(value); + + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (value.length < 2) { + setSuggestions([]); + setShowDropdown(false); + return; + } + + debounceRef.current = setTimeout(() => { + void (async () => { + try { + const res = await searchApi(value, undefined, 5); + setSuggestions(res.items); + setShowDropdown(res.items.length > 0); + } catch { + setSuggestions([]); + setShowDropdown(false); + } + })(); + }, 300); + }, + [], + ); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (query.trim()) { + setShowDropdown(false); + navigate(`/search?q=${encodeURIComponent(query.trim())}`); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + setShowDropdown(false); + } + } + + return ( +
+ {/* Hero search */} +
+

Chrysopedia

+

+ Search techniques, key moments, and creators +

+ +
+
+ handleInputChange(e.target.value)} + onFocus={() => { + if (suggestions.length > 0) setShowDropdown(true); + }} + onKeyDown={handleKeyDown} + aria-label="Search techniques" + /> + +
+ + {showDropdown && suggestions.length > 0 && ( +
+ {suggestions.map((item) => ( + setShowDropdown(false)} + > + {item.title} + + + {item.type === "technique_page" ? "Technique" : "Key Moment"} + + {item.creator_name && ( + + {item.creator_name} + + )} + + + ))} + setShowDropdown(false)} + > + See all results for "{query}" + +
+ )} +
+
+ + {/* Navigation cards */} +
+ +

Topics

+

+ Browse techniques organized by category and sub-topic +

+ + +

Creators

+

+ Discover creators and their technique libraries +

+ +
+ + {/* Recently Added */} +
+

Recently Added

+ {recentLoading ? ( +
Loading…
+ ) : recent.length === 0 ? ( +
No techniques yet.
+ ) : ( +
+ {recent.map((t) => ( + + {t.title} + + + {t.topic_category} + + {t.summary && ( + + {t.summary.length > 100 + ? `${t.summary.slice(0, 100)}…` + : t.summary} + + )} + + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/SearchResults.tsx b/frontend/src/pages/SearchResults.tsx new file mode 100644 index 0000000..120b671 --- /dev/null +++ b/frontend/src/pages/SearchResults.tsx @@ -0,0 +1,184 @@ +/** + * Full search results page. + * + * Reads `q` from URL search params, calls searchApi, groups results by type + * (technique_pages first, then key_moments). Shows fallback banner when + * keyword search was used. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useSearchParams, useNavigate } from "react-router-dom"; +import { searchApi, type SearchResultItem } from "../api/public-client"; + +export default function SearchResults() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const q = searchParams.get("q") ?? ""; + + const [results, setResults] = useState([]); + const [fallbackUsed, setFallbackUsed] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [localQuery, setLocalQuery] = useState(q); + const debounceRef = useRef | null>(null); + + const doSearch = useCallback(async (query: string) => { + if (!query.trim()) { + setResults([]); + setFallbackUsed(false); + return; + } + + setLoading(true); + setError(null); + try { + const res = await searchApi(query.trim()); + setResults(res.items); + setFallbackUsed(res.fallback_used); + } catch (err) { + setError(err instanceof Error ? err.message : "Search failed"); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + // Search when URL param changes + useEffect(() => { + setLocalQuery(q); + if (q) void doSearch(q); + }, [q, doSearch]); + + function handleInputChange(value: string) { + setLocalQuery(value); + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + if (value.trim()) { + navigate(`/search?q=${encodeURIComponent(value.trim())}`, { + replace: true, + }); + } + }, 400); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (localQuery.trim()) { + navigate(`/search?q=${encodeURIComponent(localQuery.trim())}`, { + replace: true, + }); + } + } + + // Group results by type + const techniqueResults = results.filter((r) => r.type === "technique_page"); + const momentResults = results.filter((r) => r.type === "key_moment"); + + return ( +
+ {/* Inline search bar */} +
+ handleInputChange(e.target.value)} + aria-label="Refine search" + /> + +
+ + {/* Status */} + {loading &&
Searching…
} + {error &&
Error: {error}
} + + {/* Fallback banner */} + {!loading && fallbackUsed && results.length > 0 && ( +
+ Showing keyword results — semantic search unavailable +
+ )} + + {/* No results */} + {!loading && !error && q && results.length === 0 && ( +
+

No results found for "{q}"

+
+ )} + + {/* Technique pages */} + {techniqueResults.length > 0 && ( +
+

+ Techniques ({techniqueResults.length}) +

+
+ {techniqueResults.map((item) => ( + + ))} +
+
+ )} + + {/* Key moments */} + {momentResults.length > 0 && ( +
+

+ Key Moments ({momentResults.length}) +

+
+ {momentResults.map((item, i) => ( + + ))} +
+
+ )} +
+ ); +} + +function SearchResultCard({ item }: { item: SearchResultItem }) { + return ( + +
+ {item.title} + + {item.type === "technique_page" ? "Technique" : "Key Moment"} + +
+ {item.summary && ( +

+ {item.summary.length > 200 + ? `${item.summary.slice(0, 200)}…` + : item.summary} +

+ )} +
+ {item.creator_name && {item.creator_name}} + {item.topic_category && ( + <> + · + {item.topic_category} + + )} + {item.topic_tags.length > 0 && ( + + {item.topic_tags.map((tag) => ( + + {tag} + + ))} + + )} +
+ + ); +} diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx new file mode 100644 index 0000000..a1bd890 --- /dev/null +++ b/frontend/src/pages/TechniquePage.tsx @@ -0,0 +1,260 @@ +/** + * Technique page detail view. + * + * Fetches a single technique by slug. Renders: + * - Header with title, category badge, tags, creator link, source quality + * - Amber banner for unstructured (livestream-sourced) content + * - Study guide prose from body_sections JSONB + * - Key moments index + * - Signal chains (if present) + * - Plugins referenced (if present) + * - Related techniques (if present) + * - Loading and 404 states + */ + +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + fetchTechnique, + type TechniquePageDetail as TechniqueDetail, +} from "../api/public-client"; + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export default function TechniquePage() { + const { slug } = useParams<{ slug: string }>(); + const [technique, setTechnique] = useState(null); + 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 data = await fetchTechnique(slug); + if (!cancelled) setTechnique(data); + } catch (err) { + if (!cancelled) { + if ( + err instanceof Error && + err.message.includes("404") + ) { + setNotFound(true); + } else { + setError( + err instanceof Error ? err.message : "Failed to load technique", + ); + } + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [slug]); + + if (loading) { + return
Loading technique…
; + } + + if (notFound) { + return ( +
+

Technique Not Found

+

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

+ + Back to Home + +
+ ); + } + + if (error || !technique) { + return ( +
+ Error: {error ?? "Unknown error"} +
+ ); + } + + return ( +
+ {/* Back link */} + + ← Back + + + {/* Unstructured content warning */} + {technique.source_quality === "unstructured" && ( +
+ ⚠ This technique was sourced from a livestream and may have less + structured content. +
+ )} + + {/* Header */} +
+

{technique.title}

+
+ + {technique.topic_category} + + {technique.topic_tags && technique.topic_tags.length > 0 && ( + + {technique.topic_tags.map((tag) => ( + + {tag} + + ))} + + )} + {technique.creator_info && ( + + by {technique.creator_info.name} + + )} + {technique.source_quality && ( + + {technique.source_quality} + + )} +
+
+ + {/* Summary */} + {technique.summary && ( +
+

{technique.summary}

+
+ )} + + {/* Study guide prose — body_sections */} + {technique.body_sections && + Object.keys(technique.body_sections).length > 0 && ( +
+ {Object.entries(technique.body_sections).map( + ([sectionTitle, content]) => ( +
+

{sectionTitle}

+ {typeof content === "string" ? ( +

{content}

+ ) : typeof content === "object" && content !== null ? ( +
+                      {JSON.stringify(content, null, 2)}
+                    
+ ) : ( +

{String(content)}

+ )} +
+ ), + )} +
+ )} + + {/* Key moments */} + {technique.key_moments.length > 0 && ( +
+

Key Moments

+
    + {technique.key_moments.map((km) => ( +
  1. +
    + {km.title} + + {formatTime(km.start_time)} – {formatTime(km.end_time)} + + + {km.content_type} + +
    +

    {km.summary}

    +
  2. + ))} +
+
+ )} + + {/* Signal chains */} + {technique.signal_chains && + technique.signal_chains.length > 0 && ( +
+

Signal Chains

+ {technique.signal_chains.map((chain, i) => { + const chainObj = chain as Record; + const chainName = + typeof chainObj["name"] === "string" + ? chainObj["name"] + : `Chain ${i + 1}`; + const steps = Array.isArray(chainObj["steps"]) + ? (chainObj["steps"] as string[]) + : []; + return ( +
+

{chainName}

+ {steps.length > 0 && ( +
    + {steps.map((step, j) => ( +
  1. {String(step)}
  2. + ))} +
+ )} +
+ ); + })} +
+ )} + + {/* Plugins */} + {technique.plugins && technique.plugins.length > 0 && ( +
+

Plugins Referenced

+
+ {technique.plugins.map((plugin) => ( + + {plugin} + + ))} +
+
+ )} + + {/* Related techniques */} + {technique.related_links.length > 0 && ( +
+

Related Techniques

+
    + {technique.related_links.map((link) => ( +
  • + + {link.target_title} + + + ({link.relationship}) + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 5d0c952..abb36cf 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/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.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/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