feat: Split key moment card header into standalone h3 title and flex-ro…

- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-03-30 08:55:48 +00:00
parent aa71387ad5
commit c6efec8363
33 changed files with 4637 additions and 10 deletions

View file

@ -24,3 +24,4 @@
| D016 | M002/S01 | architecture | Embedding service for the pipeline and search — OpenWebUI doesn't serve /v1/embeddings | Add Ollama container (ollama/ollama:latest) to the compose stack running nomic-embed-text on CPU | The FYN OpenWebUI instance at chat.forgetyour.name returns 404/500 on /v1/embeddings. No Ollama instance exists on the network. Ollama provides the standard OpenAI-compatible /v1/embeddings endpoint that both EmbeddingClient and SearchService expect. | Yes | agent |
| D017 | | frontend | CSS theming approach for dark mode | 77 semantic CSS custom properties in :root block, all hardcoded colors replaced with var(--*) references | A complete variable-based palette enables theme switching, ensures consistency, and makes future color changes single-point edits. 77 tokens (vs ~30 initially planned) were needed to cover all semantic contexts — badges, buttons, surfaces, text, accents, shadows, and status states. Cyan (#22d3ee) replaces indigo as the accent color throughout. | Yes | agent |
| D018 | M004/S04 | architecture | Version snapshot failure handling strategy in stage 5 | Best-effort versioning — snapshot INSERT failure logs ERROR but does not block page update (existing content overwrites proceed regardless) | Versioning is a diagnostic/benchmarking feature. Losing a version snapshot is far less damaging than failing the entire page synthesis. Follows the same non-blocking side-effect pattern established in D005 for embedding/Qdrant failures. | Yes | agent |
| D019 | M005/S02 | frontend-layout | Technique page layout structure | CSS grid 2-column layout: 1fr main + 22rem sticky sidebar, collapsing to single column at 768px. Page max-width widened from 48rem to 64rem. | 22rem sidebar provides enough room for moment cards and plugin lists without cramming. 64rem total width accommodates both columns comfortably on standard desktop displays. Sticky sidebar keeps navigation aids visible while scrolling prose. 768px breakpoint aligns with existing mobile styles in the codebase. | Yes | agent |

View file

@ -24,6 +24,7 @@ Four milestones complete. The system is deployed and running on ub01 at `http://
- **Deployment:** Docker Compose on ub01, nginx reverse proxy on nuc01
- **Pipeline admin dashboard** — Admin page at `/admin/pipeline` with video list, status badges, retrigger/revoke controls, expandable event log with token usage, collapsible JSON response viewer, and live worker status indicator. Pipeline events instrumented via `PipelineEvent` model (stage, event_type, token counts, JSONB payload).
- **Technique page 2-column layout** — Prose content (summary + study guide) on left, sidebar (key moments, signal chains, plugins, related techniques) on right at desktop widths. Sticky sidebar. Collapses to single column at ≤768px.
### Milestone History

View file

@ -7,5 +7,5 @@ Add a pipeline management dashboard under admin (trigger, pause, monitor, view l
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Pipeline Admin Dashboard | high | — | ✅ | Admin page at /admin/pipeline shows video list with status, retrigger button, and log viewer with token counts and expandable JSON responses |
| S02 | Technique Page 2-Column Layout | medium | — | | Technique page shows prose content on left, plugins/moments/chains on right at desktop widths. Single column on mobile. |
| S02 | Technique Page 2-Column Layout | medium | — | | Technique page shows prose content on left, plugins/moments/chains on right at desktop widths. Single column on mobile. |
| S03 | Key Moment Card Redesign | low | S02 | ⬜ | Key moment cards show title prominently on its own line, with source file, timestamp, and type badge on a clean secondary row |

View file

@ -0,0 +1,77 @@
---
id: S02
parent: M005
milestone: M005
provides:
- 2-column technique page layout with 22rem sidebar containing key moments, signal chains, plugins, and related techniques
requires:
[]
affects:
- S03
key_files:
- frontend/src/App.css
- frontend/src/pages/TechniquePage.tsx
key_decisions:
- Sidebar fixed at 22rem width with sticky positioning at 1.5rem from top
- Page max-width widened from 48rem to 64rem to accommodate sidebar
- 768px breakpoint for responsive collapse aligns with existing mobile styles
patterns_established:
- CSS grid 2-column layout pattern: 1fr + fixed sidebar with sticky positioning and responsive collapse
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M005/slices/S02/tasks/T01-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-30T08:49:31.884Z
blocker_discovered: false
---
# S02: Technique Page 2-Column Layout
**Technique pages now display prose content (summary + study guide) in a left column and sidebar content (key moments, signal chains, plugins, related techniques) in a right column at desktop widths, collapsing to single column on mobile.**
## What Happened
This slice restructured the technique page from a single-column flow to a 2-column CSS grid layout. The .technique-page max-width was widened from 48rem to 64rem to accommodate the sidebar. A new .technique-columns grid wrapper splits content into a 1fr main column and 22rem sidebar column with 2rem gap. The sidebar uses sticky positioning (top: 1.5rem) so navigation aids remain visible while scrolling long prose. At ≤768px the grid collapses to a single column with static positioning, preserving the existing mobile experience.
In TechniquePage.tsx, the content below the header/report-modal was wrapped in the grid container. The main column contains the Summary section and Body Sections (study guide prose). The sidebar column contains Key Moments, Signal Chains, Plugins, and Related Techniques. The header and report modal remain above the grid at full width.
All new CSS uses only structural properties (grid, gap, positioning) with no hardcoded colors, staying consistent with the existing custom property system. TypeScript compilation and production build both pass cleanly. The Docker web container was rebuilt and restarted on ub01.
## Verification
TypeScript compilation (tsc --noEmit) passed with zero errors. Production build (npm run build) succeeded — 46 modules, 658ms. Docker web container (chrysopedia-web-8096) rebuilt, restarted, and healthy. Browser verification confirmed 2-column layout at desktop width and single-column collapse at 375px mobile width. No hardcoded colors in new CSS rules. All plan must-haves satisfied.
## Requirements Advanced
- R006 — Technique page now has structured 2-column layout separating prose from reference material, improving readability and navigation
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Docker service name is chrysopedia-web not chrysopedia-web-8096 as plan stated (compose service vs container name). Had to run npm install on ub01 before type checking. TechniquePage.tsx was 505 lines not ~310 as estimated — component is larger than plan assumed.
## Known Limitations
None.
## Follow-ups
S03 (Key Moment Card Redesign) depends on this slice — the sidebar column constrains card width to 22rem which may affect card layout decisions.
## Files Created/Modified
- `frontend/src/App.css` — Widened .technique-page max-width from 48rem to 64rem. Added .technique-columns CSS grid (1fr 22rem), .technique-columns__main, .technique-columns__sidebar with sticky positioning, and @media 768px breakpoint for single-column collapse.
- `frontend/src/pages/TechniquePage.tsx` — Wrapped content sections in .technique-columns grid. Summary + body sections in __main div. Key moments + signal chains + plugins + related techniques in __sidebar div.

View file

@ -0,0 +1,58 @@
# S02: Technique Page 2-Column Layout — UAT
**Milestone:** M005
**Written:** 2026-03-30T08:49:31.884Z
## UAT: Technique Page 2-Column Layout
### Preconditions
- Chrysopedia web UI accessible at http://ub01:8096
- At least one technique page exists with key moments, signal chains, or plugins populated
### Test Cases
#### TC1: Desktop 2-Column Layout
1. Open http://ub01:8096/techniques/ in a desktop browser (viewport ≥1024px wide)
2. Click any technique with key moments listed
3. **Expected:** Page header (title, tags, creator info) spans full width
4. **Expected:** Below the header, content splits into two columns — prose (summary + study guide sections) on the left, sidebar (key moments, signal chains, plugins, related techniques) on the right
5. **Expected:** Sidebar is approximately 22rem wide; main content fills remaining space
6. **Expected:** 2rem gap visible between columns
#### TC2: Sticky Sidebar
1. On the same technique page at desktop width, scroll down through the prose content
2. **Expected:** The sidebar remains pinned near the top of the viewport (sticky at ~1.5rem from top) while the main content scrolls
3. **Expected:** If sidebar content is taller than the viewport, it scrolls independently
#### TC3: Mobile Single-Column Collapse
1. Resize browser window to ≤768px width (or use DevTools mobile emulation at 375px)
2. **Expected:** Layout collapses to a single column — all content stacks vertically
3. **Expected:** Sidebar content (key moments, etc.) appears below the prose content, not beside it
4. **Expected:** Sidebar is no longer sticky — it scrolls naturally with the page
#### TC4: Page Width
1. At desktop width, inspect the technique page container
2. **Expected:** Max-width is 64rem (was previously 48rem), providing enough horizontal room for both columns
#### TC5: No Visual Regressions
1. Navigate to a technique page with all sections populated (summary, body sections, key moments, signal chains, plugins, related techniques)
2. **Expected:** All sections render correctly — no overlapping text, no content cut off, no broken spacing
3. **Expected:** Existing styling (colors, typography, badges, cards) is unchanged
4. **Expected:** Dark theme custom properties still apply — no raw hex colors visible in new layout elements
#### TC6: Empty Sidebar Sections
1. Navigate to a technique page that has no plugins or no signal chains
2. **Expected:** Empty sections are absent from the sidebar (not rendered as blank space)
3. **Expected:** The remaining sidebar sections display correctly without extra gaps
### Edge Cases
#### EC1: Very Long Prose Content
1. Find or create a technique with many body sections (5+ sub-headings)
2. **Expected:** Main column handles the long content naturally — no overflow, no grid blowout
3. **Expected:** Sidebar sticky behavior works correctly during extended scrolling
#### EC2: Technique with Many Key Moments
1. Find a technique with 10+ key moments
2. **Expected:** Sidebar handles the long list — scrollable within the viewport if needed
3. **Expected:** At mobile widths, the moment list displays fully in the single-column flow

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M005/S02/T01",
"timestamp": 1774860475861,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 5,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 809,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 109,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,57 @@
# S03: Key Moment Card Redesign
**Goal:** Clean up key moment card layout for consistent readability
**Goal:** Key moment cards show title prominently on its own line, with source file, timestamp, and type badge on a clean secondary metadata row.
**Demo:** After this: Key moment cards show title prominently on its own line, with source file, timestamp, and type badge on a clean secondary row
## Tasks
- [x] **T01: Split key moment card header into standalone h3 title and flex-row metadata div, replacing single-row __header layout** — Restructure the key moment card in TechniquePage.tsx and App.css so the title is a standalone prominent element and the metadata (source filename, timestamp, content type badge) sits on a clean secondary row below it.
## Context
The technique page sidebar (22rem, established by S02 via D019) contains key moment cards. Currently the card header is a single flex row with `flex-wrap: wrap` containing title, source, timestamp, and badge as siblings. At 22rem width this wraps unpredictably. The fix splits the header into two explicit rows.
## Steps
1. Read `frontend/src/pages/TechniquePage.tsx` around lines 413430 to locate the key moment card JSX (the `.technique-moment` list items inside the Key Moments section within `.technique-columns__sidebar`).
2. Restructure the card JSX:
- Pull the title `<span className="technique-moment__title">` out of the `__header` div and place it before it as a standalone element. Change from `<span>` to `<h3>` for semantic correctness (it's a sub-heading within the Key Moments `<h2>` section).
- Rename the remaining `<div className="technique-moment__header">` to `<div className="technique-moment__meta">` — it now contains only source, timestamp, and badge.
- The `<p className="technique-moment__summary">` stays unchanged after the meta div.
- Target structure per card:
```
<li className="technique-moment">
<h3 className="technique-moment__title">{km.title}</h3>
<div className="technique-moment__meta">
{km.video_filename && <span className="technique-moment__source">...}
<span className="technique-moment__time">...</span>
<span className="badge badge--content-type">...</span>
</div>
<p className="technique-moment__summary">{km.summary}</p>
</li>
```
3. Read `frontend/src/App.css` around lines 13371380 to locate the key moment card styles.
4. Update CSS:
- `.technique-moment__title`: Change to `display: block; margin: 0 0 0.25rem 0; font-size: 0.9375rem; font-weight: 600; line-height: 1.3;` — block display gives it its own line, bottom margin separates from meta row. Reset h3 default margins.
- Add `.technique-moment__meta` with the same styles as the current `__header`: `display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; flex-wrap: wrap;`
- Remove or comment out `.technique-moment__header` (no longer used in JSX). If other code references it, keep it but it should be dead CSS after the JSX change.
- All other moment card styles (`.technique-moment__time`, `.technique-moment__source`, `.technique-moment__summary`, `.badge--content-type`) remain unchanged.
5. Verify:
- Run `cd frontend && npx tsc --noEmit` — zero errors
- Run `cd frontend && npm run build` — succeeds
- Grep for any remaining references to `technique-moment__header` in .tsx files to confirm it's fully replaced
- Confirm no hardcoded hex/rgba values in new or modified CSS rules
## Constraints
- Sidebar width is fixed at 22rem (D019). Card must look good within this constraint.
- Do NOT modify `.badge` or `.badge--content-type` classes — they're shared across the app.
- Preserve the `<ol>` list wrapper and `<li>` card container structure.
- Use only existing CSS custom properties for any color values.
- The `<h3>` for the title should not introduce unwanted default browser margins — reset with explicit margin in CSS.
- Estimate: 20m
- Files: frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
- Verify: cd frontend && npx tsc --noEmit && npm run build && ! grep -q 'technique-moment__header' src/pages/TechniquePage.tsx && echo 'PASS'

View file

@ -0,0 +1,72 @@
# S03: Key Moment Card Redesign — Research
**Date:** 2026-03-30
## Summary
The key moment cards in the technique page sidebar need a layout adjustment: move the title to its own prominent line, then show source filename, timestamp, and content type badge on a clean secondary metadata row. This is a straightforward CSS + minor JSX restructure within a single component file and one CSS file. No API changes, no new data fields, no new dependencies.
The cards currently render inside a 22rem sidebar (established by S02). The `.technique-moment__header` is a flex row with `flex-wrap: wrap` containing title, source, timestamp, and badge all inline. At 22rem width this wraps unpredictably. The fix is to split the header into two explicit rows: a title row and a metadata row.
## Recommendation
Restructure the card JSX to separate the title from the metadata. The title becomes a standalone element outside the metadata row. The metadata row (source filename, timestamp, content type badge) becomes its own flex container. This gives the title full width and visual prominence, while metadata items stay compact on a single secondary line.
No need for new CSS custom properties — the existing `--color-text-secondary`, `--color-text-muted`, and badge variables cover everything. The summary paragraph below the header remains unchanged.
## Implementation Landscape
### Key Files
- `frontend/src/pages/TechniquePage.tsx` (lines 413430) — Key moment card JSX. Currently renders a single `.technique-moment__header` div with title, source, timestamp, and badge as siblings. Needs restructuring: title pulled out of header, header renamed/repurposed to metadata row.
- `frontend/src/App.css` (lines 13371380) — Key moment card styles. `.technique-moment__header` is `display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap`. Needs: title gets its own block-level style, header becomes a metadata-only flex row with smaller gap.
- `frontend/src/api/public-client.ts` (line 29) — `KeyMomentSummary` interface. No changes needed — all required fields (`title`, `video_filename`, `start_time`, `end_time`, `content_type`) already exist.
### Current Card Structure (JSX)
```
<li className="technique-moment">
<div className="technique-moment__header"> ← single flex row
<span className="technique-moment__title"> ← title inline with metadata
<span className="technique-moment__source"> ← source filename
<span className="technique-moment__time"> ← timestamp range
<span className="badge badge--content-type"> ← type badge
</div>
<p className="technique-moment__summary"> ← summary text
</li>
```
### Target Card Structure (JSX)
```
<li className="technique-moment">
<h3 className="technique-moment__title"> ← title on its own line, prominent
<div className="technique-moment__meta"> ← metadata row
<span className="technique-moment__source"> ← source filename
<span className="technique-moment__time"> ← timestamp range
<span className="badge badge--content-type"> ← type badge
</div>
<p className="technique-moment__summary"> ← summary text (unchanged)
</li>
```
### Build Order
1. Restructure JSX in TechniquePage.tsx — pull title out of header div, rename header to `__meta`, change title from `<span>` to block-level element
2. Update CSS in App.css — adjust `.technique-moment__title` to block display with bottom margin, add `.technique-moment__meta` as the flex row (inheriting current `__header` styles with adjustments), remove or redirect `__header` styles
3. Verify at desktop (22rem sidebar) and mobile (single column) widths
Single task — the JSX and CSS changes are tightly coupled and should be done together.
### Verification Approach
- `npx tsc --noEmit` — TypeScript compiles cleanly
- `npm run build` — production build succeeds
- Visual check at desktop width: title is on its own line, metadata items (source, timestamp, badge) are on a secondary row
- Visual check at mobile width (≤768px): card still renders cleanly in single-column layout
- No hardcoded colors in new/modified CSS rules (all use existing custom properties)
## Constraints
- Sidebar width is fixed at 22rem (D019). Card must look good within this constraint.
- Existing badge classes (`.badge`, `.badge--content-type`) must not be modified — they're shared across the app.
- No new CSS custom properties needed — existing color tokens cover the design.
- The `<ol>` list wrapper and `<li>` card container structure should be preserved for semantic correctness.

View file

@ -0,0 +1,70 @@
---
estimated_steps: 38
estimated_files: 2
skills_used: []
---
# T01: Restructure key moment card layout: title on own line, metadata on secondary row
Restructure the key moment card in TechniquePage.tsx and App.css so the title is a standalone prominent element and the metadata (source filename, timestamp, content type badge) sits on a clean secondary row below it.
## Context
The technique page sidebar (22rem, established by S02 via D019) contains key moment cards. Currently the card header is a single flex row with `flex-wrap: wrap` containing title, source, timestamp, and badge as siblings. At 22rem width this wraps unpredictably. The fix splits the header into two explicit rows.
## Steps
1. Read `frontend/src/pages/TechniquePage.tsx` around lines 413430 to locate the key moment card JSX (the `.technique-moment` list items inside the Key Moments section within `.technique-columns__sidebar`).
2. Restructure the card JSX:
- Pull the title `<span className="technique-moment__title">` out of the `__header` div and place it before it as a standalone element. Change from `<span>` to `<h3>` for semantic correctness (it's a sub-heading within the Key Moments `<h2>` section).
- Rename the remaining `<div className="technique-moment__header">` to `<div className="technique-moment__meta">` — it now contains only source, timestamp, and badge.
- The `<p className="technique-moment__summary">` stays unchanged after the meta div.
- Target structure per card:
```
<li className="technique-moment">
<h3 className="technique-moment__title">{km.title}</h3>
<div className="technique-moment__meta">
{km.video_filename && <span className="technique-moment__source">...}
<span className="technique-moment__time">...</span>
<span className="badge badge--content-type">...</span>
</div>
<p className="technique-moment__summary">{km.summary}</p>
</li>
```
3. Read `frontend/src/App.css` around lines 13371380 to locate the key moment card styles.
4. Update CSS:
- `.technique-moment__title`: Change to `display: block; margin: 0 0 0.25rem 0; font-size: 0.9375rem; font-weight: 600; line-height: 1.3;` — block display gives it its own line, bottom margin separates from meta row. Reset h3 default margins.
- Add `.technique-moment__meta` with the same styles as the current `__header`: `display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; flex-wrap: wrap;`
- Remove or comment out `.technique-moment__header` (no longer used in JSX). If other code references it, keep it but it should be dead CSS after the JSX change.
- All other moment card styles (`.technique-moment__time`, `.technique-moment__source`, `.technique-moment__summary`, `.badge--content-type`) remain unchanged.
5. Verify:
- Run `cd frontend && npx tsc --noEmit` — zero errors
- Run `cd frontend && npm run build` — succeeds
- Grep for any remaining references to `technique-moment__header` in .tsx files to confirm it's fully replaced
- Confirm no hardcoded hex/rgba values in new or modified CSS rules
## Constraints
- Sidebar width is fixed at 22rem (D019). Card must look good within this constraint.
- Do NOT modify `.badge` or `.badge--content-type` classes — they're shared across the app.
- Preserve the `<ol>` list wrapper and `<li>` card container structure.
- Use only existing CSS custom properties for any color values.
- The `<h3>` for the title should not introduce unwanted default browser margins — reset with explicit margin in CSS.
## Inputs
- ``frontend/src/pages/TechniquePage.tsx` — current key moment card JSX with single-row __header layout`
- ``frontend/src/App.css` — current key moment card styles including __header flex row`
## Expected Output
- ``frontend/src/pages/TechniquePage.tsx` — restructured card JSX with title as standalone h3, metadata in __meta div`
- ``frontend/src/App.css` — updated styles: __title as block element, new __meta flex row, __header removed or dead`
## Verification
cd frontend && npx tsc --noEmit && npm run build && ! grep -q 'technique-moment__header' src/pages/TechniquePage.tsx && echo 'PASS'

View file

@ -0,0 +1,80 @@
---
id: T01
parent: S03
milestone: M005
provides: []
requires: []
affects: []
key_files: ["frontend/src/pages/TechniquePage.tsx", "frontend/src/App.css"]
key_decisions: ["Changed title element from span to h3 for semantic correctness within Key Moments h2 section", "Renamed __header to __meta since it now contains only metadata"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "TypeScript compilation (tsc --noEmit) passes with zero errors. Vite production build succeeds. No remaining references to technique-moment__header in TSX files. No hardcoded hex/rgba values in modified CSS. Full composite verification command from task plan returns PASS."
completed_at: 2026-03-30T08:55:23.703Z
blocker_discovered: false
---
# T01: Split key moment card header into standalone h3 title and flex-row metadata div, replacing single-row __header layout
> Split key moment card header into standalone h3 title and flex-row metadata div, replacing single-row __header layout
## What Happened
---
id: T01
parent: S03
milestone: M005
key_files:
- frontend/src/pages/TechniquePage.tsx
- frontend/src/App.css
key_decisions:
- Changed title element from span to h3 for semantic correctness within Key Moments h2 section
- Renamed __header to __meta since it now contains only metadata
duration: ""
verification_result: passed
completed_at: 2026-03-30T08:55:23.704Z
blocker_discovered: false
---
# T01: Split key moment card header into standalone h3 title and flex-row metadata div, replacing single-row __header layout
**Split key moment card header into standalone h3 title and flex-row metadata div, replacing single-row __header layout**
## What Happened
Restructured the key moment card in TechniquePage.tsx and App.css. Extracted the title from the __header flex row into a standalone h3 element, renamed __header to __meta for the remaining metadata elements (source filename, timestamp, content type badge). Updated CSS: __title now uses display:block with explicit h3 margin reset; __meta inherits the previous __header flex layout. Synced full frontend source tree from ub01 to enable local TypeScript and Vite build verification.
## Verification
TypeScript compilation (tsc --noEmit) passes with zero errors. Vite production build succeeds. No remaining references to technique-moment__header in TSX files. No hardcoded hex/rgba values in modified CSS. Full composite verification command from task plan returns PASS.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms |
| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3500ms |
| 3 | `! grep -q 'technique-moment__header' frontend/src/pages/TechniquePage.tsx` | 0 | ✅ pass | 100ms |
| 4 | `Full composite verification (tsc + build + grep)` | 0 | ✅ pass | 6300ms |
## Deviations
Synced full frontend source tree and build config from ub01 to enable local tsc and vite build verification — local checkout only had the two target files.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/pages/TechniquePage.tsx`
- `frontend/src/App.css`
## Deviations
Synced full frontend source tree and build config from ub01 to enable local tsc and vite build verification — local checkout only had the two target files.
## Known Issues
None.

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a12" />
<title>Chrysopedia</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1888
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "chrysopedia-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.3",
"vite": "^6.0.3"
}
}

