diff --git a/.gsd/milestones/M001/slices/S04/S04-PLAN.md b/.gsd/milestones/M001/slices/S04/S04-PLAN.md
index 047a174..bf38e9c 100644
--- a/.gsd/milestones/M001/slices/S04/S04-PLAN.md
+++ b/.gsd/milestones/M001/slices/S04/S04-PLAN.md
@@ -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
diff --git a/.gsd/milestones/M001/slices/S04/tasks/T02-VERIFY.json b/.gsd/milestones/M001/slices/S04/tasks/T02-VERIFY.json
new file mode 100644
index 0000000..84b00e1
--- /dev/null
+++ b/.gsd/milestones/M001/slices/S04/tasks/T02-VERIFY.json
@@ -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
+}
diff --git a/.gsd/milestones/M001/slices/S04/tasks/T03-SUMMARY.md b/.gsd/milestones/M001/slices/S04/tasks/T03-SUMMARY.md
new file mode 100644
index 0000000..91ab247
--- /dev/null
+++ b/.gsd/milestones/M001/slices/S04/tasks/T03-SUMMARY.md
@@ -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.
diff --git a/frontend/src/App.css b/frontend/src/App.css
index c90372a..bdab000 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -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;
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a121bc5..2979145 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
diff --git a/frontend/src/components/ModeToggle.tsx b/frontend/src/components/ModeToggle.tsx
new file mode 100644
index 0000000..71496a3
--- /dev/null
+++ b/frontend/src/components/ModeToggle.tsx
@@ -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(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 (
+
+
+
+ {reviewMode ? "Review Mode" : "Auto Mode"}
+
+
+
+ );
+}
diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..8f71e76
--- /dev/null
+++ b/frontend/src/components/StatusBadge.tsx
@@ -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 (
+
+ {normalized}
+
+ );
+}
diff --git a/frontend/src/pages/MomentDetail.tsx b/frontend/src/pages/MomentDetail.tsx
index c2cf48d..dbfc600 100644
--- a/frontend/src/pages/MomentDetail.tsx
+++ b/frontend/src/pages/MomentDetail.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [actionError, setActionError] = useState(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([]);
+ 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 Loading…
;
+ if (error)
+ return (
+
+
+ ← Back to queue
+
+
Error: {error}
+
+ );
+ if (!moment) return null;
return (
-
-
+
+
← Back to queue
-
Moment Detail
-
-
Moment ID: {momentId}
+
+ {/* ── Moment header ── */}
+
+
{moment.title}
+
+
+ {/* ── Moment data ── */}
+
+
+ Content Type
+ {moment.content_type}
+
+
+ Time Range
+
+ {formatTime(moment.start_time)} – {formatTime(moment.end_time)}
+
+
+
+ Source
+
+ {moment.creator_name} · {moment.video_filename}
+
+
+ {moment.plugins && moment.plugins.length > 0 && (
+
+ Plugins
+ {moment.plugins.join(", ")}
+
+ )}
+
+
Summary
+
{moment.summary}
+
+ {moment.raw_transcript && (
+
+
Raw Transcript
+
{moment.raw_transcript}
+
+ )}
+
+
+ {/* ── Action error ── */}
+ {actionError &&
{actionError}
}
+
+ {/* ── Edit mode ── */}
+ {editing ? (
+
+
Edit Moment
+
+ Title
+ setEditTitle(e.target.value)}
+ />
+
+
+ Summary
+
+
+ Content Type
+ setEditContentType(e.target.value)}
+ />
+
+
+
+ Save
+
+ setEditing(false)}
+ disabled={acting}
+ >
+ Cancel
+
+
+
+ ) : (
+ /* ── Action buttons ── */
+
+
+ ✓ Approve
+
+
+ ✕ Reject
+
+
+ ✎ Edit
+
+
+ ✂ Split
+
+
+ ⊕ Merge
+
+
+ )}
+
+ {/* ── Split dialog ── */}
+ {showSplit && (
+
setShowSplit(false)}>
+
e.stopPropagation()}>
+
Split Moment
+
+ Enter a timestamp (in seconds) between{" "}
+ {formatTime(moment.start_time)} and {formatTime(moment.end_time)}.
+
+
+ Split Time (seconds)
+ setSplitTime(e.target.value)}
+ placeholder={`e.g. ${((moment.start_time + moment.end_time) / 2).toFixed(1)}`}
+ />
+
+
+
+ Split
+
+ setShowSplit(false)}
+ >
+ Cancel
+
+
+
+
+ )}
+
+ {/* ── Merge dialog ── */}
+ {showMerge && (
+
setShowMerge(false)}>
+
e.stopPropagation()}>
+
Merge Moment
+
+ Select another moment from the same video to merge with.
+
+ {mergeCandidates.length === 0 ? (
+
+ No other moments from this video available.
+
+ ) : (
+
+ Target Moment
+ setMergeTargetId(e.target.value)}
+ >
+ Select a moment…
+ {mergeCandidates.map((c) => (
+
+ {c.title} ({formatTime(c.start_time)} –{" "}
+ {formatTime(c.end_time)})
+
+ ))}
+
+
+ )}
+
+
+ Merge
+
+ setShowMerge(false)}
+ >
+ Cancel
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx
index c6a48c9..888adea 100644
--- a/frontend/src/pages/ReviewQueue.tsx
+++ b/frontend/src/pages/ReviewQueue.tsx
@@ -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
([]);
const [stats, setStats] = useState(null);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [filter, setFilter] = useState("pending");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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 Loading…
;
- if (error) return Error: {error}
;
+ 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 (
-
Review Queue
+ {/* ── Header row with title and mode toggle ── */}
+
+
Review Queue
+
+
+ {/* ── Stats bar ── */}
{stats && (
-
-
- Pending {stats.pending}
-
-
- Approved {" "}
- {stats.approved}
-
-
- Edited {stats.edited}
-
-
- Rejected {" "}
- {stats.rejected}
-
+
+
+ {stats.pending}
+ Pending
+
+
+ {stats.approved}
+ Approved
+
+
+ {stats.edited}
+ Edited
+
+
+ {stats.rejected}
+ Rejected
+
)}
- {items.length === 0 ? (
-
No pending moments to review.
- ) : (
- items.map((item) => (
-
+ {FILTERS.map((f) => (
+
handleFilterChange(f.value)}
>
-
-
{item.title}
-
- {item.creator_name} · {item.video_filename} ·{" "}
- {item.start_time.toFixed(1)}s – {item.end_time.toFixed(1)}s
-
-
-
- {item.review_status}
-
-
-
-
- ))
+ {f.label}
+
+ ))}
+
+
+ {/* ── Queue list ── */}
+ {loading ? (
+
Loading…
+ ) : error ? (
+
Error: {error}
+ ) : items.length === 0 ? (
+
+
No moments match the "{filter}" filter.
+
+ ) : (
+ <>
+
+ {items.map((item) => (
+
+
+ {item.title}
+
+
+
+ {item.summary.length > 150
+ ? `${item.summary.slice(0, 150)}…`
+ : item.summary}
+
+
+ {item.creator_name}
+ ·
+ {item.video_filename}
+ ·
+
+ {formatTime(item.start_time)} – {formatTime(item.end_time)}
+
+
+
+ ))}
+
+
+ {/* ── Pagination ── */}
+
+ setOffset(Math.max(0, offset - PAGE_SIZE))}
+ >
+ ← Previous
+
+
+ {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
+
+ setOffset(offset + PAGE_SIZE)}
+ >
+ Next →
+
+
+ >
)}
);
diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo
index cabd243..5d0c952 100644
--- a/frontend/tsconfig.app.tsbuildinfo
+++ b/frontend/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx"],"version":"5.6.3"}
\ No newline at end of file