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:
jlightner 2026-03-29 23:29:01 +00:00
parent 5542ae455f
commit 2cb0f9c381
10 changed files with 1249 additions and 80 deletions

View file

@ -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

View 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
}

View 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.

View file

@ -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;
}
}

View file

@ -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">

View file

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

View file

@ -0,0 +1,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>
);
}

View file

@ -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>
);
}

View file

@ -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} &middot; {item.video_filename} &middot;{" "}
{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>
);

View file

@ -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"}