feat: Built HighlightQueue page with filter tabs, candidate cards with…

- "frontend/src/api/highlights.ts"
- "frontend/src/pages/HighlightQueue.tsx"
- "frontend/src/pages/HighlightQueue.module.css"
- "frontend/src/App.tsx"
- "frontend/src/pages/CreatorDashboard.tsx"

GSD-Task: S01/T02
This commit is contained in:
jlightner 2026-04-04 07:01:57 +00:00
parent 87f09f0192
commit 8e3ba00aab
5 changed files with 814 additions and 0 deletions

View file

@ -23,6 +23,7 @@ const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog")); const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
const ChatPage = React.lazy(() => import("./pages/ChatPage")); const ChatPage = React.lazy(() => import("./pages/ChatPage"));
const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
import AdminDropdown from "./components/AdminDropdown"; import AdminDropdown from "./components/AdminDropdown";
import ImpersonationBanner from "./components/ImpersonationBanner"; import ImpersonationBanner from "./components/ImpersonationBanner";
import AppFooter from "./components/AppFooter"; import AppFooter from "./components/AppFooter";
@ -203,6 +204,7 @@ function AppShell() {
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} /> <Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} /> <Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} /> <Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
{/* Fallback */} {/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -0,0 +1,81 @@
import { request, BASE } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ScoreBreakdown {
duration_score: number;
content_density_score: number;
technique_relevance_score: number;
position_score: number;
uniqueness_score: number;
engagement_proxy_score: number;
plugin_diversity_score: number;
}
export interface KeyMomentInfo {
id: string;
title: string;
start_time: number;
end_time: number;
}
export interface HighlightCandidate {
id: string;
key_moment_id: string;
source_video_id: string;
score: number;
score_breakdown?: ScoreBreakdown | null;
duration_secs: number;
status: string;
trim_start: number | null;
trim_end: number | null;
key_moment: KeyMomentInfo | null;
}
export interface HighlightListResponse {
highlights: HighlightCandidate[];
}
// ── API functions ────────────────────────────────────────────────────────────
export function fetchCreatorHighlights(params?: {
status?: string;
shorts_only?: boolean;
}): Promise<HighlightListResponse> {
const query = new URLSearchParams();
if (params?.status) query.set("status", params.status);
if (params?.shorts_only) query.set("shorts_only", "true");
const qs = query.toString();
return request<HighlightListResponse>(
`${BASE}/creator/highlights${qs ? `?${qs}` : ""}`,
);
}
export function fetchHighlightDetail(
id: string,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}`,
);
}
export function updateHighlightStatus(
id: string,
status: string,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}`,
{ method: "PATCH", body: JSON.stringify({ status }) },
);
}
export function trimHighlight(
id: string,
trim_start: number,
trim_end: number,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}/trim`,
{ method: "PATCH", body: JSON.stringify({ trim_start, trim_end }) },
);
}

View file

@ -31,6 +31,12 @@ function SidebarNav() {
</svg> </svg>
Chapters Chapters
</NavLink> </NavLink>
<NavLink to="/creator/highlights" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Highlights
</NavLink>
<NavLink to="/creator/consent" className={linkClass}> <NavLink to="/creator/consent" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />

View file

@ -0,0 +1,388 @@
/* ── Layout — reuses creator sidebar pattern ──────────────────────────────── */
.layout {
display: flex;
gap: 0;
min-height: 60vh;
}
.content {
flex: 1;
min-width: 0;
padding: 2rem;
}
.pageTitle {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
}
.subtitle {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
/* ── Filter tabs ──────────────────────────────────────────────────────────── */
.filterTabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.filterTab {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
margin-bottom: -1px;
}
.filterTab:hover {
color: var(--color-text-secondary);
}
.filterTabActive {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* ── Candidate cards ──────────────────────────────────────────────────────── */
.candidateList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.candidateCard {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color 0.15s;
}
.candidateCard:hover {
border-color: var(--color-accent);
}
.candidateHeader {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.candidateTitle {
flex: 1;
min-width: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.candidateDuration {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
white-space: nowrap;
}
/* ── Badges ────────────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
flex-shrink: 0;
}
.badgeCandidate {
background: var(--color-badge-pending-bg);
color: var(--color-badge-pending-text);
}
.badgeApproved {
background: var(--color-badge-approved-bg);
color: var(--color-badge-approved-text);
}
.badgeRejected {
background: var(--color-badge-rejected-bg);
color: var(--color-badge-rejected-text);
}
.shortsBadge {
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
color: var(--color-accent, #00ffd1);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
/* ── Composite score bar ──────────────────────────────────────────────────── */
.compositeScore {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.compositeLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
min-width: 3rem;
text-align: right;
}
.scoreBarTrack {
flex: 1;
height: 6px;
background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.06));
border-radius: 3px;
overflow: hidden;
}
.scoreBarFill {
height: 100%;
border-radius: 3px;
background: var(--color-accent);
transition: width 0.3s ease;
}
/* ── Score breakdown ──────────────────────────────────────────────────────── */
.breakdownSection {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.breakdownRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.breakdownLabel {
font-size: 0.6875rem;
color: var(--color-text-muted);
min-width: 9rem;
text-align: right;
}
.breakdownBar {
flex: 1;
height: 4px;
background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.06));
border-radius: 2px;
overflow: hidden;
}
.breakdownBarFill {
height: 100%;
border-radius: 2px;
background: var(--color-accent);
opacity: 0.7;
transition: width 0.3s ease;
}
.breakdownValue {
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
min-width: 2.5rem;
}
/* ── Action buttons ───────────────────────────────────────────────────────── */
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.actionBtn {
padding: 0.375rem 0.875rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.actionBtn:hover {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.actionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.approveBtn {
background: var(--color-badge-approved-bg, rgba(34, 197, 94, 0.12));
color: var(--color-badge-approved-text, #22c55e);
border-color: transparent;
}
.approveBtn:hover {
background: rgba(34, 197, 94, 0.22);
color: #22c55e;
}
.rejectBtn {
background: var(--color-badge-rejected-bg, rgba(239, 68, 68, 0.12));
color: var(--color-badge-rejected-text, #ef4444);
border-color: transparent;
}
.rejectBtn:hover {
background: rgba(239, 68, 68, 0.22);
color: #ef4444;
}
.trimBtn {
composes: actionBtn;
}
/* ── Trim panel ───────────────────────────────────────────────────────────── */
.trimPanel {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border);
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.trimField {
display: flex;
align-items: center;
gap: 0.375rem;
}
.trimLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
}
.trimInput {
width: 5rem;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.trimInput:focus {
outline: none;
border-color: var(--color-accent);
}
.trimActions {
display: flex;
gap: 0.375rem;
margin-left: auto;
}
/* ── States ────────────────────────────────────────────────────────────────── */
.loadingState {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.emptyState {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.emptyState h2 {
font-size: 1.125rem;
color: var(--color-text-secondary);
margin: 0 0 0.5rem;
}
.emptyState p {
font-size: 0.875rem;
margin: 0;
}
.errorState {
background: var(--color-error-bg, rgba(220, 38, 38, 0.1));
color: var(--color-error, #ef4444);
padding: 1rem;
border-radius: 8px;
font-size: 0.875rem;
}
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.layout {
flex-direction: column;
}
.content {
padding: 1.25rem;
}
.candidateHeader {
flex-wrap: wrap;
}
.breakdownLabel {
min-width: 6rem;
font-size: 0.625rem;
}
.trimPanel {
flex-direction: column;
align-items: flex-start;
}
.trimActions {
margin-left: 0;
}
}

View file

@ -0,0 +1,337 @@
import { useCallback, useEffect, useState } from "react";
import { SidebarNav } from "./CreatorDashboard";
import { ApiError } from "../api/client";
import {
fetchCreatorHighlights,
fetchHighlightDetail,
updateHighlightStatus,
trimHighlight,
type HighlightCandidate,
type ScoreBreakdown,
} from "../api/highlights";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./HighlightQueue.module.css";
/* ── Helpers ────────────────────────────────────────────────────────────────── */
function formatDuration(secs: number): string {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function statusBadgeClass(status: string): string {
switch (status) {
case "approved":
return styles.badgeApproved;
case "rejected":
return styles.badgeRejected;
default:
return styles.badgeCandidate;
}
}
const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [
{ key: "duration_score", label: "Duration" },
{ key: "content_density_score", label: "Content Density" },
{ key: "technique_relevance_score", label: "Technique" },
{ key: "position_score", label: "Position" },
{ key: "uniqueness_score", label: "Uniqueness" },
{ key: "engagement_proxy_score", label: "Engagement" },
{ key: "plugin_diversity_score", label: "Plugin Diversity" },
];
type FilterTab = "all" | "shorts" | "approved" | "rejected";
/* ── Component ──────────────────────────────────────────────────────────────── */
export default function HighlightQueue() {
useDocumentTitle("Highlight Queue");
const [highlights, setHighlights] = useState<HighlightCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [trimStart, setTrimStart] = useState<string>("");
const [trimEnd, setTrimEnd] = useState<string>("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const loadHighlights = useCallback(async (tab: FilterTab) => {
setLoading(true);
setError(null);
try {
const params: { status?: string; shorts_only?: boolean } = {};
if (tab === "shorts") params.shorts_only = true;
else if (tab === "approved") params.status = "approved";
else if (tab === "rejected") params.status = "rejected";
const res = await fetchCreatorHighlights(params);
setHighlights(res.highlights);
} catch (err) {
setError(err instanceof ApiError ? err.detail : "Failed to load highlights");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadHighlights(activeTab);
}, [activeTab, loadHighlights]);
const handleTabChange = (tab: FilterTab) => {
setActiveTab(tab);
setExpandedId(null);
};
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
await updateHighlightStatus(id, "approved");
await loadHighlights(activeTab);
} catch (err) {
setError(err instanceof ApiError ? err.detail : "Failed to update status");
} finally {
setActionLoading(null);
}
};
const handleReject = async (id: string) => {
setActionLoading(id);
try {
await updateHighlightStatus(id, "rejected");
await loadHighlights(activeTab);
} catch (err) {
setError(err instanceof ApiError ? err.detail : "Failed to update status");
} finally {
setActionLoading(null);
}
};
const handleToggleTrim = async (h: HighlightCandidate) => {
if (expandedId === h.id) {
setExpandedId(null);
return;
}
// Fetch full detail to get score_breakdown
try {
const detail = await fetchHighlightDetail(h.id);
setHighlights((prev) =>
prev.map((x) => (x.id === h.id ? detail : x)),
);
} catch {
// non-critical — proceed with what we have
}
setExpandedId(h.id);
setTrimStart(
h.trim_start != null
? String(h.trim_start)
: h.key_moment
? String(h.key_moment.start_time)
: "0",
);
setTrimEnd(
h.trim_end != null
? String(h.trim_end)
: h.key_moment
? String(h.key_moment.end_time)
: "0",
);
};
const handleSaveTrim = async (id: string) => {
const start = parseFloat(trimStart);
const end = parseFloat(trimEnd);
if (isNaN(start) || isNaN(end) || start >= end || start < 0) {
setError("Invalid trim values — start must be less than end, both ≥ 0");
return;
}
setActionLoading(id);
try {
await trimHighlight(id, start, end);
setExpandedId(null);
await loadHighlights(activeTab);
} catch (err) {
setError(err instanceof ApiError ? err.detail : "Failed to save trim");
} finally {
setActionLoading(null);
}
};
const tabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "All" },
{ key: "shorts", label: "Shorts" },
{ key: "approved", label: "Approved" },
{ key: "rejected", label: "Rejected" },
];
return (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<h1 className={styles.pageTitle}>Highlight Queue</h1>
<p className={styles.subtitle}>Review, trim, and approve auto-detected highlights</p>
{/* Filter tabs */}
<div className={styles.filterTabs}>
{tabs.map((t) => (
<button
key={t.key}
className={`${styles.filterTab}${activeTab === t.key ? ` ${styles.filterTabActive}` : ""}`}
onClick={() => handleTabChange(t.key)}
>
{t.label}
</button>
))}
</div>
{/* Error */}
{error && <div className={styles.errorState}>{error}</div>}
{/* Loading */}
{loading && <div className={styles.loadingState}>Loading highlights</div>}
{/* Empty */}
{!loading && !error && highlights.length === 0 && (
<div className={styles.emptyState}>
<h2>No highlights found</h2>
<p>
{activeTab === "all"
? "No highlight candidates have been detected yet."
: `No ${activeTab === "shorts" ? "short (≤ 60 s)" : activeTab} highlights.`}
</p>
</div>
)}
{/* Candidate list */}
{!loading && highlights.length > 0 && (
<div className={styles.candidateList}>
{highlights.map((h) => (
<div key={h.id} className={styles.candidateCard}>
{/* Header */}
<div className={styles.candidateHeader}>
<span className={styles.candidateTitle}>
{h.key_moment?.title ?? "Untitled highlight"}
</span>
<span className={styles.candidateDuration}>
{formatDuration(h.duration_secs)}
</span>
{h.duration_secs <= 60 && (
<span className={styles.shortsBadge}>Short</span>
)}
<span className={`${styles.badge} ${statusBadgeClass(h.status)}`}>
{h.status}
</span>
</div>
{/* Composite score */}
<div className={styles.compositeScore}>
<span className={styles.compositeLabel}>
{Math.round(h.score * 100)}%
</span>
<div className={styles.scoreBarTrack}>
<div
className={styles.scoreBarFill}
style={{ width: `${Math.round(h.score * 100)}%` }}
/>
</div>
</div>
{/* Score breakdown (shown when expanded) */}
{expandedId === h.id && h.score_breakdown && (
<div className={styles.breakdownSection}>
{BREAKDOWN_LABELS.map(({ key, label }) => {
const val = h.score_breakdown![key];
return (
<div key={key} className={styles.breakdownRow}>
<span className={styles.breakdownLabel}>{label}</span>
<div className={styles.breakdownBar}>
<div
className={styles.breakdownBarFill}
style={{ width: `${Math.round(val * 100)}%` }}
/>
</div>
<span className={styles.breakdownValue}>
{Math.round(val * 100)}%
</span>
</div>
);
})}
</div>
)}
{/* Action buttons */}
<div className={styles.actions}>
<button
className={`${styles.actionBtn} ${styles.approveBtn}`}
disabled={actionLoading === h.id || h.status === "approved"}
onClick={() => handleApprove(h.id)}
>
Approve
</button>
<button
className={`${styles.actionBtn} ${styles.rejectBtn}`}
disabled={actionLoading === h.id || h.status === "rejected"}
onClick={() => handleReject(h.id)}
>
Discard
</button>
<button
className={styles.actionBtn}
disabled={actionLoading === h.id}
onClick={() => handleToggleTrim(h)}
>
{expandedId === h.id ? "Close" : "Trim"}
</button>
</div>
{/* Trim panel */}
{expandedId === h.id && (
<div className={styles.trimPanel}>
<div className={styles.trimField}>
<span className={styles.trimLabel}>Start (s)</span>
<input
type="number"
className={styles.trimInput}
value={trimStart}
onChange={(e) => setTrimStart(e.target.value)}
min={0}
step={0.1}
/>
</div>
<div className={styles.trimField}>
<span className={styles.trimLabel}>End (s)</span>
<input
type="number"
className={styles.trimInput}
value={trimEnd}
onChange={(e) => setTrimEnd(e.target.value)}
min={0}
step={0.1}
/>
</div>
<div className={styles.trimActions}>
<button
className={`${styles.actionBtn} ${styles.approveBtn}`}
disabled={actionLoading === h.id}
onClick={() => handleSaveTrim(h.id)}
>
Save
</button>
<button
className={styles.actionBtn}
onClick={() => setExpandedId(null)}
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}