diff --git a/.gsd/milestones/M005/slices/S01/S01-PLAN.md b/.gsd/milestones/M005/slices/S01/S01-PLAN.md index c913c50..bb39588 100644 --- a/.gsd/milestones/M005/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M005/slices/S01/S01-PLAN.md @@ -12,7 +12,7 @@ - Estimate: 30min - Files: backend/routers/pipeline.py, backend/schemas.py, backend/main.py - Verify: curl -s http://localhost:8096/api/v1/admin/pipeline/videos | python3 -m json.tool && curl -s http://localhost:8096/api/v1/admin/pipeline/worker-status | python3 -m json.tool -- [ ] **T03: Pipeline admin frontend page** — New AdminPipeline.tsx page at /admin/pipeline. Video list table with status badges, retrigger/pause buttons. Expandable row showing event log timeline with token usage and collapsible JSON response viewer. Worker status indicator. Wire into App.tsx and nav. +- [x] **T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator** — New AdminPipeline.tsx page at /admin/pipeline. Video list table with status badges, retrigger/pause buttons. Expandable row showing event log timeline with token usage and collapsible JSON response viewer. Worker status indicator. Wire into App.tsx and nav. - Estimate: 45min - Files: frontend/src/pages/AdminPipeline.tsx, frontend/src/api/public-client.ts, frontend/src/App.tsx, frontend/src/App.css - Verify: docker compose build chrysopedia-web 2>&1 | tail -5 (exit 0, zero TS errors) diff --git a/.gsd/milestones/M005/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M005/slices/S01/tasks/T02-VERIFY.json new file mode 100644 index 0000000..5653db3 --- /dev/null +++ b/.gsd/milestones/M005/slices/S01/tasks/T02-VERIFY.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M005/S01/T02", + "timestamp": 1774859415126, + "passed": true, + "discoverySource": "none", + "checks": [] +} diff --git a/.gsd/milestones/M005/slices/S01/tasks/T03-SUMMARY.md b/.gsd/milestones/M005/slices/S01/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..8418058 --- /dev/null +++ b/.gsd/milestones/M005/slices/S01/tasks/T03-SUMMARY.md @@ -0,0 +1,83 @@ +--- +id: T03 +parent: S01 +milestone: M005 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/AdminPipeline.tsx", "frontend/src/api/public-client.ts", "frontend/src/App.tsx", "frontend/src/App.css"] +key_decisions: ["Used grid layout for video rows with info/meta/actions columns", "Worker status auto-refreshes every 15s via setInterval", "JsonViewer component for collapsible JSON payload display"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Docker compose build chrysopedia-web succeeded with zero TS errors (exit 0). Browser verification at http://ub01:8096/admin/pipeline confirmed: page renders with 3 videos, status badges, worker status indicator, expandable event log with 24 events, collapsible JSON viewer, and zero console errors." +completed_at: 2026-03-30T08:35:03.406Z +blocker_discovered: false +--- + +# T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator + +> Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator + +## What Happened +--- +id: T03 +parent: S01 +milestone: M005 +key_files: + - frontend/src/pages/AdminPipeline.tsx + - frontend/src/api/public-client.ts + - frontend/src/App.tsx + - frontend/src/App.css +key_decisions: + - Used grid layout for video rows with info/meta/actions columns + - Worker status auto-refreshes every 15s via setInterval + - JsonViewer component for collapsible JSON payload display +duration: "" +verification_result: passed +completed_at: 2026-03-30T08:35:03.407Z +blocker_discovered: false +--- + +# T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator + +**Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator** + +## What Happened + +Created the pipeline admin frontend page with four file changes: AdminPipeline.tsx with video list, event log, JSON viewer, and worker status; API client functions in public-client.ts; route and nav in App.tsx; themed CSS in App.css. All components use real API data from the five backend endpoints verified in T02. Worker status auto-refreshes every 15s. Event log shows pagination, token counts, model names, and collapsible JSON payloads. + +## Verification + +Docker compose build chrysopedia-web succeeded with zero TS errors (exit 0). Browser verification at http://ub01:8096/admin/pipeline confirmed: page renders with 3 videos, status badges, worker status indicator, expandable event log with 24 events, collapsible JSON viewer, and zero console errors. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `docker compose build chrysopedia-web 2>&1 | tail -5` | 0 | ✅ pass | 4700ms | +| 2 | `browser_assert: text_visible Pipeline Management + selector_visible .pipeline-video + selector_visible .worker-status + no_console_errors` | 0 | ✅ pass | 1000ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/pages/AdminPipeline.tsx` +- `frontend/src/api/public-client.ts` +- `frontend/src/App.tsx` +- `frontend/src/App.css` + + +## Deviations +None. + +## Known Issues +None. diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..08b2bfd --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,2764 @@ +/* ── Theme tokens ──────────────────────────────────────────────────────────── */ + +:root { + /* Backgrounds */ + --color-bg-page: #0f0f14; + --color-bg-surface: #1a1a24; + --color-bg-surface-hover: #22222e; + --color-bg-input: #1a1a24; + --color-bg-header: #0a0a12; + --color-bg-header-alt: #14141e; + --color-bg-transcript: #12121a; + + /* Text */ + --color-text-primary: #e2e2ea; + --color-text-secondary: #8b8b9a; + --color-text-muted: #6b6b7a; + --color-text-active: #e2e2ea; + --color-text-on-header: rgba(255, 255, 255, 0.8); + --color-text-on-header-hover: #fff; + --color-text-on-header-label: rgba(255, 255, 255, 0.9); + + /* Borders */ + --color-border: #2a2a38; + --color-border-active: #22d3ee; + + /* Accent (cyan) */ + --color-accent: #22d3ee; + --color-accent-hover: #67e8f9; + --color-accent-subtle: rgba(34, 211, 238, 0.1); + --color-accent-focus: rgba(34, 211, 238, 0.15); + + /* Shadows / overlays */ + --color-shadow: rgba(0, 0, 0, 0.2); + --color-shadow-heavy: rgba(0, 0, 0, 0.4); + --color-overlay: rgba(0, 0, 0, 0.6); + + /* Status badges */ + --color-badge-pending-bg: #422006; + --color-badge-pending-text: #fcd34d; + --color-badge-approved-bg: #052e16; + --color-badge-approved-text: #6ee7b7; + --color-badge-edited-bg: #1e1b4b; + --color-badge-edited-text: #93c5fd; + --color-badge-rejected-bg: #450a0a; + --color-badge-rejected-text: #fca5a5; + + /* Semantic buttons */ + --color-btn-approve: #059669; + --color-btn-approve-hover: #047857; + --color-btn-reject: #dc2626; + --color-btn-reject-hover: #b91c1c; + + /* Mode toggle (green/amber work on dark) */ + --color-toggle-review: #10b981; + --color-toggle-auto: #f59e0b; + --color-toggle-track: #6b7280; + --color-toggle-track-active: #059669; + --color-toggle-thumb: #fff; + + /* Error */ + --color-error: #f87171; + --color-error-bg: #450a0a; + --color-error-border: #7f1d1d; + + /* Fallback / warning banners */ + --color-banner-amber-bg: #422006; + --color-banner-amber-border: #854d0e; + --color-banner-amber-text: #fcd34d; + + /* Pills / special badges */ + --color-pill-bg: #22222e; + --color-pill-text: #e2e2ea; + --color-pill-plugin-bg: #2e1065; + --color-pill-plugin-text: #c4b5fd; + --color-badge-category-bg: #1e1b4b; + --color-badge-category-text: #93c5fd; + --color-badge-type-technique-bg: #1e1b4b; + --color-badge-type-technique-text: #93c5fd; + --color-badge-type-moment-bg: #422006; + --color-badge-type-moment-text: #fcd34d; + --color-badge-content-type-bg: #22222e; + --color-badge-content-type-text: #e2e2ea; + --color-badge-quality-structured-bg: #052e16; + --color-badge-quality-structured-text: #6ee7b7; + --color-badge-quality-unstructured-bg: #422006; + --color-badge-quality-unstructured-text: #fcd34d; + + /* Genre pills */ + --color-genre-pill-bg: #1a1a24; + --color-genre-pill-text: #e2e2ea; + --color-genre-pill-border: #2a2a38; + --color-genre-pill-hover-bg: #22222e; + --color-genre-pill-hover-border: #67e8f9; + --color-genre-pill-active-bg: #22d3ee; + --color-genre-pill-active-text: #0f0f14; + --color-genre-pill-active-border: #22d3ee; + --color-genre-pill-active-hover-bg: #67e8f9; + + /* Sort toggle */ + --color-sort-btn-bg: #1a1a24; + --color-sort-btn-text: #8b8b9a; + --color-sort-btn-border: #2a2a38; + --color-sort-btn-hover-bg: #22222e; + --color-sort-btn-hover-text: #e2e2ea; + --color-sort-btn-active-bg: #22d3ee; + --color-sort-btn-active-text: #0f0f14; + --color-sort-btn-active-hover-bg: #67e8f9; + + /* Technique page creator link */ + --color-link-accent: #22d3ee; + + /* Search btn (dark variant) */ + --color-btn-search-bg: #22d3ee; + --color-btn-search-text: #0f0f14; + --color-btn-search-hover-bg: #67e8f9; + + /* Typeahead see-all link */ + --color-typeahead-see-all: #22d3ee; +} + +/* ── Base ─────────────────────────────────────────────────────────────────── */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + overflow-x: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; + color: var(--color-text-primary); + background: var(--color-bg-page); +} + +/* ── App shell ────────────────────────────────────────────────────────────── */ + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--color-bg-header); + color: var(--color-text-on-header-hover); +} + +.app-header h1 { + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.app-header__right { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.app-header nav a { + color: var(--color-text-on-header); + text-decoration: none; + font-size: 0.875rem; +} + +.app-header nav a:hover { + color: var(--color-text-on-header-hover); +} + +.app-main { + max-width: 72rem; + margin: 1.5rem auto; + padding: 0 1.5rem; +} + +/* ── Queue header ─────────────────────────────────────────────────────────── */ + +.queue-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.queue-header h2 { + font-size: 1.25rem; + font-weight: 700; +} + +/* ── Stats bar ────────────────────────────────────────────────────────────── */ + +.stats-bar { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.stats-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem; + border-radius: 0.5rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + box-shadow: 0 1px 3px var(--color-shadow); +} + +.stats-card__count { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; +} + +.stats-card__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + margin-top: 0.25rem; +} + +.stats-card--pending .stats-card__count { color: var(--color-badge-pending-text); } +.stats-card--approved .stats-card__count { color: var(--color-badge-approved-text); } +.stats-card--edited .stats-card__count { color: var(--color-badge-edited-text); } +.stats-card--rejected .stats-card__count { color: var(--color-badge-rejected-text); } + +/* ── Filter tabs ──────────────────────────────────────────────────────────── */ + +.filter-tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--color-border); + margin-bottom: 1rem; +} + +.filter-tab { + padding: 0.5rem 1rem; + border: none; + background: none; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.15s, border-color 0.15s; +} + +.filter-tab:hover { + color: var(--color-text-primary); +} + +.filter-tab--active { + color: var(--color-text-active); + border-bottom-color: var(--color-border-active); +} + +/* ── Cards ────────────────────────────────────────────────────────────────── */ + +.card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 1.25rem; + margin-bottom: 1rem; + box-shadow: 0 1px 3px var(--color-shadow); +} + +.card h2 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.card p { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +/* ── Queue cards ──────────────────────────────────────────────────────────── */ + +.queue-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.queue-card { + display: block; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 1rem 1.25rem; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 3px var(--color-shadow); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.queue-card:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 2px 8px var(--color-accent-subtle); +} + +.queue-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.375rem; +} + +.queue-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.queue-card__summary { + font-size: 0.8125rem; + color: var(--color-text-secondary); + margin-bottom: 0.375rem; + line-height: 1.4; +} + +.queue-card__meta { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.queue-card__separator { + color: var(--color-border); +} + +/* ── Status badges ────────────────────────────────────────────────────────── */ + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge--pending { + background: var(--color-badge-pending-bg); + color: var(--color-badge-pending-text); +} + +.badge--approved { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.badge--edited { + background: var(--color-badge-edited-bg); + color: var(--color-badge-edited-text); +} + +.badge--rejected { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); +} + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + background: var(--color-bg-surface); + color: var(--color-text-primary); + transition: background 0.15s, border-color 0.15s, opacity 0.15s; +} + +.btn:hover { + background: var(--color-bg-surface-hover); + border-color: var(--color-text-muted); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--approve { + background: var(--color-btn-approve); + color: var(--color-text-on-header-hover); + border-color: var(--color-btn-approve); +} + +.btn--approve:hover { + background: var(--color-btn-approve-hover); +} + +.btn--reject { + background: var(--color-btn-reject); + color: var(--color-text-on-header-hover); + border-color: var(--color-btn-reject); +} + +.btn--reject:hover { + background: var(--color-btn-reject-hover); +} + +/* ── Mode toggle ──────────────────────────────────────────────────────────── */ + +.mode-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.mode-toggle__dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +.mode-toggle__dot--review { + background: var(--color-toggle-review); +} + +.mode-toggle__dot--auto { + background: var(--color-toggle-auto); +} + +.mode-toggle__label { + color: var(--color-text-on-header-label); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 6rem; +} + +.mode-toggle__switch { + position: relative; + width: 2.5rem; + height: 1.25rem; + background: var(--color-toggle-track); + border: none; + border-radius: 9999px; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; +} + +.mode-toggle__switch--active { + background: var(--color-toggle-track-active); +} + +.mode-toggle__switch::after { + content: ""; + position: absolute; + top: 0.125rem; + left: 0.125rem; + width: 1rem; + height: 1rem; + background: var(--color-toggle-thumb); + border-radius: 50%; + transition: transform 0.2s; +} + +.mode-toggle__switch--active::after { + transform: translateX(1.25rem); +} + +.mode-toggle__switch:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Pagination ───────────────────────────────────────────────────────────── */ + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 1.25rem; + padding: 0.75rem 0; +} + +.pagination__info { + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +/* ── Detail page ──────────────────────────────────────────────────────────── */ + +.back-link { + display: inline-block; + font-size: 0.875rem; + color: var(--color-text-secondary); + text-decoration: none; + margin-bottom: 0.5rem; +} + +.back-link:hover { + color: var(--color-text-primary); +} + +.detail-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.detail-header h2 { + font-size: 1.25rem; + font-weight: 700; +} + +.detail-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.detail-field { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.detail-field label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.detail-field span, +.detail-field p { + font-size: 0.875rem; + color: var(--color-text-primary); +} + +.detail-field--full { + grid-column: 1 / -1; +} + +.detail-transcript { + background: var(--color-bg-transcript); + padding: 0.75rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + line-height: 1.6; + white-space: pre-wrap; + max-height: 20rem; + overflow-y: auto; +} + +/* ── Action bar ───────────────────────────────────────────────────────────── */ + +.action-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.action-error { + background: var(--color-error-bg); + border: 1px solid var(--color-error-border); + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + color: var(--color-badge-rejected-text); + font-size: 0.8125rem; + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +/* ── Edit form ────────────────────────────────────────────────────────────── */ + +.edit-form { + margin-top: 1rem; +} + +.edit-form h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.edit-field { + margin-bottom: 0.75rem; +} + +.edit-field label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.25rem; +} + +.edit-field input, +.edit-field textarea, +.edit-field select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: inherit; + line-height: 1.5; + color: var(--color-text-primary); + background: var(--color-bg-input); + transition: border-color 0.15s; +} + +.edit-field input:focus, +.edit-field textarea:focus, +.edit-field select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-focus); +} + +.edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +/* ── Dialogs (modal overlays) ─────────────────────────────────────────────── */ + +.dialog-overlay { + position: fixed; + inset: 0; + background: var(--color-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.dialog { + background: var(--color-bg-surface); + border-radius: 0.75rem; + padding: 1.5rem; + width: 90%; + max-width: 28rem; + box-shadow: 0 10px 40px var(--color-shadow-heavy); +} + +.dialog h3 { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.dialog__hint { + font-size: 0.8125rem; + color: var(--color-text-secondary); + margin-bottom: 1rem; +} + +.dialog__actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +/* ── Loading / empty states ───────────────────────────────────────────────── */ + +.loading { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.error-text { + color: var(--color-error); +} + +/* ── Responsive ───────────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .stats-bar { + flex-direction: column; + gap: 0.5rem; + } + + .stats-card { + flex-direction: row; + justify-content: space-between; + } + + .detail-card { + grid-template-columns: 1fr; + } + + .queue-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .action-bar { + flex-direction: column; + } + + .action-bar .btn { + width: 100%; + justify-content: center; + } + + .app-header { + flex-direction: column; + gap: 0.5rem; + } + + .app-header__right { + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + } +} + +/* ══════════════════════════════════════════════════════════════════════════════ + PUBLIC PAGES + ══════════════════════════════════════════════════════════════════════════════ */ + +/* ── Header brand link ────────────────────────────────────────────────────── */ + +.app-header__brand { + text-decoration: none; + color: inherit; +} + +.app-nav { + display: flex; + align-items: center; + gap: 1rem; +} + +.app-nav a { + color: var(--color-text-on-header); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.15s; +} + +.app-nav a:hover { + color: var(--color-text-on-header-hover); +} + +/* ── Home / Hero ──────────────────────────────────────────────────────────── */ + +.home-hero { + text-align: center; + padding: 3rem 1rem 2rem; +} + +.home-hero__title { + font-size: 2.25rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.375rem; +} + +.home-hero__subtitle { + font-size: 1rem; + color: var(--color-text-secondary); + margin-bottom: 1.5rem; +} + +/* ── Search form ──────────────────────────────────────────────────────────── */ + +.search-container { + position: relative; + max-width: 36rem; + margin: 0 auto; +} + +.search-form { + display: flex; + gap: 0.5rem; +} + +.search-form--hero { + justify-content: center; +} + +.search-form--inline { + margin-bottom: 1.25rem; +} + +.search-input { + flex: 1; + padding: 0.625rem 1rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + font-size: 0.9375rem; + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-input); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-focus); +} + +.search-input--hero { + padding: 0.75rem 1.25rem; + font-size: 1.0625rem; + border-radius: 0.625rem; +} + +.btn--search { + background: var(--color-btn-search-bg); + color: var(--color-btn-search-text); + border-color: var(--color-btn-search-bg); + border-radius: 0.5rem; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.btn--search:hover { + background: var(--color-btn-search-hover-bg); +} + +/* ── Typeahead dropdown ───────────────────────────────────────────────────── */ + +.typeahead-dropdown { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 8px 24px var(--color-shadow-heavy); + z-index: 50; + overflow: hidden; +} + +.typeahead-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1rem; + text-decoration: none; + color: inherit; + transition: background 0.1s; +} + +.typeahead-item:hover { + background: var(--color-bg-surface-hover); +} + +.typeahead-item__title { + font-size: 0.875rem; + font-weight: 500; +} + +.typeahead-item__meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.typeahead-item__type { + padding: 0.0625rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.typeahead-item__type--technique_page { + background: var(--color-badge-type-technique-bg); + color: var(--color-badge-type-technique-text); +} + +.typeahead-item__type--key_moment { + background: var(--color-badge-type-moment-bg); + color: var(--color-badge-type-moment-text); +} + +.typeahead-see-all { + display: block; + padding: 0.5rem 1rem; + text-align: center; + font-size: 0.8125rem; + color: var(--color-typeahead-see-all); + text-decoration: none; + border-top: 1px solid var(--color-border); + transition: background 0.1s; +} + +.typeahead-see-all:hover { + background: var(--color-bg-surface-hover); +} + +/* ── Navigation cards ─────────────────────────────────────────────────────── */ + +.nav-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + max-width: 36rem; + margin: 0 auto 2rem; +} + +.nav-card { + display: block; + padding: 1.5rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.625rem; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 3px var(--color-shadow); + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; +} + +.nav-card:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 4px 12px var(--color-accent-subtle); + transform: translateY(-1px); +} + +.nav-card__title { + font-size: 1.0625rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.nav-card__desc { + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* ── Recently Added section ───────────────────────────────────────────────── */ + +.recent-section { + max-width: 36rem; + margin: 0 auto 2rem; +} + +.recent-section__title { + font-size: 1.125rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.recent-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.recent-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.875rem 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.recent-card:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 2px 8px var(--color-accent-subtle); +} + +.recent-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.recent-card__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.recent-card__summary { + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* ── Search results page ──────────────────────────────────────────────────── */ + +.search-results-page { + max-width: 48rem; +} + +.search-fallback-banner { + padding: 0.5rem 0.75rem; + background: var(--color-banner-amber-bg); + border: 1px solid var(--color-banner-amber-border); + border-radius: 0.375rem; + font-size: 0.8125rem; + color: var(--color-banner-amber-text); + margin-bottom: 1rem; +} + +.search-group { + margin-bottom: 1.5rem; +} + +.search-group__title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: var(--color-text-primary); +} + +.search-group__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.search-result-card { + display: block; + padding: 1rem 1.25rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 3px var(--color-shadow); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-result-card:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 2px 8px var(--color-accent-subtle); +} + +.search-result-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; +} + +.search-result-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.search-result-card__summary { + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.4; + margin-bottom: 0.375rem; +} + +.search-result-card__meta { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-muted); + flex-wrap: wrap; +} + +.search-result-card__tags { + display: inline-flex; + gap: 0.25rem; + margin-left: 0.25rem; +} + +/* ── Pills / tags ─────────────────────────────────────────────────────────── */ + +.pill { + display: inline-block; + padding: 0.0625rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 500; + background: var(--color-pill-bg); + color: var(--color-pill-text); +} + +.pill--plugin { + background: var(--color-pill-plugin-bg); + color: var(--color-pill-plugin-text); +} + +.pill-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.badge--category { + background: var(--color-badge-category-bg); + color: var(--color-badge-category-text); +} + +.badge--type { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.badge--type-technique_page { + background: var(--color-badge-type-technique-bg); + color: var(--color-badge-type-technique-text); +} + +.badge--type-key_moment { + background: var(--color-badge-type-moment-bg); + color: var(--color-badge-type-moment-text); +} + +.badge--content-type { + background: var(--color-badge-content-type-bg); + color: var(--color-badge-content-type-text); + font-size: 0.6875rem; +} + +.badge--quality { + font-size: 0.6875rem; + text-transform: capitalize; +} + +.badge--quality-structured { + background: var(--color-badge-quality-structured-bg); + color: var(--color-badge-quality-structured-text); +} + +.badge--quality-unstructured { + background: var(--color-badge-quality-unstructured-bg); + color: var(--color-badge-quality-unstructured-text); +} + +/* ── Technique page ───────────────────────────────────────────────────────── */ + +.technique-page { + max-width: 48rem; +} + +.technique-404 { + text-align: center; + padding: 3rem 1rem; +} + +.technique-404 h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-404 p { + color: var(--color-text-secondary); + margin-bottom: 1.5rem; +} + +.technique-banner { + padding: 0.625rem 1rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + margin-bottom: 1rem; +} + +.technique-banner--amber { + background: var(--color-banner-amber-bg); + border: 1px solid var(--color-banner-amber-border); + color: var(--color-banner-amber-text); +} + +.technique-header { + margin-bottom: 1.5rem; +} + +.technique-header__title { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.5rem; + line-height: 1.2; +} + +.technique-header__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.technique-header__tags { + display: inline-flex; + gap: 0.25rem; +} + +.technique-header__creator { + font-size: 0.875rem; + color: var(--color-link-accent); + text-decoration: none; +} + +.technique-header__creator:hover { + text-decoration: underline; +} + +.technique-header__stats { + font-size: 0.8125rem; + color: var(--color-text-secondary); + margin-top: 0.5rem; +} + +/* ── Technique prose / sections ───────────────────────────────────────────── */ + +.technique-summary { + margin-bottom: 1.5rem; +} + +.technique-summary p { + font-size: 1rem; + color: var(--color-text-primary); + line-height: 1.6; +} + +.technique-prose { + margin-bottom: 2rem; +} + +.technique-prose__section { + margin-bottom: 1.5rem; +} + +.technique-prose__section h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-prose__section p { + font-size: 0.9375rem; + color: var(--color-text-primary); + line-height: 1.7; +} + +.technique-prose__json { + background: var(--color-bg-transcript); + padding: 0.75rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + overflow-x: auto; + line-height: 1.5; +} + +/* ── Key moments list ─────────────────────────────────────────────────────── */ + +.technique-moments { + margin-bottom: 2rem; +} + +.technique-moments h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.technique-moments__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.technique-moment { + padding: 0.875rem 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; +} + +.technique-moment__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + flex-wrap: wrap; +} + +.technique-moment__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.technique-moment__time { + font-size: 0.75rem; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.technique-moment__source { + font-size: 0.75rem; + color: var(--color-text-muted); + font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", monospace; + max-width: 20rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.technique-moment__summary { + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.5; +} + +/* ── Signal chains ────────────────────────────────────────────────────────── */ + +.technique-chains { + margin-bottom: 2rem; +} + +.technique-chains h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.technique-chain { + margin-bottom: 1rem; + padding: 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; +} + +.technique-chain h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.technique-chain__flow { + font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", monospace; + font-size: 0.875rem; + line-height: 1.8; + color: var(--color-text-primary); + background: var(--color-bg-transcript); + padding: 0.75rem 1rem; + border-radius: 0.375rem; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.technique-chain__arrow { + color: var(--color-accent); +} + +.technique-chain__step { + display: inline; +} + +/* ── Plugins ──────────────────────────────────────────────────────────────── */ + +.technique-plugins { + margin-bottom: 2rem; +} + +.technique-plugins h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +/* ── Related techniques ───────────────────────────────────────────────────── */ + +.technique-related { + margin-bottom: 2rem; +} + +.technique-related h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.technique-related__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.technique-related__list a { + color: var(--color-link-accent); + text-decoration: none; + font-size: 0.9375rem; +} + +.technique-related__list a:hover { + text-decoration: underline; +} + +.technique-related__rel { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-left: 0.375rem; +} + +/* ══════════════════════════════════════════════════════════════════════════════ + CREATORS BROWSE + ══════════════════════════════════════════════════════════════════════════════ */ + +.creators-browse { + max-width: 56rem; +} + +.creators-browse__title { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.creators-browse__subtitle { + font-size: 0.9375rem; + color: var(--color-text-secondary); + margin-bottom: 1.25rem; +} + +/* ── Controls row ─────────────────────────────────────────────────────────── */ + +.creators-controls { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.sort-toggle { + display: inline-flex; + border: 1px solid var(--color-sort-btn-border); + border-radius: 0.375rem; + overflow: hidden; +} + +.sort-toggle__btn { + padding: 0.375rem 0.75rem; + border: none; + background: var(--color-sort-btn-bg); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-sort-btn-text); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.sort-toggle__btn + .sort-toggle__btn { + border-left: 1px solid var(--color-sort-btn-border); +} + +.sort-toggle__btn:hover { + background: var(--color-sort-btn-hover-bg); + color: var(--color-sort-btn-hover-text); +} + +.sort-toggle__btn--active { + background: var(--color-sort-btn-active-bg); + color: var(--color-sort-btn-active-text); +} + +.sort-toggle__btn--active:hover { + background: var(--color-sort-btn-active-hover-bg); +} + +.creators-filter-input { + flex: 1; + min-width: 12rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-input); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.creators-filter-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-focus); +} + +/* ── Genre pills ──────────────────────────────────────────────────────────── */ + +.genre-pills { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 1.25rem; +} + +.genre-pill { + display: inline-block; + padding: 0.25rem 0.75rem; + border: 1px solid var(--color-genre-pill-border); + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + background: var(--color-genre-pill-bg); + color: var(--color-genre-pill-text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.genre-pill:hover { + border-color: var(--color-genre-pill-hover-border); + background: var(--color-genre-pill-hover-bg); +} + +.genre-pill--active { + background: var(--color-genre-pill-active-bg); + color: var(--color-genre-pill-active-text); + border-color: var(--color-genre-pill-active-border); +} + +.genre-pill--active:hover { + background: var(--color-genre-pill-active-hover-bg); +} + +/* ── Creator list ─────────────────────────────────────────────────────────── */ + +.creators-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.creator-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1.25rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 3px var(--color-shadow); + transition: border-color 0.15s, box-shadow 0.15s; + flex-wrap: wrap; +} + +.creator-row:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 2px 8px var(--color-accent-subtle); +} + +.creator-row__name { + font-size: 0.9375rem; + font-weight: 600; + min-width: 10rem; +} + +.creator-row__genres { + display: inline-flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.creator-row__stats { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.creator-row__stat { + font-variant-numeric: tabular-nums; +} + +.creator-row__separator { + color: var(--color-border); +} + +/* ══════════════════════════════════════════════════════════════════════════════ + CREATOR DETAIL + ══════════════════════════════════════════════════════════════════════════════ */ + +.creator-detail { + max-width: 48rem; +} + +.creator-detail__header { + margin-bottom: 1.5rem; +} + +.creator-detail__name { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.5rem; + line-height: 1.2; +} + +.creator-detail__meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.creator-detail__genres { + display: inline-flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.creator-detail__stats { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.creator-techniques { + margin-top: 1.5rem; +} + +.creator-techniques__title { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.creator-techniques__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.creator-technique-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.875rem 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 3px var(--color-shadow); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.creator-technique-card:hover { + border-color: var(--color-accent-hover); + box-shadow: 0 2px 8px var(--color-accent-subtle); +} + +.creator-technique-card__title { + font-size: 0.9375rem; + font-weight: 600; +} + +.creator-technique-card__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.creator-technique-card__tags { + display: inline-flex; + gap: 0.25rem; +} + +.creator-technique-card__summary { + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* ══════════════════════════════════════════════════════════════════════════════ + TOPICS BROWSE + ══════════════════════════════════════════════════════════════════════════════ */ + +.topics-browse { + max-width: 56rem; +} + +.topics-browse__title { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.topics-browse__subtitle { + font-size: 0.9375rem; + color: var(--color-text-secondary); + margin-bottom: 1.25rem; +} + +.topics-filter-input { + width: 100%; + max-width: 24rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-input); + margin-bottom: 1.25rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.topics-filter-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-focus); +} + +/* ── Topics hierarchy ─────────────────────────────────────────────────────── */ + +.topics-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.topic-category { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 1px 3px var(--color-shadow); +} + +.topic-category__header { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + padding: 0.875rem 1.25rem; + border: none; + background: none; + cursor: pointer; + text-align: left; + font-family: inherit; + transition: background 0.15s; +} + +.topic-category__header:hover { + background: var(--color-bg-surface-hover); +} + +.topic-category__chevron { + font-size: 0.625rem; + color: var(--color-text-muted); + flex-shrink: 0; + width: 0.75rem; +} + +.topic-category__name { + font-size: 1rem; + font-weight: 700; + color: var(--color-text-primary); +} + +.topic-category__desc { + font-size: 0.8125rem; + color: var(--color-text-secondary); + flex: 1; +} + +.topic-category__count { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + margin-left: auto; +} + +/* ── Sub-topics ───────────────────────────────────────────────────────────── */ + +.topic-subtopics { + border-top: 1px solid var(--color-border); +} + +.topic-subtopic { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1.25rem 0.625rem 2.75rem; + text-decoration: none; + color: inherit; + font-size: 0.875rem; + transition: background 0.1s; +} + +.topic-subtopic:hover { + background: var(--color-bg-surface-hover); +} + +.topic-subtopic + .topic-subtopic { + border-top: 1px solid var(--color-bg-surface-hover); +} + +.topic-subtopic__name { + font-weight: 500; + color: var(--color-text-primary); + text-transform: capitalize; +} + +.topic-subtopic__counts { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.topic-subtopic__count { + font-variant-numeric: tabular-nums; +} + +.topic-subtopic__separator { + color: var(--color-border); +} + +/* ── Public responsive (extended) ─────────────────────────────────────────── */ + +@media (max-width: 640px) { + .home-hero__title { + font-size: 1.75rem; + } + + .nav-cards { + grid-template-columns: 1fr; + } + + .technique-header__title { + font-size: 1.375rem; + } + + .search-form { + flex-direction: column; + } + + .search-input--hero { + width: 100%; + } + + .app-nav { + gap: 0.75rem; + font-size: 0.8125rem; + } + + .creators-controls { + flex-direction: column; + align-items: stretch; + } + + .creator-row { + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + } + + .creator-row__stats { + margin-left: 0; + white-space: normal; + flex-wrap: wrap; + } + + .creators-browse__title, + .topics-browse__title, + .creator-detail__name { + font-size: 1.375rem; + } + + .topic-category__desc { + display: none; + } + + .topic-subtopic { + padding-left: 2rem; + } +} + +/* ── Report Issue Modal ─────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + background: var(--color-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.5rem; + width: 90%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; +} + +.report-modal__title { + margin: 0 0 0.75rem; + color: var(--color-text-primary); + font-size: 1.1rem; +} + +.report-modal__context { + color: var(--color-text-secondary); + font-size: 0.85rem; + margin: 0 0 1rem; +} + +.report-modal__context strong { + color: var(--color-text-primary); +} + +.report-modal__success { + color: var(--color-accent); + margin: 0.5rem 0 1.5rem; +} + +.report-modal__label { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.report-modal__select { + background: var(--color-bg-input); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem; + font-size: 0.9rem; +} + +.report-modal__textarea { + background: var(--color-bg-input); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem; + font-size: 0.9rem; + resize: vertical; + min-height: 80px; + font-family: inherit; +} + +.report-modal__textarea:focus, +.report-modal__select:focus { + outline: none; + border-color: var(--color-accent); +} + +.report-modal__error { + color: var(--color-error); + font-size: 0.85rem; + margin: 0 0 0.75rem; +} + +.report-modal__actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.report-issue-btn { + margin-top: 0.5rem; + align-self: flex-start; +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.btn { + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + border: 1px solid var(--color-border); + transition: background 0.15s, border-color 0.15s; +} + +.btn--small { + padding: 0.3rem 0.7rem; + font-size: 0.8rem; +} + +.btn--primary { + background: var(--color-accent); + color: var(--color-bg-page); + border-color: var(--color-accent); + font-weight: 600; +} + +.btn--primary:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.btn--primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--secondary { + background: var(--color-bg-input); + color: var(--color-text-primary); + border-color: var(--color-border); +} + +.btn--secondary:hover:not(:disabled) { + background: var(--color-border); +} + +.btn--danger { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); + border-color: var(--color-badge-rejected-bg); +} + +.btn--danger:hover:not(:disabled) { + opacity: 0.85; +} + +/* ── Admin Reports ──────────────────────────────────────────────────────── */ + +.admin-reports { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.admin-reports__title { + color: var(--color-text-primary); + margin: 0 0 0.25rem; +} + +.admin-reports__subtitle { + color: var(--color-text-muted); + margin: 0 0 1.5rem; + font-size: 0.9rem; +} + +.admin-reports__filters { + margin-bottom: 1.25rem; +} + +.admin-reports__list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.report-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; +} + +.report-card--open { + border-left: 3px solid var(--color-accent); +} + +.report-card--acknowledged { + border-left: 3px solid var(--color-badge-pending-text); +} + +.report-card--resolved { + border-left: 3px solid var(--color-badge-approved-text); +} + +.report-card--dismissed { + border-left: 3px solid var(--color-text-muted); + opacity: 0.7; +} + +.report-card__header { + padding: 0.75rem 1rem; + cursor: pointer; +} + +.report-card__header:hover { + background: var(--color-bg-input); +} + +.report-card__meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; + flex-wrap: wrap; +} + +.report-card__date { + color: var(--color-text-muted); + font-size: 0.8rem; + margin-left: auto; +} + +.report-card__summary { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.report-card__content-title { + color: var(--color-text-primary); + font-weight: 500; + font-size: 0.9rem; +} + +.report-card__description { + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.report-card__detail { + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.report-card__full-description { + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.report-card__full-description strong { + color: var(--color-text-primary); +} + +.report-card__full-description p { + margin: 0.25rem 0 0; + white-space: pre-wrap; +} + +.report-card__url { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.report-card__url a { + color: var(--color-accent); +} + +.report-card__info-row { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--color-text-muted); + flex-wrap: wrap; +} + +.report-card__notes-label { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.report-card__notes { + background: var(--color-bg-input); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem; + font-size: 0.85rem; + font-family: inherit; + resize: vertical; +} + +.report-card__notes:focus { + outline: none; + border-color: var(--color-accent); +} + +.report-card__actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Status pill colors */ +.pill--open { + background: var(--color-accent-subtle); + color: var(--color-accent); +} + +.pill--acknowledged { + background: var(--color-badge-pending-bg); + color: var(--color-badge-pending-text); +} + +.pill--resolved { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.pill--dismissed { + background: var(--color-bg-input); + color: var(--color-text-muted); +} + +/* ── Version Switcher ───────────────────────────────────────────────────── */ + +.technique-header__actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.version-switcher { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.version-switcher__label { + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.version-switcher__select { + background: var(--color-bg-input); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; +} + +.version-switcher__select:focus { + outline: none; + border-color: var(--color-accent); +} + +.version-switcher__loading { + color: var(--color-text-muted); + font-size: 0.75rem; + font-style: italic; +} + +.technique-banner--version { + background: var(--color-accent-subtle); + border: 1px solid var(--color-accent); + border-radius: 8px; + padding: 0.6rem 1rem; + color: var(--color-accent); + font-size: 0.85rem; + display: flex; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +/* ── Version Metadata ───────────────────────────────────────────────────── */ + +.version-metadata { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; +} + +.version-metadata__title { + color: var(--color-text-secondary); + font-size: 0.8rem; + font-weight: 600; + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.version-metadata__grid { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.version-metadata__item { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.version-metadata__item--wide { + flex-basis: 100%; +} + +.version-metadata__key { + color: var(--color-text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.version-metadata__value { + color: var(--color-text-primary); + font-size: 0.85rem; +} + +.version-metadata__hashes { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.15rem; +} + +.version-metadata__hash { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.version-metadata__hash-file { + color: var(--color-text-secondary); +} + +.version-metadata__hash-value { + font-family: "SF Mono", "Fira Code", monospace; + color: var(--color-text-muted); + font-size: 0.75rem; + background: var(--color-bg-input); + padding: 0.1rem 0.35rem; + border-radius: 3px; +} + +/* ── Pipeline Admin ─────────────────────────────────────────────────────── */ + +.admin-pipeline { + max-width: 1100px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.admin-pipeline__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.admin-pipeline__header-right { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.admin-pipeline__title { + color: var(--color-text-primary); + margin: 0 0 0.25rem; +} + +.admin-pipeline__subtitle { + color: var(--color-text-muted); + margin: 0; + font-size: 0.9rem; +} + +.admin-pipeline__list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* ── Worker Status Indicator ────────────────────────────────────────────── */ + +.worker-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--color-text-secondary); + padding: 0.35rem 0.75rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + white-space: nowrap; +} + +.worker-status__dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.worker-status__dot--online { + background: var(--color-badge-approved-text); + box-shadow: 0 0 6px var(--color-badge-approved-text); +} + +.worker-status__dot--offline { + background: var(--color-error); + box-shadow: 0 0 6px var(--color-error); +} + +.worker-status__dot--unknown { + background: var(--color-text-muted); +} + +.worker-status__label { + font-weight: 500; +} + +.worker-status__detail { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.worker-status--error { + border-color: var(--color-error-border); +} + +/* ── Pipeline Video Row ─────────────────────────────────────────────────── */ + +.pipeline-video { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; +} + +.pipeline-video__header { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + cursor: pointer; +} + +.pipeline-video__header:hover { + background: var(--color-bg-input); +} + +.pipeline-video__info { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.pipeline-video__filename { + color: var(--color-text-primary); + font-weight: 500; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pipeline-video__creator { + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.pipeline-video__meta { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; +} + +.pipeline-video__stat { + color: var(--color-text-muted); + font-size: 0.8rem; + white-space: nowrap; +} + +.pipeline-video__time { + color: var(--color-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +.pipeline-video__actions { + display: flex; + gap: 0.375rem; +} + +.pipeline-video__message { + padding: 0.375rem 1rem; + font-size: 0.8rem; +} + +.pipeline-video__message--ok { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.pipeline-video__message--err { + background: var(--color-error-bg); + color: var(--color-error); +} + +.pipeline-video__detail { + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--color-border); +} + +.pipeline-video__detail-meta { + display: flex; + gap: 1.25rem; + font-size: 0.8rem; + color: var(--color-text-muted); + margin-bottom: 1rem; + flex-wrap: wrap; +} + +/* ── Pipeline Badges ────────────────────────────────────────────────────── */ + +.pipeline-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + background: var(--color-pill-bg); + color: var(--color-pill-text); + white-space: nowrap; +} + +.pipeline-badge--success { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.pipeline-badge--active { + background: var(--color-badge-edited-bg); + color: var(--color-badge-edited-text); +} + +.pipeline-badge--error { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); +} + +.pipeline-badge--pending { + background: var(--color-badge-pending-bg); + color: var(--color-badge-pending-text); +} + +.pipeline-badge--event-start { + background: var(--color-badge-edited-bg); + color: var(--color-badge-edited-text); +} + +.pipeline-badge--event-complete { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.pipeline-badge--event-error { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); +} + +.pipeline-badge--event-llm_call { + background: var(--color-pill-plugin-bg); + color: var(--color-pill-plugin-text); +} + +/* ── Pipeline Events ────────────────────────────────────────────────────── */ + +.pipeline-events__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.pipeline-events__count { + font-size: 0.85rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.pipeline-events__empty { + font-size: 0.85rem; + color: var(--color-text-muted); + padding: 0.5rem 0; +} + +.pipeline-events__list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.pipeline-event { + background: var(--color-bg-page); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.pipeline-event--error { + border-left: 3px solid var(--color-error); +} + +.pipeline-event__row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.pipeline-event__icon { + font-size: 0.85rem; + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} + +.pipeline-event__stage { + color: var(--color-text-primary); + font-size: 0.8125rem; + font-weight: 500; +} + +.pipeline-event__model { + color: var(--color-text-muted); + font-size: 0.75rem; + font-family: monospace; +} + +.pipeline-event__tokens { + color: var(--color-pill-plugin-text); + font-size: 0.75rem; + font-weight: 500; +} + +.pipeline-event__duration { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.pipeline-event__time { + color: var(--color-text-muted); + font-size: 0.75rem; + margin-left: auto; + white-space: nowrap; +} + +/* ── Pipeline Events Pager ──────────────────────────────────────────────── */ + +.pipeline-events__pager { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.pipeline-events__pager-info { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +/* ── Collapsible JSON ───────────────────────────────────────────────────── */ + +.json-viewer { + margin-top: 0.375rem; +} + +.json-viewer__toggle { + background: none; + border: none; + color: var(--color-accent); + font-size: 0.75rem; + cursor: pointer; + padding: 0; + font-family: inherit; +} + +.json-viewer__toggle:hover { + color: var(--color-accent-hover); +} + +.json-viewer__content { + margin: 0.375rem 0 0; + padding: 0.5rem 0.75rem; + background: var(--color-bg-transcript); + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text-secondary); + font-size: 0.75rem; + line-height: 1.5; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e96014c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,58 @@ +import { Link, Navigate, Route, Routes } from "react-router-dom"; +import Home from "./pages/Home"; +import SearchResults from "./pages/SearchResults"; +import TechniquePage from "./pages/TechniquePage"; +import CreatorsBrowse from "./pages/CreatorsBrowse"; +import CreatorDetail from "./pages/CreatorDetail"; +import TopicsBrowse from "./pages/TopicsBrowse"; +import ReviewQueue from "./pages/ReviewQueue"; +import MomentDetail from "./pages/MomentDetail"; +import AdminReports from "./pages/AdminReports"; +import AdminPipeline from "./pages/AdminPipeline"; +import ModeToggle from "./components/ModeToggle"; + +export default function App() { + return ( +
+
+ +

Chrysopedia

+ +
+ + +
+
+ +
+ + {/* Public routes */} + } /> + } /> + } /> + + {/* Browse routes */} + } /> + } /> + } /> + + {/* Admin routes */} + } /> + } /> + } /> + } /> + + {/* Fallback */} + } /> + +
+
+ ); +} diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts new file mode 100644 index 0000000..b94f0a7 --- /dev/null +++ b/frontend/src/api/public-client.ts @@ -0,0 +1,469 @@ +/** + * Typed API client for Chrysopedia public endpoints. + * + * Mirrors backend schemas: SearchResponse, TechniquePageDetail, TopicCategory, CreatorBrowseItem. + * Uses the same request pattern as client.ts. + */ + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface SearchResultItem { + title: string; + slug: string; + type: string; + score: number; + summary: string; + creator_name: string; + creator_slug: string; + topic_category: string; + topic_tags: string[]; +} + +export interface SearchResponse { + items: SearchResultItem[]; + total: number; + query: string; + fallback_used: boolean; +} + +export interface KeyMomentSummary { + id: string; + title: string; + summary: string; + start_time: number; + end_time: number; + content_type: string; + plugins: string[] | null; + video_filename: string; +} + +export interface CreatorInfo { + name: string; + slug: string; + genres: string[] | null; +} + +export interface RelatedLinkItem { + target_title: string; + target_slug: string; + relationship: string; +} + +export interface TechniquePageDetail { + id: string; + title: string; + slug: string; + topic_category: string; + topic_tags: string[] | null; + summary: string | null; + body_sections: Record | null; + signal_chains: unknown[] | null; + plugins: string[] | null; + creator_id: string; + source_quality: string | null; + view_count: number; + review_status: string; + created_at: string; + updated_at: string; + key_moments: KeyMomentSummary[]; + creator_info: CreatorInfo | null; + related_links: RelatedLinkItem[]; + version_count: number; +} + +export interface TechniquePageVersionSummary { + version_number: number; + created_at: string; + pipeline_metadata: Record | null; +} + +export interface TechniquePageVersionListResponse { + items: TechniquePageVersionSummary[]; + total: number; +} + +export interface TechniquePageVersionDetail { + version_number: number; + content_snapshot: Record; + pipeline_metadata: Record | null; + created_at: string; +} + +export interface TechniqueListItem { + id: string; + title: string; + slug: string; + topic_category: string; + topic_tags: string[] | null; + summary: string | null; + creator_id: string; + source_quality: string | null; + view_count: number; + review_status: string; + created_at: string; + updated_at: string; +} + +export interface TechniqueListResponse { + items: TechniqueListItem[]; + total: number; + offset: number; + limit: number; +} + +export interface TopicSubTopic { + name: string; + technique_count: number; + creator_count: number; +} + +export interface TopicCategory { + name: string; + description: string; + sub_topics: TopicSubTopic[]; +} + +export interface CreatorBrowseItem { + id: string; + name: string; + slug: string; + genres: string[] | null; + folder_name: string; + view_count: number; + created_at: string; + updated_at: string; + technique_count: number; + video_count: number; +} + +export interface CreatorBrowseResponse { + items: CreatorBrowseItem[]; + total: number; + offset: number; + limit: number; +} + +export interface CreatorDetailResponse { + id: string; + name: string; + slug: string; + genres: string[] | null; + folder_name: string; + view_count: number; + created_at: string; + updated_at: string; + video_count: number; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const BASE = "/api/v1"; + +class ApiError extends Error { + constructor( + public status: number, + public detail: string, + ) { + super(`API ${status}: ${detail}`); + this.name = "ApiError"; + } +} + +async function request(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); + + if (!res.ok) { + let detail = res.statusText; + try { + const body: unknown = await res.json(); + if (typeof body === "object" && body !== null && "detail" in body) { + const d = (body as { detail: unknown }).detail; + detail = typeof d === "string" ? d : Array.isArray(d) ? d.map((e: any) => e.msg || JSON.stringify(e)).join("; ") : JSON.stringify(d); + } + } catch { + // body not JSON — keep statusText + } + throw new ApiError(res.status, detail); + } + + return res.json() as Promise; +} + +// ── Search ─────────────────────────────────────────────────────────────────── + +export async function searchApi( + q: string, + scope?: string, + limit?: number, +): Promise { + const qs = new URLSearchParams({ q }); + if (scope) qs.set("scope", scope); + if (limit !== undefined) qs.set("limit", String(limit)); + return request(`${BASE}/search?${qs.toString()}`); +} + +// ── Techniques ─────────────────────────────────────────────────────────────── + +export interface TechniqueListParams { + limit?: number; + offset?: number; + category?: string; + creator_slug?: string; +} + +export async function fetchTechniques( + params: TechniqueListParams = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + if (params.category) qs.set("category", params.category); + if (params.creator_slug) qs.set("creator_slug", params.creator_slug); + const query = qs.toString(); + return request( + `${BASE}/techniques${query ? `?${query}` : ""}`, + ); +} + +export async function fetchTechnique( + slug: string, +): Promise { + return request(`${BASE}/techniques/${slug}`); +} + +export async function fetchTechniqueVersions( + slug: string, +): Promise { + return request( + `${BASE}/techniques/${slug}/versions`, + ); +} + +export async function fetchTechniqueVersion( + slug: string, + versionNumber: number, +): Promise { + return request( + `${BASE}/techniques/${slug}/versions/${versionNumber}`, + ); +} + +// ── Topics ─────────────────────────────────────────────────────────────────── + +export async function fetchTopics(): Promise { + return request(`${BASE}/topics`); +} + +// ── Creators ───────────────────────────────────────────────────────────────── + +export interface CreatorListParams { + sort?: string; + genre?: string; + limit?: number; + offset?: number; +} + +export async function fetchCreators( + params: CreatorListParams = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.sort) qs.set("sort", params.sort); + if (params.genre) qs.set("genre", params.genre); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + const query = qs.toString(); + return request( + `${BASE}/creators${query ? `?${query}` : ""}`, + ); +} + +export async function fetchCreator( + slug: string, +): Promise { + return request(`${BASE}/creators/${slug}`); +} + + +// ── Content Reports ───────────────────────────────────────────────────────── + +export interface ContentReportCreate { + content_type: string; + content_id?: string | null; + content_title?: string | null; + report_type: string; + description: string; + page_url?: string | null; +} + +export interface ContentReport { + id: string; + content_type: string; + content_id: string | null; + content_title: string | null; + report_type: string; + description: string; + status: string; + admin_notes: string | null; + page_url: string | null; + created_at: string; + resolved_at: string | null; +} + +export interface ContentReportListResponse { + items: ContentReport[]; + total: number; + offset: number; + limit: number; +} + +export async function submitReport( + body: ContentReportCreate, +): Promise { + return request(`${BASE}/reports`, { + method: "POST", + body: JSON.stringify(body), + }); +} + +export async function fetchReports(params: { + status?: string; + content_type?: string; + offset?: number; + limit?: number; +} = {}): Promise { + const qs = new URLSearchParams(); + if (params.status) qs.set("status", params.status); + if (params.content_type) qs.set("content_type", params.content_type); + 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( + `${BASE}/admin/reports${query ? `?${query}` : ""}`, + ); +} + +export async function updateReport( + id: string, + body: { status?: string; admin_notes?: string }, +): Promise { + return request(`${BASE}/admin/reports/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); +} + + +// ── Pipeline Admin ────────────────────────────────────────────────────────── + +export interface PipelineVideoItem { + id: string; + filename: string; + processing_status: string; + creator_name: string; + created_at: string | null; + updated_at: string | null; + event_count: number; + total_tokens_used: number; + last_event_at: string | null; +} + +export interface PipelineVideoListResponse { + items: PipelineVideoItem[]; + total: number; +} + +export interface PipelineEvent { + id: string; + video_id: string; + stage: string; + event_type: string; + prompt_tokens: number | null; + completion_tokens: number | null; + total_tokens: number | null; + model: string | null; + duration_ms: number | null; + payload: Record | null; + created_at: string | null; +} + +export interface PipelineEventListResponse { + items: PipelineEvent[]; + total: number; + offset: number; + limit: number; +} + +export interface WorkerTask { + id: string; + name: string; + args: unknown[]; + time_start: number | null; +} + +export interface WorkerInfo { + name: string; + active_tasks: WorkerTask[]; + reserved_tasks: number; + total_completed: number; + uptime: string | null; + pool_size: number | null; +} + +export interface WorkerStatusResponse { + online: boolean; + workers: WorkerInfo[]; + error?: string; +} + +export interface TriggerResponse { + status: string; + video_id: string; + current_processing_status?: string; +} + +export interface RevokeResponse { + status: string; + video_id: string; + tasks_revoked: number; +} + +export async function fetchPipelineVideos(): Promise { + return request(`${BASE}/admin/pipeline/videos`); +} + +export async function fetchPipelineEvents( + videoId: string, + params: { offset?: number; limit?: number; stage?: string; event_type?: string } = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.stage) qs.set("stage", params.stage); + if (params.event_type) qs.set("event_type", params.event_type); + const query = qs.toString(); + return request( + `${BASE}/admin/pipeline/events/${videoId}${query ? `?${query}` : ""}`, + ); +} + +export async function fetchWorkerStatus(): Promise { + return request(`${BASE}/admin/pipeline/worker-status`); +} + +export async function triggerPipeline(videoId: string): Promise { + return request(`${BASE}/admin/pipeline/trigger/${videoId}`, { + method: "POST", + }); +} + +export async function revokePipeline(videoId: string): Promise { + return request(`${BASE}/admin/pipeline/revoke/${videoId}`, { + method: "POST", + }); +} diff --git a/frontend/src/pages/AdminPipeline.tsx b/frontend/src/pages/AdminPipeline.tsx new file mode 100644 index 0000000..e38f2a0 --- /dev/null +++ b/frontend/src/pages/AdminPipeline.tsx @@ -0,0 +1,417 @@ +/** + * Pipeline admin dashboard — video list with status, retrigger/revoke, + * expandable event log with token usage and collapsible JSON viewer. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + fetchPipelineVideos, + fetchPipelineEvents, + fetchWorkerStatus, + triggerPipeline, + revokePipeline, + type PipelineVideoItem, + type PipelineEvent, + type WorkerStatusResponse, +} from "../api/public-client"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatTokens(n: number): string { + if (n === 0) return "0"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function statusBadgeClass(status: string): string { + switch (status) { + case "completed": + case "indexed": + return "pipeline-badge--success"; + case "processing": + case "extracted": + case "classified": + case "synthesized": + return "pipeline-badge--active"; + case "failed": + case "error": + return "pipeline-badge--error"; + case "pending": + case "queued": + return "pipeline-badge--pending"; + default: + return ""; + } +} + +function eventTypeIcon(eventType: string): string { + switch (eventType) { + case "start": + return "▶"; + case "complete": + return "✓"; + case "error": + return "✗"; + case "llm_call": + return "🤖"; + default: + return "·"; + } +} + +// ── Collapsible JSON ───────────────────────────────────────────────────────── + +function JsonViewer({ data }: { data: Record | null }) { + const [open, setOpen] = useState(false); + if (!data || Object.keys(data).length === 0) return null; + + return ( +
+ + {open && ( +
+          {JSON.stringify(data, null, 2)}
+        
+ )} +
+ ); +} + +// ── Event Log ──────────────────────────────────────────────────────────────── + +function EventLog({ videoId }: { videoId: string }) { + const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const limit = 50; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchPipelineEvents(videoId, { offset, limit }); + setEvents(res.items); + setTotal(res.total); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load events"); + } finally { + setLoading(false); + } + }, [videoId, offset]); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return
Loading events…
; + if (error) return
Error: {error}
; + if (events.length === 0) return
No events recorded.
; + + const hasNext = offset + limit < total; + const hasPrev = offset > 0; + + return ( +
+
+ {total} event{total !== 1 ? "s" : ""} + +
+ +
+ {events.map((evt) => ( +
+
+ {eventTypeIcon(evt.event_type)} + {evt.stage} + + {evt.event_type} + + {evt.model && {evt.model}} + {evt.total_tokens != null && evt.total_tokens > 0 && ( + + {formatTokens(evt.total_tokens)} tok + + )} + {evt.duration_ms != null && ( + {evt.duration_ms}ms + )} + {formatDate(evt.created_at)} +
+ +
+ ))} +
+ + {(hasPrev || hasNext) && ( +
+ + + {offset + 1}–{Math.min(offset + limit, total)} of {total} + + +
+ )} +
+ ); +} + +// ── Worker Status ──────────────────────────────────────────────────────────── + +function WorkerStatus() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setError(null); + const res = await fetchWorkerStatus(); + setStatus(res); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed"); + } + }, []); + + useEffect(() => { + void load(); + const id = setInterval(() => void load(), 15_000); + return () => clearInterval(id); + }, [load]); + + if (error) { + return ( +
+ + Worker: error ({error}) +
+ ); + } + + if (!status) { + return ( +
+ + Worker: checking… +
+ ); + } + + return ( +
+ + + {status.online ? `${status.workers.length} worker${status.workers.length !== 1 ? "s" : ""} online` : "Workers offline"} + + {status.workers.map((w) => ( + + {w.active_tasks.length > 0 + ? `${w.active_tasks.length} active` + : "idle"} + {w.pool_size != null && ` · pool ${w.pool_size}`} + + ))} +
+ ); +} + +// ── Main Page ──────────────────────────────────────────────────────────────── + +export default function AdminPipeline() { + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + const [actionMessage, setActionMessage] = useState<{ id: string; text: string; ok: boolean } | null>(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchPipelineVideos(); + setVideos(res.items); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load videos"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleTrigger = async (videoId: string) => { + setActionLoading(videoId); + setActionMessage(null); + try { + const res = await triggerPipeline(videoId); + setActionMessage({ id: videoId, text: `Triggered (${res.status})`, ok: true }); + // Refresh after short delay to let status update + setTimeout(() => void load(), 2000); + } catch (err) { + setActionMessage({ + id: videoId, + text: err instanceof Error ? err.message : "Trigger failed", + ok: false, + }); + } finally { + setActionLoading(null); + } + }; + + const handleRevoke = async (videoId: string) => { + setActionLoading(videoId); + setActionMessage(null); + try { + const res = await revokePipeline(videoId); + setActionMessage({ + id: videoId, + text: res.tasks_revoked > 0 + ? `Revoked ${res.tasks_revoked} task${res.tasks_revoked !== 1 ? "s" : ""}` + : "No active tasks", + ok: true, + }); + setTimeout(() => void load(), 2000); + } catch (err) { + setActionMessage({ + id: videoId, + text: err instanceof Error ? err.message : "Revoke failed", + ok: false, + }); + } finally { + setActionLoading(null); + } + }; + + const toggleExpand = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + return ( +
+
+
+

Pipeline Management

+

+ {videos.length} video{videos.length !== 1 ? "s" : ""} +

+
+
+ + +
+
+ + {loading ? ( +
Loading videos…
+ ) : error ? ( +
Error: {error}
+ ) : videos.length === 0 ? ( +
No videos in pipeline.
+ ) : ( +
+ {videos.map((video) => ( +
+
toggleExpand(video.id)} + > +
+ + {video.filename} + + {video.creator_name} +
+ +
+ + {video.processing_status} + + + {video.event_count} events + + + {formatTokens(video.total_tokens_used)} tokens + + + {formatDate(video.last_event_at)} + +
+ +
e.stopPropagation()}> + + +
+
+ + {actionMessage?.id === video.id && ( +
+ {actionMessage.text} +
+ )} + + {expandedId === video.id && ( +
+
+ ID: {video.id.slice(0, 8)}… + Created: {formatDate(video.created_at)} + Updated: {formatDate(video.updated_at)} +
+ +
+ )} +
+ ))} +
+ )} +
+ ); +}