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:
parent
c05e4da594
commit
ce08d729cd
8 changed files with 930 additions and 1 deletions
|
|
@ -58,7 +58,7 @@
|
|||
- Estimate: 1h
|
||||
- Files: backend/models.py, alembic/versions/021_add_highlight_trim_columns.py, backend/routers/creator_highlights.py, backend/main.py
|
||||
- Verify: cd /home/aux/projects/content-to-kb-automator && python -c "import sys; sys.path.insert(0,'backend'); from models import HighlightCandidate; assert hasattr(HighlightCandidate,'trim_start'); print('OK')" && grep -q creator_highlights backend/main.py && echo 'Registered'
|
||||
- [ ] **T02: HighlightQueue page, API layer, route wiring, and sidebar link** — Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation.
|
||||
- [x] **T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link** — Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
28
.gsd/milestones/M022/slices/S01/tasks/T01-VERIFY.json
Normal file
28
.gsd/milestones/M022/slices/S01/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M022/S01/T01",
|
||||
"timestamp": 1775285908195,
|
||||
"passed": true,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd /home/aux/projects/content-to-kb-automator",
|
||||
"exitCode": 0,
|
||||
"durationMs": 7,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "grep -q creator_highlights backend/main.py",
|
||||
"exitCode": 0,
|
||||
"durationMs": 6,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "echo 'Registered'",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4,
|
||||
"verdict": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
.gsd/milestones/M022/slices/S01/tasks/T02-SUMMARY.md
Normal file
87
.gsd/milestones/M022/slices/S01/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S01
|
||||
milestone: M022
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["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"]
|
||||
key_decisions: ["Fetch full detail (score_breakdown) lazily when user expands trim panel, not on list load"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "npx tsc --noEmit passed with zero errors. All file existence and content grep checks passed: API layer, page, CSS module exist; HighlightQueue route wired in App.tsx; Highlights link added in CreatorDashboard.tsx SidebarNav."
|
||||
completed_at: 2026-04-04T07:01:49.565Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link
|
||||
|
||||
> Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S01
|
||||
milestone: M022
|
||||
key_files:
|
||||
- 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
|
||||
key_decisions:
|
||||
- Fetch full detail (score_breakdown) lazily when user expands trim panel, not on list load
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T07:01:49.566Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link
|
||||
|
||||
**Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created the complete frontend for the highlight review queue: TypeScript API layer (4 functions matching backend endpoints), CSS module following ChapterReview patterns, HighlightQueue page component with filter tabs (All/Shorts/Approved/Rejected), candidate cards showing key_moment title/duration/score/status, 7-dimension score breakdown bars, approve/discard action buttons, inline trim panel with validated number inputs. Added lazy-loaded route in App.tsx and Highlights star-icon link in SidebarNav.
|
||||
|
||||
## Verification
|
||||
|
||||
npx tsc --noEmit passed with zero errors. All file existence and content grep checks passed: API layer, page, CSS module exist; HighlightQueue route wired in App.tsx; Highlights link added in CreatorDashboard.tsx SidebarNav.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 4200ms |
|
||||
| 2 | `test -f frontend/src/api/highlights.ts` | 0 | ✅ pass | 50ms |
|
||||
| 3 | `test -f frontend/src/pages/HighlightQueue.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 4 | `test -f frontend/src/pages/HighlightQueue.module.css` | 0 | ✅ pass | 50ms |
|
||||
| 5 | `grep -q HighlightQueue frontend/src/App.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 6 | `grep -q Highlights frontend/src/pages/CreatorDashboard.tsx` | 0 | ✅ pass | 50ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Score breakdown fetched lazily via fetchHighlightDetail on expand rather than included in list response — avoids N+1 detail calls on initial load.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `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`
|
||||
|
||||
|
||||
## Deviations
|
||||
Score breakdown fetched lazily via fetchHighlightDetail on expand rather than included in list response — avoids N+1 detail calls on initial load.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -23,6 +23,7 @@ const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
|||
const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
|
||||
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
||||
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
||||
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
||||
import AdminDropdown from "./components/AdminDropdown";
|
||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||
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/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/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
81
frontend/src/api/highlights.ts
Normal file
81
frontend/src/api/highlights.ts
Normal 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 }) },
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ function SidebarNav() {
|
|||
</svg>
|
||||
Chapters
|
||||
</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}>
|
||||
<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" />
|
||||
|
|
|
|||
388
frontend/src/pages/HighlightQueue.module.css
Normal file
388
frontend/src/pages/HighlightQueue.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
337
frontend/src/pages/HighlightQueue.tsx
Normal file
337
frontend/src/pages/HighlightQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue