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:
parent
aa71387ad5
commit
c6efec8363
33 changed files with 4637 additions and 10 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
77
.gsd/milestones/M005/slices/S02/S02-SUMMARY.md
Normal file
77
.gsd/milestones/M005/slices/S02/S02-SUMMARY.md
Normal 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.
|
||||
58
.gsd/milestones/M005/slices/S02/S02-UAT.md
Normal file
58
.gsd/milestones/M005/slices/S02/S02-UAT.md
Normal 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
|
||||
30
.gsd/milestones/M005/slices/S02/tasks/T01-VERIFY.json
Normal file
30
.gsd/milestones/M005/slices/S02/tasks/T01-VERIFY.json
Normal 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
|
||||
}
|
||||
|
|
@ -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 413–430 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 1337–1380 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'
|
||||
|
|
|
|||
72
.gsd/milestones/M005/slices/S03/S03-RESEARCH.md
Normal file
72
.gsd/milestones/M005/slices/S03/S03-RESEARCH.md
Normal 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 413–430) — 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 1337–1380) — 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.
|
||||
70
.gsd/milestones/M005/slices/S03/tasks/T01-PLAN.md
Normal file
70
.gsd/milestones/M005/slices/S03/tasks/T01-PLAN.md
Normal 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 413–430 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 1337–1380 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'
|
||||
80
.gsd/milestones/M005/slices/S03/tasks/T01-SUMMARY.md
Normal file
80
.gsd/milestones/M005/slices/S03/tasks/T01-SUMMARY.md
Normal 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
13
frontend/index.html
Normal 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
1888
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
193
frontend/src/api/client.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
59
frontend/src/components/ModeToggle.tsx
Normal file
59
frontend/src/components/ModeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/ReportIssueModal.tsx
Normal file
135
frontend/src/components/ReportIssueModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/StatusBadge.tsx
Normal file
19
frontend/src/components/StatusBadge.tsx
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
246
frontend/src/pages/AdminReports.tsx
Normal file
246
frontend/src/pages/AdminReports.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
frontend/src/pages/CreatorDetail.tsx
Normal file
160
frontend/src/pages/CreatorDetail.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Creator detail page.
|
||||
*
|
||||
* Shows creator info (name, genres, video/technique counts) and lists
|
||||
* their technique pages with links. Handles loading and 404 states.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
fetchCreator,
|
||||
fetchTechniques,
|
||||
type CreatorDetailResponse,
|
||||
type TechniqueListItem,
|
||||
} from "../api/public-client";
|
||||
|
||||
export default function CreatorDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
|
||||
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setNotFound(false);
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [creatorData, techData] = await Promise.all([
|
||||
fetchCreator(slug),
|
||||
fetchTechniques({ creator_slug: slug, limit: 100 }),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setCreator(creatorData);
|
||||
setTechniques(techData.items);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (err instanceof Error && err.message.includes("404")) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load creator",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading creator…</div>;
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="technique-404">
|
||||
<h2>Creator Not Found</h2>
|
||||
<p>The creator "{slug}" doesn't exist.</p>
|
||||
<Link to="/creators" className="btn">
|
||||
Back to Creators
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !creator) {
|
||||
return (
|
||||
<div className="loading error-text">
|
||||
Error: {error ?? "Unknown error"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="creator-detail">
|
||||
<Link to="/creators" className="back-link">
|
||||
← Creators
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="creator-detail__header">
|
||||
<h1 className="creator-detail__name">{creator.name}</h1>
|
||||
<div className="creator-detail__meta">
|
||||
{creator.genres && creator.genres.length > 0 && (
|
||||
<span className="creator-detail__genres">
|
||||
{creator.genres.map((g) => (
|
||||
<span key={g} className="pill">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="creator-detail__stats">
|
||||
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
|
||||
<span className="queue-card__separator">·</span>
|
||||
{creator.view_count.toLocaleString()} views
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Technique pages */}
|
||||
<section className="creator-techniques">
|
||||
<h2 className="creator-techniques__title">
|
||||
Techniques ({techniques.length})
|
||||
</h2>
|
||||
{techniques.length === 0 ? (
|
||||
<div className="empty-state">No techniques yet.</div>
|
||||
) : (
|
||||
<div className="creator-techniques__list">
|
||||
{techniques.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/techniques/${t.slug}`}
|
||||
className="creator-technique-card"
|
||||
>
|
||||
<span className="creator-technique-card__title">
|
||||
{t.title}
|
||||
</span>
|
||||
<span className="creator-technique-card__meta">
|
||||
<span className="badge badge--category">
|
||||
{t.topic_category}
|
||||
</span>
|
||||
{t.topic_tags && t.topic_tags.length > 0 && (
|
||||
<span className="creator-technique-card__tags">
|
||||
{t.topic_tags.map((tag) => (
|
||||
<span key={tag} className="pill">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{t.summary && (
|
||||
<span className="creator-technique-card__summary">
|
||||
{t.summary.length > 120
|
||||
? `${t.summary.slice(0, 120)}…`
|
||||
: t.summary}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
frontend/src/pages/CreatorsBrowse.tsx
Normal file
185
frontend/src/pages/CreatorsBrowse.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Creators browse page (R007, R014).
|
||||
*
|
||||
* - Default sort: random (creator equity — no featured/highlighted creators)
|
||||
* - Genre filter pills from canonical taxonomy
|
||||
* - Type-to-narrow client-side name filter
|
||||
* - Sort toggle: Random | Alphabetical | Views
|
||||
* - Click row → /creators/{slug}
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchCreators,
|
||||
type CreatorBrowseItem,
|
||||
} from "../api/public-client";
|
||||
|
||||
const GENRES = [
|
||||
"Bass music",
|
||||
"Drum & bass",
|
||||
"Dubstep",
|
||||
"Halftime",
|
||||
"House",
|
||||
"Techno",
|
||||
"IDM",
|
||||
"Glitch",
|
||||
"Downtempo",
|
||||
"Neuro",
|
||||
"Ambient",
|
||||
"Experimental",
|
||||
"Cinematic",
|
||||
];
|
||||
|
||||
type SortMode = "random" | "alpha" | "views";
|
||||
|
||||
const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
{ value: "random", label: "Random" },
|
||||
{ value: "alpha", label: "A–Z" },
|
||||
{ value: "views", label: "Views" },
|
||||
];
|
||||
|
||||
export default function CreatorsBrowse() {
|
||||
const [creators, setCreators] = useState<CreatorBrowseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortMode>("random");
|
||||
const [genreFilter, setGenreFilter] = useState<string | null>(null);
|
||||
const [nameFilter, setNameFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchCreators({
|
||||
sort,
|
||||
genre: genreFilter ?? undefined,
|
||||
limit: 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
222
frontend/src/pages/Home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
frontend/src/pages/MomentDetail.tsx
Normal file
454
frontend/src/pages/MomentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
frontend/src/pages/ReviewQueue.tsx
Normal file
189
frontend/src/pages/ReviewQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/pages/SearchResults.tsx
Normal file
184
frontend/src/pages/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
156
frontend/src/pages/TopicsBrowse.tsx
Normal file
156
frontend/src/pages/TopicsBrowse.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Topics browse page (R008).
|
||||
*
|
||||
* Two-level hierarchy: 6 top-level categories with expandable/collapsible
|
||||
* sub-topics. Each sub-topic shows technique_count and creator_count.
|
||||
* Filter input narrows categories and sub-topics.
|
||||
* Click sub-topic → search results filtered to that topic.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchTopics, type TopicCategory } from "../api/public-client";
|
||||
|
||||
export default function TopicsBrowse() {
|
||||
const [categories, setCategories] = useState<TopicCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchTopics();
|
||||
if (!cancelled) {
|
||||
setCategories(data);
|
||||
// All expanded by default
|
||||
setExpanded(new Set(data.map((c) => c.name)));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load topics",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filter: show categories whose name or sub-topics match
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
const filtered = filter
|
||||
? categories
|
||||
.map((cat) => {
|
||||
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
|
||||
const matchingSubs = cat.sub_topics.filter((st) =>
|
||||
st.name.toLowerCase().includes(lowerFilter),
|
||||
);
|
||||
if (catMatches) return cat; // show full category
|
||||
if (matchingSubs.length > 0) {
|
||||
return { ...cat, sub_topics: matchingSubs };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as TopicCategory[]
|
||||
: categories;
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading topics…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="loading error-text">Error: {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="topics-browse">
|
||||
<h2 className="topics-browse__title">Topics</h2>
|
||||
<p className="topics-browse__subtitle">
|
||||
Browse techniques organized by category and sub-topic
|
||||
</p>
|
||||
|
||||
{/* Filter */}
|
||||
<input
|
||||
type="search"
|
||||
className="topics-filter-input"
|
||||
placeholder="Filter topics…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
aria-label="Filter topics"
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
No topics matching "{filter}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="topics-list">
|
||||
{filtered.map((cat) => (
|
||||
<div key={cat.name} className="topic-category">
|
||||
<button
|
||||
className="topic-category__header"
|
||||
onClick={() => toggleCategory(cat.name)}
|
||||
aria-expanded={expanded.has(cat.name)}
|
||||
>
|
||||
<span className="topic-category__chevron">
|
||||
{expanded.has(cat.name) ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="topic-category__name">{cat.name}</span>
|
||||
<span className="topic-category__desc">{cat.description}</span>
|
||||
<span className="topic-category__count">
|
||||
{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded.has(cat.name) && (
|
||||
<div className="topic-subtopics">
|
||||
{cat.sub_topics.map((st) => (
|
||||
<Link
|
||||
key={st.name}
|
||||
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
|
||||
className="topic-subtopic"
|
||||
>
|
||||
<span className="topic-subtopic__name">{st.name}</span>
|
||||
<span className="topic-subtopic__counts">
|
||||
<span className="topic-subtopic__count">
|
||||
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="topic-subtopic__separator">·</span>
|
||||
<span className="topic-subtopic__count">
|
||||
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
1
frontend/tsconfig.app.tsbuildinfo
Normal file
1
frontend/tsconfig.app.tsbuildinfo
Normal 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
4
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue