feat: Built complete admin review queue UI: queue list page with stats…
- "frontend/src/pages/ReviewQueue.tsx" - "frontend/src/pages/MomentDetail.tsx" - "frontend/src/components/StatusBadge.tsx" - "frontend/src/components/ModeToggle.tsx" - "frontend/src/App.tsx" - "frontend/src/App.css" GSD-Task: S04/T03
This commit is contained in:
parent
5542ae455f
commit
2cb0f9c381
10 changed files with 1249 additions and 80 deletions
|
|
@ -134,7 +134,7 @@
|
|||
- Estimate: 1h
|
||||
- Files: frontend/package.json, frontend/vite.config.ts, frontend/tsconfig.json, frontend/tsconfig.app.json, frontend/index.html, frontend/src/main.tsx, frontend/src/App.tsx, frontend/src/App.css, frontend/src/api/client.ts, frontend/src/pages/ReviewQueue.tsx, frontend/src/pages/MomentDetail.tsx
|
||||
- Verify: cd frontend && npm run build && test -f dist/index.html && npx tsc --noEmit
|
||||
- [ ] **T03: Build review queue UI pages with status filters, moment actions, and mode toggle** — Implement the full admin review queue UI: the queue list page with status filter tabs and stats summary, the moment detail/review page with approve/reject/edit/split/merge actions, and the review mode toggle. Wire all pages to the API client from T02.
|
||||
- [x] **T03: Built complete admin review queue UI: queue list page with stats bar, status filter tabs, pagination, mode toggle; moment detail page with approve/reject/edit/split/merge actions and modal dialogs; reusable StatusBadge and ModeToggle components** — Implement the full admin review queue UI: the queue list page with status filter tabs and stats summary, the moment detail/review page with approve/reject/edit/split/merge actions, and the review mode toggle. Wire all pages to the API client from T02.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
36
.gsd/milestones/M001/slices/S04/tasks/T02-VERIFY.json
Normal file
36
.gsd/milestones/M001/slices/S04/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M001/S04/T02",
|
||||
"timestamp": 1774826513381,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd frontend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 3,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "npm run build",
|
||||
"exitCode": 254,
|
||||
"durationMs": 85,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "test -f dist/index.html",
|
||||
"exitCode": 1,
|
||||
"durationMs": 6,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "npx tsc --noEmit",
|
||||
"exitCode": 1,
|
||||
"durationMs": 771,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
91
.gsd/milestones/M001/slices/S04/tasks/T03-SUMMARY.md
Normal file
91
.gsd/milestones/M001/slices/S04/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
id: T03
|
||||
parent: S04
|
||||
milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/pages/ReviewQueue.tsx", "frontend/src/pages/MomentDetail.tsx", "frontend/src/components/StatusBadge.tsx", "frontend/src/components/ModeToggle.tsx", "frontend/src/App.tsx", "frontend/src/App.css"]
|
||||
key_decisions: ["MomentDetail fetches full queue to find moment by ID since backend has no single-moment GET endpoint", "Split dialog validates timestamp client-side before API call"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Frontend build succeeds with zero TypeScript errors. All task-level grep checks pass (StatusBadge/ModeToggle integrated in ReviewQueue, all action names present in MomentDetail). All slice-level backend tests pass (24/24 review tests, 40/40 full suite, 9 routes registered)."
|
||||
completed_at: 2026-03-29T23:28:51.575Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built complete admin review queue UI: queue list page with stats bar, status filter tabs, pagination, mode toggle; moment detail page with approve/reject/edit/split/merge actions and modal dialogs; reusable StatusBadge and ModeToggle components
|
||||
|
||||
> Built complete admin review queue UI: queue list page with stats bar, status filter tabs, pagination, mode toggle; moment detail page with approve/reject/edit/split/merge actions and modal dialogs; reusable StatusBadge and ModeToggle components
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S04
|
||||
milestone: M001
|
||||
key_files:
|
||||
- frontend/src/pages/ReviewQueue.tsx
|
||||
- frontend/src/pages/MomentDetail.tsx
|
||||
- frontend/src/components/StatusBadge.tsx
|
||||
- frontend/src/components/ModeToggle.tsx
|
||||
- frontend/src/App.tsx
|
||||
- frontend/src/App.css
|
||||
key_decisions:
|
||||
- MomentDetail fetches full queue to find moment by ID since backend has no single-moment GET endpoint
|
||||
- Split dialog validates timestamp client-side before API call
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-29T23:28:51.576Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built complete admin review queue UI: queue list page with stats bar, status filter tabs, pagination, mode toggle; moment detail page with approve/reject/edit/split/merge actions and modal dialogs; reusable StatusBadge and ModeToggle components
|
||||
|
||||
**Built complete admin review queue UI: queue list page with stats bar, status filter tabs, pagination, mode toggle; moment detail page with approve/reject/edit/split/merge actions and modal dialogs; reusable StatusBadge and ModeToggle components**
|
||||
|
||||
## What Happened
|
||||
|
||||
Replaced the placeholder ReviewQueue page with a full-featured admin queue page: stats bar showing counts per status, 5 filter tabs that re-fetch data on click, paginated moment list with Previous/Next navigation, queue cards with title/summary/creator/video/time/status badge, and empty state. Added ModeToggle to queue page header and global app header with green/amber dot indicator. Built MomentDetail page with complete moment data display, action buttons (Approve/Reject navigate back, Edit toggles inline editing, Split opens modal with timestamp validation, Merge opens modal with same-video moment dropdown), loading/error states. Created reusable StatusBadge and ModeToggle components. Updated App.tsx and wrote comprehensive CSS covering all UI elements with responsive layout.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend build succeeds with zero TypeScript errors. All task-level grep checks pass (StatusBadge/ModeToggle integrated in ReviewQueue, all action names present in MomentDetail). All slice-level backend tests pass (24/24 review tests, 40/40 full suite, 9 routes registered).
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build && test -f dist/index.html` | 0 | ✅ pass | 2700ms |
|
||||
| 2 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 1000ms |
|
||||
| 3 | `grep -q 'StatusBadge|ModeToggle' frontend/src/pages/ReviewQueue.tsx` | 0 | ✅ pass | 10ms |
|
||||
| 4 | `grep -q 'approve|reject|split|merge' frontend/src/pages/MomentDetail.tsx` | 0 | ✅ pass | 10ms |
|
||||
| 5 | `cd backend && python -m pytest tests/test_review.py -v` | 0 | ✅ pass | 11120ms |
|
||||
| 6 | `cd backend && python -m pytest tests/ -v` | 0 | ✅ pass | 133170ms |
|
||||
| 7 | `cd backend && python -c "from routers.review import router; print(len(router.routes))"` | 0 | ✅ pass | 3200ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
MomentDetail uses fetchQueue with limit=500 to find individual moment since there's no dedicated single-moment GET endpoint. Works for single-admin tool scope.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/pages/ReviewQueue.tsx`
|
||||
- `frontend/src/pages/MomentDetail.tsx`
|
||||
- `frontend/src/components/StatusBadge.tsx`
|
||||
- `frontend/src/components/ModeToggle.tsx`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/App.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
MomentDetail uses fetchQueue with limit=500 to find individual moment since there's no dedicated single-moment GET endpoint. Works for single-admin tool scope.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -33,6 +33,12 @@ body {
|
|||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
|
|
@ -49,6 +55,90 @@ body {
|
|||
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: #fff;
|
||||
border: 1px solid #e2e2e8;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.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: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stats-card--pending .stats-card__count { color: #92400e; }
|
||||
.stats-card--approved .stats-card__count { color: #065f46; }
|
||||
.stats-card--edited .stats-card__count { color: #1e40af; }
|
||||
.stats-card--rejected .stats-card__count { color: #991b1b; }
|
||||
|
||||
/* ── Filter tabs ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #e2e2e8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.filter-tab--active {
|
||||
color: #1a1a2e;
|
||||
border-bottom-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
|
|
@ -71,6 +161,62 @@ body {
|
|||
color: #555;
|
||||
}
|
||||
|
||||
/* ── Queue cards ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e2e8;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
border-color: #a5b4fc;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.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: #6b7280;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.queue-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.queue-card__separator {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* ── Status badges ────────────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
|
|
@ -108,7 +254,7 @@ body {
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
|
|
@ -116,7 +262,7 @@ body {
|
|||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
|
|
@ -124,6 +270,11 @@ body {
|
|||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--approve {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
|
|
@ -153,15 +304,36 @@ body {
|
|||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mode-toggle__dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mode-toggle__dot--review {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.mode-toggle__dot--auto {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.mode-toggle__label {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-toggle__switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background: #d1d5db;
|
||||
background: #6b7280;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle__switch--active {
|
||||
|
|
@ -184,6 +356,206 @@ body {
|
|||
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: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Detail page ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.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: #9ca3af;
|
||||
}
|
||||
|
||||
.detail-field span,
|
||||
.detail-field p {
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.detail-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-transcript {
|
||||
background: #f9fafb;
|
||||
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: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #991b1b;
|
||||
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: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.edit-field input,
|
||||
.edit-field textarea,
|
||||
.edit-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.edit-field input:focus,
|
||||
.edit-field textarea:focus,
|
||||
.edit-field select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Dialogs (modal overlays) ─────────────────────────────────────────────── */
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 28rem;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog__hint {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dialog__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Loading / empty states ───────────────────────────────────────────────── */
|
||||
|
||||
.loading {
|
||||
|
|
@ -192,3 +564,57 @@ body {
|
|||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import ReviewQueue from "./pages/ReviewQueue";
|
||||
import MomentDetail from "./pages/MomentDetail";
|
||||
import ModeToggle from "./components/ModeToggle";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Chrysopedia Admin</h1>
|
||||
<nav>
|
||||
<a href="/admin/review">Review Queue</a>
|
||||
</nav>
|
||||
<div className="app-header__right">
|
||||
<ModeToggle />
|
||||
<nav>
|
||||
<a href="/admin/review">Review Queue</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,458 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
/**
|
||||
* 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 {
|
||||
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 res = await fetchQueue({ limit: 500 });
|
||||
const found = res.items.find((m) => m.id === momentId);
|
||||
if (!found) {
|
||||
setError("Moment not found");
|
||||
} else {
|
||||
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: 500 });
|
||||
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>
|
||||
<Link to="/admin/review" style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
||||
<div className="detail-page">
|
||||
<Link to="/admin/review" className="back-link">
|
||||
← Back to queue
|
||||
</Link>
|
||||
<h2 style={{ marginTop: "0.5rem" }}>Moment Detail</h2>
|
||||
<div className="card">
|
||||
<p>Moment ID: <code>{momentId}</code></p>
|
||||
|
||||
{/* ── 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { useEffect, useState } from "react";
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -6,90 +12,177 @@ import {
|
|||
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);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [queueRes, statsRes] = await Promise.all([
|
||||
fetchQueue({ status: "pending" }),
|
||||
fetchStats(),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setItems(queueRes.items);
|
||||
setStats(statsRes);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load queue");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="loading">Loading…</div>;
|
||||
if (error) return <div className="loading">Error: {error}</div>;
|
||||
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>
|
||||
<h2>Review Queue</h2>
|
||||
{/* ── Header row with title and mode toggle ── */}
|
||||
<div className="queue-header">
|
||||
<h2>Review Queue</h2>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
{/* ── Stats bar ── */}
|
||||
{stats && (
|
||||
<div className="card" style={{ display: "flex", gap: "1.5rem" }}>
|
||||
<span>
|
||||
<span className="badge badge--pending">Pending</span> {stats.pending}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--approved">Approved</span>{" "}
|
||||
{stats.approved}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--edited">Edited</span> {stats.edited}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--rejected">Rejected</span>{" "}
|
||||
{stats.rejected}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="loading">No pending moments to review.</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/admin/review/${item.id}`}
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
{/* ── 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)}
|
||||
>
|
||||
<div className="card">
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
{item.creator_name} · {item.video_filename} ·{" "}
|
||||
{item.start_time.toFixed(1)}s – {item.end_time.toFixed(1)}s
|
||||
</p>
|
||||
<p style={{ marginTop: "0.25rem" }}>
|
||||
<span
|
||||
className={`badge badge--${item.review_status}`}
|
||||
>
|
||||
{item.review_status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx"],"version":"5.6.3"}
|
||||
Loading…
Add table
Reference in a new issue