View file

@ -1341,7 +1341,15 @@ body {
border-radius: 0.5rem;
}
.technique-moment__header {
.technique-moment__title {
display: block;
margin: 0 0 0.25rem 0;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.3;
}
.technique-moment__meta {
display: flex;
align-items: center;
gap: 0.5rem;
@ -1349,11 +1357,6 @@ body {
flex-wrap: wrap;
}
.technique-moment__title {
font-size: 0.9375rem;
font-weight: 600;
}
.technique-moment__time {
font-size: 0.75rem;
color: var(--color-text-secondary);

193
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,193 @@
/**
* Typed API client for Chrysopedia review queue endpoints.
*
* All functions use fetch() with JSON handling and throw on non-OK responses.
* Base URL is empty so requests go through the Vite dev proxy or nginx in prod.
*/
// ── Types ───────────────────────────────────────────────────────────────────
export interface KeyMomentRead {
id: string;
source_video_id: string;
technique_page_id: string | null;
title: string;
summary: string;
start_time: number;
end_time: number;
content_type: string;
plugins: string[] | null;
raw_transcript: string | null;
review_status: string;
created_at: string;
updated_at: string;
}
export interface ReviewQueueItem extends KeyMomentRead {
video_filename: string;
creator_name: string;
}
export interface ReviewQueueResponse {
items: ReviewQueueItem[];
total: number;
offset: number;
limit: number;
}
export interface ReviewStatsResponse {
pending: number;
approved: number;
edited: number;
rejected: number;
}
export interface ReviewModeResponse {
review_mode: boolean;
}
export interface MomentEditRequest {
title?: string;
summary?: string;
start_time?: number;
end_time?: number;
content_type?: string;
plugins?: string[];
}
export interface MomentSplitRequest {
split_time: number;
}
export interface MomentMergeRequest {
target_moment_id: string;
}
export interface QueueParams {
status?: string;
offset?: number;
limit?: number;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const BASE = "/api/v1/review";
class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(`API ${status}: ${detail}`);
this.name = "ApiError";
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail ?? detail;
} catch {
// body not JSON — keep statusText
}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<T>;
}
// ── Queue ────────────────────────────────────────────────────────────────────
export async function fetchQueue(
params: QueueParams = {},
): Promise<ReviewQueueResponse> {
const qs = new URLSearchParams();
if (params.status) qs.set("status", params.status);
if (params.offset !== undefined) qs.set("offset", String(params.offset));
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const query = qs.toString();
return request<ReviewQueueResponse>(
`${BASE}/queue${query ? `?${query}` : ""}`,
);
}
export async function fetchMoment(
momentId: string,
): Promise<ReviewQueueItem> {
return request<ReviewQueueItem>(`${BASE}/moments/${momentId}`);
}
export async function fetchStats(): Promise<ReviewStatsResponse> {
return request<ReviewStatsResponse>(`${BASE}/stats`);
}
// ── Actions ──────────────────────────────────────────────────────────────────
export async function approveMoment(id: string): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}/approve`, {
method: "POST",
});
}
export async function rejectMoment(id: string): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}/reject`, {
method: "POST",
});
}
export async function editMoment(
id: string,
data: MomentEditRequest,
): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function splitMoment(
id: string,
splitTime: number,
): Promise<KeyMomentRead[]> {
const body: MomentSplitRequest = { split_time: splitTime };
return request<KeyMomentRead[]>(`${BASE}/moments/${id}/split`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function mergeMoments(
id: string,
targetId: string,
): Promise<KeyMomentRead> {
const body: MomentMergeRequest = { target_moment_id: targetId };
return request<KeyMomentRead>(`${BASE}/moments/${id}/merge`, {
method: "POST",
body: JSON.stringify(body),
});
}
// ── Mode ─────────────────────────────────────────────────────────────────────
export async function getReviewMode(): Promise<ReviewModeResponse> {
return request<ReviewModeResponse>(`${BASE}/mode`);
}
export async function setReviewMode(
enabled: boolean,
): Promise<ReviewModeResponse> {
return request<ReviewModeResponse>(`${BASE}/mode`, {
method: "PUT",
body: JSON.stringify({ review_mode: enabled }),
});
}

View file

@ -0,0 +1,59 @@
/**
* Review / Auto mode toggle switch.
*
* Reads and writes mode via getReviewMode / setReviewMode API.
* Green dot = review mode active; amber = auto mode.
*/
import { useEffect, useState } from "react";
import { getReviewMode, setReviewMode } from "../api/client";
export default function ModeToggle() {
const [reviewMode, setReviewModeState] = useState<boolean | null>(null);
const [toggling, setToggling] = useState(false);
useEffect(() => {
let cancelled = false;
getReviewMode()
.then((res) => {
if (!cancelled) setReviewModeState(res.review_mode);
})
.catch(() => {
// silently fail — mode indicator will just stay hidden
});
return () => { cancelled = true; };
}, []);
async function handleToggle() {
if (reviewMode === null || toggling) return;
setToggling(true);
try {
const res = await setReviewMode(!reviewMode);
setReviewModeState(res.review_mode);
} catch {
// swallow — leave previous state
} finally {
setToggling(false);
}
}
if (reviewMode === null) return null;
return (
<div className="mode-toggle">
<span
className={`mode-toggle__dot ${reviewMode ? "mode-toggle__dot--review" : "mode-toggle__dot--auto"}`}
/>
<span className="mode-toggle__label">
{reviewMode ? "Review Mode" : "Auto Mode"}
</span>
<button
type="button"
className={`mode-toggle__switch ${reviewMode ? "mode-toggle__switch--active" : ""}`}
onClick={handleToggle}
disabled={toggling}
aria-label={`Switch to ${reviewMode ? "auto" : "review"} mode`}
/>
</div>
);
}

View file

@ -0,0 +1,135 @@
import { useState } from "react";
import { submitReport, type ContentReportCreate } from "../api/public-client";
interface ReportIssueModalProps {
contentType: string;
contentId?: string | null;
contentTitle?: string | null;
onClose: () => void;
}
const REPORT_TYPES = [
{ value: "inaccurate", label: "Inaccurate content" },
{ value: "missing_info", label: "Missing information" },
{ value: "wrong_attribution", label: "Wrong attribution" },
{ value: "formatting", label: "Formatting issue" },
{ value: "other", label: "Other" },
];
export default function ReportIssueModal({
contentType,
contentId,
contentTitle,
onClose,
}: ReportIssueModalProps) {
const [reportType, setReportType] = useState("inaccurate");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (description.trim().length < 10) {
setError("Please provide at least 10 characters describing the issue.");
return;
}
setSubmitting(true);
setError(null);
try {
const body: ContentReportCreate = {
content_type: contentType,
content_id: contentId ?? null,
content_title: contentTitle ?? null,
report_type: reportType,
description: description.trim(),
page_url: window.location.href,
};
await submitReport(body);
setSubmitted(true);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit report",
);
} finally {
setSubmitting(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content report-modal" onClick={(e) => e.stopPropagation()}>
{submitted ? (
<>
<h3 className="report-modal__title">Thank you</h3>
<p className="report-modal__success">
Your report has been submitted. We'll review it shortly.
</p>
<button className="btn btn--primary" onClick={onClose}>
Close
</button>
</>
) : (
<>
<h3 className="report-modal__title">Report an issue</h3>
{contentTitle && (
<p className="report-modal__context">
About: <strong>{contentTitle}</strong>
</p>
)}
<form onSubmit={handleSubmit}>
<label className="report-modal__label">
Issue type
<select
className="report-modal__select"
value={reportType}
onChange={(e) => setReportType(e.target.value)}
>
{REPORT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</label>
<label className="report-modal__label">
Description
<textarea
className="report-modal__textarea"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the issue…"
rows={4}
maxLength={2000}
/>
</label>
{error && <p className="report-modal__error">{error}</p>}
<div className="report-modal__actions">
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn--primary"
disabled={submitting || description.trim().length < 10}
>
{submitting ? "Submitting…" : "Submit report"}
</button>
</div>
</form>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
/**
* Reusable status badge with color coding.
*
* Maps review_status values to colored pill shapes:
* pending amber, approved green, edited blue, rejected red
*/
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const normalized = status.toLowerCase();
return (
<span className={`badge badge--${normalized}`}>
{normalized}
</span>
);
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./App.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View file

@ -0,0 +1,246 @@
/**
* Admin content reports management page.
*
* Lists user-submitted issue reports with filtering by status,
* inline triage (acknowledge/resolve/dismiss), and admin notes.
*/
import { useEffect, useState } from "react";
import {
fetchReports,
updateReport,
type ContentReport,
} from "../api/public-client";
const STATUS_OPTIONS = [
{ value: "", label: "All" },
{ value: "open", label: "Open" },
{ value: "acknowledged", label: "Acknowledged" },
{ value: "resolved", label: "Resolved" },
{ value: "dismissed", label: "Dismissed" },
];
const STATUS_ACTIONS: Record<string, { label: string; next: string }[]> = {
open: [
{ label: "Acknowledge", next: "acknowledged" },
{ label: "Resolve", next: "resolved" },
{ label: "Dismiss", next: "dismissed" },
],
acknowledged: [
{ label: "Resolve", next: "resolved" },
{ label: "Dismiss", next: "dismissed" },
{ label: "Reopen", next: "open" },
],
resolved: [{ label: "Reopen", next: "open" }],
dismissed: [{ label: "Reopen", next: "open" }],
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function reportTypeLabel(rt: string): string {
return rt.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
}
export default function AdminReports() {
const [reports, setReports] = useState<ContentReport[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [noteText, setNoteText] = useState("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const res = await fetchReports({
status: statusFilter || undefined,
limit: 100,
});
setReports(res.items);
setTotal(res.total);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load reports");
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [statusFilter]);
const handleAction = async (reportId: string, newStatus: string) => {
setActionLoading(reportId);
try {
const updated = await updateReport(reportId, {
status: newStatus,
...(noteText.trim() ? { admin_notes: noteText.trim() } : {}),
});
setReports((prev) =>
prev.map((r) => (r.id === reportId ? updated : r)),
);
setNoteText("");
if (newStatus === "resolved" || newStatus === "dismissed") {
setExpandedId(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Action failed");
} finally {
setActionLoading(null);
}
};
const toggleExpand = (id: string) => {
if (expandedId === id) {
setExpandedId(null);
setNoteText("");
} else {
setExpandedId(id);
const report = reports.find((r) => r.id === id);
setNoteText(report?.admin_notes ?? "");
}
};
return (
<div className="admin-reports">
<h2 className="admin-reports__title">Content Reports</h2>
<p className="admin-reports__subtitle">
{total} report{total !== 1 ? "s" : ""} total
</p>
{/* Status filter */}
<div className="admin-reports__filters">
<div className="sort-toggle" role="group" aria-label="Filter by status">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sort-toggle__btn${statusFilter === opt.value ? " sort-toggle__btn--active" : ""}`}
onClick={() => setStatusFilter(opt.value)}
aria-pressed={statusFilter === opt.value}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Content */}
{loading ? (
<div className="loading">Loading reports</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : reports.length === 0 ? (
<div className="empty-state">
{statusFilter ? `No ${statusFilter} reports.` : "No reports yet."}
</div>
) : (
<div className="admin-reports__list">
{reports.map((report) => (
<div
key={report.id}
className={`report-card report-card--${report.status}`}
>
<div
className="report-card__header"
onClick={() => toggleExpand(report.id)}
>
<div className="report-card__meta">
<span className={`pill pill--${report.status}`}>
{report.status}
</span>
<span className="pill">{reportTypeLabel(report.report_type)}</span>
<span className="report-card__date">
{formatDate(report.created_at)}
</span>
</div>
<div className="report-card__summary">
{report.content_title && (
<span className="report-card__content-title">
{report.content_title}
</span>
)}
<span className="report-card__description">
{report.description.length > 120
? report.description.slice(0, 120) + "…"
: report.description}
</span>
</div>
</div>
{expandedId === report.id && (
<div className="report-card__detail">
<div className="report-card__full-description">
<strong>Full description:</strong>
<p>{report.description}</p>
</div>
{report.page_url && (
<div className="report-card__url">
<strong>Page:</strong>{" "}
<a href={report.page_url} target="_blank" rel="noopener noreferrer">
{report.page_url}
</a>
</div>
)}
<div className="report-card__info-row">
<span>Type: {report.content_type}</span>
{report.content_id && <span>ID: {report.content_id.slice(0, 8)}</span>}
{report.resolved_at && (
<span>Resolved: {formatDate(report.resolved_at)}</span>
)}
</div>
{/* Admin notes */}
<label className="report-card__notes-label">
Admin notes:
<textarea
className="report-card__notes"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={2}
placeholder="Add notes…"
/>
</label>
{/* Action buttons */}
<div className="report-card__actions">
{(STATUS_ACTIONS[report.status] ?? []).map((action) => (
<button
key={action.next}
className={`btn btn--${action.next === "resolved" ? "primary" : action.next === "dismissed" ? "danger" : "secondary"}`}
onClick={() => handleAction(report.id, action.next)}
disabled={actionLoading === report.id}
>
{actionLoading === report.id ? "…" : action.label}
</button>
))}
{noteText !== (report.admin_notes ?? "") && (
<button
className="btn btn--secondary"
onClick={() => handleAction(report.id, report.status)}
disabled={actionLoading === report.id}
>
Save notes
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,160 @@
/**
* Creator detail page.
*
* Shows creator info (name, genres, video/technique counts) and lists
* their technique pages with links. Handles loading and 404 states.
*/
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
fetchCreator,
fetchTechniques,
type CreatorDetailResponse,
type TechniqueListItem,
} from "../api/public-client";
export default function CreatorDetail() {
const { slug } = useParams<{ slug: string }>();
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
void (async () => {
try {
const [creatorData, techData] = await Promise.all([
fetchCreator(slug),
fetchTechniques({ creator_slug: slug, limit: 100 }),
]);
if (!cancelled) {
setCreator(creatorData);
setTechniques(techData.items);
}
} catch (err) {
if (!cancelled) {
if (err instanceof Error && err.message.includes("404")) {
setNotFound(true);
} else {
setError(
err instanceof Error ? err.message : "Failed to load creator",
);
}
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [slug]);
if (loading) {
return <div className="loading">Loading creator</div>;
}
if (notFound) {
return (
<div className="technique-404">
<h2>Creator Not Found</h2>
<p>The creator "{slug}" doesn't exist.</p>
<Link to="/creators" className="btn">
Back to Creators
</Link>
</div>
);
}
if (error || !creator) {
return (
<div className="loading error-text">
Error: {error ?? "Unknown error"}
</div>
);
}
return (
<div className="creator-detail">
<Link to="/creators" className="back-link">
Creators
</Link>
{/* Header */}
<header className="creator-detail__header">
<h1 className="creator-detail__name">{creator.name}</h1>
<div className="creator-detail__meta">
{creator.genres && creator.genres.length > 0 && (
<span className="creator-detail__genres">
{creator.genres.map((g) => (
<span key={g} className="pill">
{g}
</span>
))}
</span>
)}
<span className="creator-detail__stats">
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
<span className="queue-card__separator">·</span>
{creator.view_count.toLocaleString()} views
</span>
</div>
</header>
{/* Technique pages */}
<section className="creator-techniques">
<h2 className="creator-techniques__title">
Techniques ({techniques.length})
</h2>
{techniques.length === 0 ? (
<div className="empty-state">No techniques yet.</div>
) : (
<div className="creator-techniques__list">
{techniques.map((t) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="creator-technique-card"
>
<span className="creator-technique-card__title">
{t.title}
</span>
<span className="creator-technique-card__meta">
<span className="badge badge--category">
{t.topic_category}
</span>
{t.topic_tags && t.topic_tags.length > 0 && (
<span className="creator-technique-card__tags">
{t.topic_tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</span>
)}
</span>
{t.summary && (
<span className="creator-technique-card__summary">
{t.summary.length > 120
? `${t.summary.slice(0, 120)}`
: t.summary}
</span>
)}
</Link>
))}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,185 @@
/**
* Creators browse page (R007, R014).
*
* - Default sort: random (creator equity no featured/highlighted creators)
* - Genre filter pills from canonical taxonomy
* - Type-to-narrow client-side name filter
* - Sort toggle: Random | Alphabetical | Views
* - Click row /creators/{slug}
*/
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
fetchCreators,
type CreatorBrowseItem,
} from "../api/public-client";
const GENRES = [
"Bass music",
"Drum & bass",
"Dubstep",
"Halftime",
"House",
"Techno",
"IDM",
"Glitch",
"Downtempo",
"Neuro",
"Ambient",
"Experimental",
"Cinematic",
];
type SortMode = "random" | "alpha" | "views";
const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "random", label: "Random" },
{ value: "alpha", label: "AZ" },
{ value: "views", label: "Views" },
];
export default function CreatorsBrowse() {
const [creators, setCreators] = useState<CreatorBrowseItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useState<SortMode>("random");
const [genreFilter, setGenreFilter] = useState<string | null>(null);
const [nameFilter, setNameFilter] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const res = await fetchCreators({
sort,
genre: genreFilter ?? undefined,
limit: 100,
});
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>
);
}

222
frontend/src/pages/Home.tsx Normal file
View file

@ -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<SearchResultItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [recent, setRecent] = useState<TechniqueListItem[]>([]);
const [recentLoading, setRecentLoading] = useState(true);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="home">
{/* Hero search */}
<section className="home-hero">
<h2 className="home-hero__title">Chrysopedia</h2>
<p className="home-hero__subtitle">
Search techniques, key moments, and creators
</p>
<div className="search-container" ref={dropdownRef}>
<form onSubmit={handleSubmit} className="search-form search-form--hero">
<input
ref={inputRef}
type="search"
className="search-input search-input--hero"
placeholder="Search techniques…"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => {
if (suggestions.length > 0) setShowDropdown(true);
}}
onKeyDown={handleKeyDown}
aria-label="Search techniques"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
{showDropdown && suggestions.length > 0 && (
<div className="typeahead-dropdown">
{suggestions.map((item) => (
<Link
key={`${item.type}-${item.slug}`}
to={`/techniques/${item.slug}`}
className="typeahead-item"
onClick={() => setShowDropdown(false)}
>
<span className="typeahead-item__title">{item.title}</span>
<span className="typeahead-item__meta">
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{item.type === "technique_page" ? "Technique" : "Key Moment"}
</span>
{item.creator_name && (
<span className="typeahead-item__creator">
{item.creator_name}
</span>
)}
</span>
</Link>
))}
<Link
to={`/search?q=${encodeURIComponent(query)}`}
className="typeahead-see-all"
onClick={() => setShowDropdown(false)}
>
See all results for "{query}"
</Link>
</div>
)}
</div>
</section>
{/* Navigation cards */}
<section className="nav-cards">
<Link to="/topics" className="nav-card">
<h3 className="nav-card__title">Topics</h3>
<p className="nav-card__desc">
Browse techniques organized by category and sub-topic
</p>
</Link>
<Link to="/creators" className="nav-card">
<h3 className="nav-card__title">Creators</h3>
<p className="nav-card__desc">
Discover creators and their technique libraries
</p>
</Link>
</section>
{/* Recently Added */}
<section className="recent-section">
<h3 className="recent-section__title">Recently Added</h3>
{recentLoading ? (
<div className="loading">Loading</div>
) : recent.length === 0 ? (
<div className="empty-state">No techniques yet.</div>
) : (
<div className="recent-list">
{recent.map((t) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="recent-card"
>
<span className="recent-card__title">{t.title}</span>
<span className="recent-card__meta">
<span className="badge badge--category">
{t.topic_category}
</span>
{t.summary && (
<span className="recent-card__summary">
{t.summary.length > 100
? `${t.summary.slice(0, 100)}`
: t.summary}
</span>
)}
</span>
</Link>
))}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,454 @@
/**
* Moment review detail page.
*
* Displays full moment data with action buttons:
* - Approve / Reject navigate back to queue
* - Edit inline edit mode for title, summary, content_type
* - Split dialog with timestamp input
* - Merge dialog with moment selector
*/
import { useCallback, useEffect, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import {
fetchMoment,
fetchQueue,
approveMoment,
rejectMoment,
editMoment,
splitMoment,
mergeMoments,
type ReviewQueueItem,
} from "../api/client";
import StatusBadge from "../components/StatusBadge";
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 MomentDetail() {
const { momentId } = useParams<{ momentId: string }>();
const navigate = useNavigate();
// ── Data state ──
const [moment, setMoment] = useState<ReviewQueueItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
// ── Edit state ──
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editSummary, setEditSummary] = useState("");
const [editContentType, setEditContentType] = useState("");
// ── Split state ──
const [showSplit, setShowSplit] = useState(false);
const [splitTime, setSplitTime] = useState("");
// ── Merge state ──
const [showMerge, setShowMerge] = useState(false);
const [mergeCandidates, setMergeCandidates] = useState<ReviewQueueItem[]>([]);
const [mergeTargetId, setMergeTargetId] = useState("");
const loadMoment = useCallback(async () => {
if (!momentId) return;
setLoading(true);
setError(null);
try {
// Fetch all moments and find the one matching our ID
const found = await fetchMoment(momentId);
setMoment(found);
setEditTitle(found.title);
setEditSummary(found.summary);
setEditContentType(found.content_type);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load moment");
} finally {
setLoading(false);
}
}, [momentId]);
useEffect(() => {
void loadMoment();
}, [loadMoment]);
// ── Action handlers ──
async function handleApprove() {
if (!momentId || acting) return;
setActing(true);
setActionError(null);
try {
await approveMoment(momentId);
navigate("/admin/review");
} catch (err) {
setActionError(err instanceof Error ? err.message : "Approve failed");
} finally {
setActing(false);
}
}
async function handleReject() {
if (!momentId || acting) return;
setActing(true);
setActionError(null);
try {
await rejectMoment(momentId);
navigate("/admin/review");
} catch (err) {
setActionError(err instanceof Error ? err.message : "Reject failed");
} finally {
setActing(false);
}
}
function startEdit() {
if (!moment) return;
setEditTitle(moment.title);
setEditSummary(moment.summary);
setEditContentType(moment.content_type);
setEditing(true);
setActionError(null);
}
async function handleEditSave() {
if (!momentId || acting) return;
setActing(true);
setActionError(null);
try {
await editMoment(momentId, {
title: editTitle,
summary: editSummary,
content_type: editContentType,
});
setEditing(false);
await loadMoment();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Edit failed");
} finally {
setActing(false);
}
}
function openSplitDialog() {
if (!moment) return;
setSplitTime("");
setShowSplit(true);
setActionError(null);
}
async function handleSplit() {
if (!momentId || !moment || acting) return;
const t = parseFloat(splitTime);
if (isNaN(t) || t <= moment.start_time || t >= moment.end_time) {
setActionError(
`Split time must be between ${formatTime(moment.start_time)} and ${formatTime(moment.end_time)}`
);
return;
}
setActing(true);
setActionError(null);
try {
await splitMoment(momentId, t);
setShowSplit(false);
navigate("/admin/review");
} catch (err) {
setActionError(err instanceof Error ? err.message : "Split failed");
} finally {
setActing(false);
}
}
async function openMergeDialog() {
if (!moment) return;
setShowMerge(true);
setMergeTargetId("");
setActionError(null);
try {
// Load moments from the same video for merge candidates
const res = await fetchQueue({ limit: 100 });
const candidates = res.items.filter(
(m) => m.source_video_id === moment.source_video_id && m.id !== moment.id
);
setMergeCandidates(candidates);
} catch {
setMergeCandidates([]);
}
}
async function handleMerge() {
if (!momentId || !mergeTargetId || acting) return;
setActing(true);
setActionError(null);
try {
await mergeMoments(momentId, mergeTargetId);
setShowMerge(false);
navigate("/admin/review");
} catch (err) {
setActionError(err instanceof Error ? err.message : "Merge failed");
} finally {
setActing(false);
}
}
// ── Render ──
if (loading) return <div className="loading">Loading</div>;
if (error)
return (
<div>
<Link to="/admin/review" className="back-link">
Back to queue
</Link>
<div className="loading error-text">Error: {error}</div>
</div>
);
if (!moment) return null;
return (
<div className="detail-page">
<Link to="/admin/review" className="back-link">
Back to queue
</Link>
{/* ── Moment header ── */}
<div className="detail-header">
<h2>{moment.title}</h2>
<StatusBadge status={moment.review_status} />
</div>
{/* ── Moment data ── */}
<div className="card detail-card">
<div className="detail-field">
<label>Content Type</label>
<span>{moment.content_type}</span>
</div>
<div className="detail-field">
<label>Time Range</label>
<span>
{formatTime(moment.start_time)} {formatTime(moment.end_time)}
</span>
</div>
<div className="detail-field">
<label>Source</label>
<span>
{moment.creator_name} · {moment.video_filename}
</span>
</div>
{moment.plugins && moment.plugins.length > 0 && (
<div className="detail-field">
<label>Plugins</label>
<span>{moment.plugins.join(", ")}</span>
</div>
)}
<div className="detail-field detail-field--full">
<label>Summary</label>
<p>{moment.summary}</p>
</div>
{moment.raw_transcript && (
<div className="detail-field detail-field--full">
<label>Raw Transcript</label>
<p className="detail-transcript">{moment.raw_transcript}</p>
</div>
)}
</div>
{/* ── Action error ── */}
{actionError && <div className="action-error">{actionError}</div>}
{/* ── Edit mode ── */}
{editing ? (
<div className="card edit-form">
<h3>Edit Moment</h3>
<div className="edit-field">
<label htmlFor="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
</div>
<div className="edit-field">
<label htmlFor="edit-summary">Summary</label>
<textarea
id="edit-summary"
rows={4}
value={editSummary}
onChange={(e) => setEditSummary(e.target.value)}
/>
</div>
<div className="edit-field">
<label htmlFor="edit-content-type">Content Type</label>
<input
id="edit-content-type"
type="text"
value={editContentType}
onChange={(e) => setEditContentType(e.target.value)}
/>
</div>
<div className="edit-actions">
<button
type="button"
className="btn btn--approve"
onClick={handleEditSave}
disabled={acting}
>
Save
</button>
<button
type="button"
className="btn"
onClick={() => setEditing(false)}
disabled={acting}
>
Cancel
</button>
</div>
</div>
) : (
/* ── Action buttons ── */
<div className="action-bar">
<button
type="button"
className="btn btn--approve"
onClick={handleApprove}
disabled={acting}
>
Approve
</button>
<button
type="button"
className="btn btn--reject"
onClick={handleReject}
disabled={acting}
>
Reject
</button>
<button
type="button"
className="btn"
onClick={startEdit}
disabled={acting}
>
Edit
</button>
<button
type="button"
className="btn"
onClick={openSplitDialog}
disabled={acting}
>
Split
</button>
<button
type="button"
className="btn"
onClick={openMergeDialog}
disabled={acting}
>
Merge
</button>
</div>
)}
{/* ── Split dialog ── */}
{showSplit && (
<div className="dialog-overlay" onClick={() => setShowSplit(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Split Moment</h3>
<p className="dialog__hint">
Enter a timestamp (in seconds) between{" "}
{formatTime(moment.start_time)} and {formatTime(moment.end_time)}.
</p>
<div className="edit-field">
<label htmlFor="split-time">Split Time (seconds)</label>
<input
id="split-time"
type="number"
step="0.1"
min={moment.start_time}
max={moment.end_time}
value={splitTime}
onChange={(e) => setSplitTime(e.target.value)}
placeholder={`e.g. ${((moment.start_time + moment.end_time) / 2).toFixed(1)}`}
/>
</div>
<div className="dialog__actions">
<button
type="button"
className="btn btn--approve"
onClick={handleSplit}
disabled={acting}
>
Split
</button>
<button
type="button"
className="btn"
onClick={() => setShowSplit(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* ── Merge dialog ── */}
{showMerge && (
<div className="dialog-overlay" onClick={() => setShowMerge(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Merge Moment</h3>
<p className="dialog__hint">
Select another moment from the same video to merge with.
</p>
{mergeCandidates.length === 0 ? (
<p className="dialog__hint">
No other moments from this video available.
</p>
) : (
<div className="edit-field">
<label htmlFor="merge-target">Target Moment</label>
<select
id="merge-target"
value={mergeTargetId}
onChange={(e) => setMergeTargetId(e.target.value)}
>
<option value="">Select a moment</option>
{mergeCandidates.map((c) => (
<option key={c.id} value={c.id}>
{c.title} ({formatTime(c.start_time)} {" "}
{formatTime(c.end_time)})
</option>
))}
</select>
</div>
)}
<div className="dialog__actions">
<button
type="button"
className="btn btn--approve"
onClick={handleMerge}
disabled={acting || !mergeTargetId}
>
Merge
</button>
<button
type="button"
className="btn"
onClick={() => setShowMerge(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,189 @@
/**
* Admin review queue page.
*
* Shows stats bar, status filter tabs, paginated moment list, and mode toggle.
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
fetchQueue,
fetchStats,
type ReviewQueueItem,
type ReviewStatsResponse,
} from "../api/client";
import StatusBadge from "../components/StatusBadge";
import ModeToggle from "../components/ModeToggle";
const PAGE_SIZE = 20;
type StatusFilter = "all" | "pending" | "approved" | "edited" | "rejected";
const FILTERS: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Pending", value: "pending" },
{ label: "Approved", value: "approved" },
{ label: "Edited", value: "edited" },
{ label: "Rejected", value: "rejected" },
];
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 ReviewQueue() {
const [items, setItems] = useState<ReviewQueueItem[]>([]);
const [stats, setStats] = useState<ReviewStatsResponse | null>(null);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState<StatusFilter>("pending");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async (status: StatusFilter, page: number) => {
setLoading(true);
setError(null);
try {
const [queueRes, statsRes] = await Promise.all([
fetchQueue({
status: status === "all" ? undefined : status,
offset: page,
limit: PAGE_SIZE,
}),
fetchStats(),
]);
setItems(queueRes.items);
setTotal(queueRes.total);
setStats(statsRes);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load queue");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData(filter, offset);
}, [filter, offset, loadData]);
function handleFilterChange(f: StatusFilter) {
setFilter(f);
setOffset(0);
}
const hasNext = offset + PAGE_SIZE < total;
const hasPrev = offset > 0;
return (
<div>
{/* ── Header row with title and mode toggle ── */}
<div className="queue-header">
<h2>Review Queue</h2>
<ModeToggle />
</div>
{/* ── Stats bar ── */}
{stats && (
<div className="stats-bar">
<div className="stats-card stats-card--pending">
<span className="stats-card__count">{stats.pending}</span>
<span className="stats-card__label">Pending</span>
</div>
<div className="stats-card stats-card--approved">
<span className="stats-card__count">{stats.approved}</span>
<span className="stats-card__label">Approved</span>
</div>
<div className="stats-card stats-card--edited">
<span className="stats-card__count">{stats.edited}</span>
<span className="stats-card__label">Edited</span>
</div>
<div className="stats-card stats-card--rejected">
<span className="stats-card__count">{stats.rejected}</span>
<span className="stats-card__label">Rejected</span>
</div>
</div>
)}
{/* ── Filter tabs ── */}
<div className="filter-tabs">
{FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`filter-tab ${filter === f.value ? "filter-tab--active" : ""}`}
onClick={() => handleFilterChange(f.value)}
>
{f.label}
</button>
))}
</div>
{/* ── Queue list ── */}
{loading ? (
<div className="loading">Loading</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : items.length === 0 ? (
<div className="empty-state">
<p>No moments match the "{filter}" filter.</p>
</div>
) : (
<>
<div className="queue-list">
{items.map((item) => (
<Link
key={item.id}
to={`/admin/review/${item.id}`}
className="queue-card"
>
<div className="queue-card__header">
<span className="queue-card__title">{item.title}</span>
<StatusBadge status={item.review_status} />
</div>
<p className="queue-card__summary">
{item.summary.length > 150
? `${item.summary.slice(0, 150)}`
: item.summary}
</p>
<div className="queue-card__meta">
<span>{item.creator_name}</span>
<span className="queue-card__separator">·</span>
<span>{item.video_filename}</span>
<span className="queue-card__separator">·</span>
<span>
{formatTime(item.start_time)} {formatTime(item.end_time)}
</span>
</div>
</Link>
))}
</div>
{/* ── Pagination ── */}
<div className="pagination">
<button
type="button"
className="btn"
disabled={!hasPrev}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
Previous
</button>
<span className="pagination__info">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
type="button"
className="btn"
disabled={!hasNext}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Next
</button>
</div>
</>
)}
</div>
);
}

View file

@ -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<SearchResultItem[]>([]);
const [fallbackUsed, setFallbackUsed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localQuery, setLocalQuery] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="search-results-page">
{/* Inline search bar */}
<form onSubmit={handleSubmit} className="search-form search-form--inline">
<input
type="search"
className="search-input search-input--inline"
placeholder="Search techniques…"
value={localQuery}
onChange={(e) => handleInputChange(e.target.value)}
aria-label="Refine search"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
{/* Status */}
{loading && <div className="loading">Searching</div>}
{error && <div className="loading error-text">Error: {error}</div>}
{/* Fallback banner */}
{!loading && fallbackUsed && results.length > 0 && (
<div className="search-fallback-banner">
Showing keyword results semantic search unavailable
</div>
)}
{/* No results */}
{!loading && !error && q && results.length === 0 && (
<div className="empty-state">
<p>No results found for "{q}"</p>
</div>
)}
{/* Technique pages */}
{techniqueResults.length > 0 && (
<section className="search-group">
<h3 className="search-group__title">
Techniques ({techniqueResults.length})
</h3>
<div className="search-group__list">
{techniqueResults.map((item) => (
<SearchResultCard key={`tp-${item.slug}`} item={item} />
))}
</div>
</section>
)}
{/* Key moments */}
{momentResults.length > 0 && (
<section className="search-group">
<h3 className="search-group__title">
Key Moments ({momentResults.length})
</h3>
<div className="search-group__list">
{momentResults.map((item, i) => (
<SearchResultCard key={`km-${item.slug}-${i}`} item={item} />
))}
</div>
</section>
)}
</div>
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
return (
<Link
to={`/techniques/${item.slug}`}
className="search-result-card"
>
<div className="search-result-card__header">
<span className="search-result-card__title">{item.title}</span>
<span className={`badge badge--type badge--type-${item.type}`}>
{item.type === "technique_page" ? "Technique" : "Key Moment"}
</span>
</div>
{item.summary && (
<p className="search-result-card__summary">
{item.summary.length > 200
? `${item.summary.slice(0, 200)}`
: item.summary}
</p>
)}
<div className="search-result-card__meta">
{item.creator_name && <span>{item.creator_name}</span>}
{item.topic_category && (
<>
<span className="queue-card__separator">·</span>
<span>{item.topic_category}</span>
</>
)}
{item.topic_tags.length > 0 && (
<span className="search-result-card__tags">
{item.topic_tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</span>
)}
</div>
</Link>
);
}

View file

@ -412,8 +412,8 @@ export default function TechniquePage() {
<ol className="technique-moments__list">
{technique.key_moments.map((km) => (
<li key={km.id} className="technique-moment">
<div className="technique-moment__header">
<span className="technique-moment__title">{km.title}</span>
<h3 className="technique-moment__title">{km.title}</h3>
<div className="technique-moment__meta">
{km.video_filename && (
<span className="technique-moment__source">
{km.video_filename}

View file

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

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}

View file

@ -0,0 +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/ReportIssueModal.tsx","./src/components/StatusBadge.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.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"}

4
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

14
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:8001",
changeOrigin: true,
},
},
},
});