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 (

Chrysopedia Admin

- +
+ + +
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 ── */} +
+
+ + {moment.content_type} +
+
+ + + {formatTime(moment.start_time)} – {formatTime(moment.end_time)} + +
+
+ + + {moment.creator_name} · {moment.video_filename} + +
+ {moment.plugins && moment.plugins.length > 0 && ( +
+ + {moment.plugins.join(", ")} +
+ )} +
+ +

{moment.summary}

+
+ {moment.raw_transcript && ( +
+ +

{moment.raw_transcript}

+
+ )} +
+ + {/* ── Action error ── */} + {actionError &&
{actionError}
} + + {/* ── Edit mode ── */} + {editing ? ( +
+

Edit Moment

+
+ + setEditTitle(e.target.value)} + /> +
+
+ +