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