feat: Bootstrapped React + Vite + TypeScript frontend with typed API cl…
- "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" GSD-Task: S04/T02
This commit is contained in:
parent
c2edba952c
commit
5542ae455f
17 changed files with 2624 additions and 3 deletions
|
|
@ -78,7 +78,7 @@
|
|||
- Estimate: 2h
|
||||
- Files: backend/schemas.py, backend/routers/review.py, backend/redis_client.py, backend/main.py, backend/requirements.txt, backend/tests/test_review.py
|
||||
- Verify: cd backend && python -m pytest tests/test_review.py -v && python -m pytest tests/ -v
|
||||
- [ ] **T02: Bootstrap React + Vite + TypeScript frontend with API client** — Replace the placeholder frontend with a real React + Vite + TypeScript application. Install dependencies, configure Vite with API proxy for development, create the app shell with React Router, and build a typed API client module for the review endpoints. Verify `npm run build` produces `dist/index.html` compatible with the existing Docker build pipeline.
|
||||
- [x] **T02: Bootstrapped React + Vite + TypeScript frontend with typed API client for all 9 review endpoints, React Router shell, and admin CSS foundation** — Replace the placeholder frontend with a real React + Vite + TypeScript application. Install dependencies, configure Vite with API proxy for development, create the app shell with React Router, and build a typed API client module for the review endpoints. Verify `npm run build` produces `dist/index.html` compatible with the existing Docker build pipeline.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
30
.gsd/milestones/M001/slices/S04/tasks/T01-VERIFY.json
Normal file
30
.gsd/milestones/M001/slices/S04/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M001/S04/T01",
|
||||
"timestamp": 1774826023449,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "python -m pytest tests/test_review.py -v",
|
||||
"exitCode": 4,
|
||||
"durationMs": 238,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "python -m pytest tests/ -v",
|
||||
"exitCode": 5,
|
||||
"durationMs": 233,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
102
.gsd/milestones/M001/slices/S04/tasks/T02-SUMMARY.md
Normal file
102
.gsd/milestones/M001/slices/S04/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_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", "frontend/src/vite-env.d.ts"]
|
||||
key_decisions: ["API client uses bare fetch() with a shared request() helper — no external HTTP library needed", "ReviewQueue page fetches data on mount with useEffect + Promise.all for queue and stats in parallel"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "npm run build succeeds producing dist/index.html. npx tsc --noEmit passes with zero TypeScript errors. API client exports all key functions verified by grep. All 3 slice-level checks pass: test_review.py 24/24, full suite 40/40, route count = 9."
|
||||
completed_at: 2026-03-29T23:21:39.477Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Bootstrapped React + Vite + TypeScript frontend with typed API client for all 9 review endpoints, React Router shell, and admin CSS foundation
|
||||
|
||||
> Bootstrapped React + Vite + TypeScript frontend with typed API client for all 9 review endpoints, React Router shell, and admin CSS foundation
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M001
|
||||
key_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
|
||||
- frontend/src/vite-env.d.ts
|
||||
key_decisions:
|
||||
- API client uses bare fetch() with a shared request() helper — no external HTTP library needed
|
||||
- ReviewQueue page fetches data on mount with useEffect + Promise.all for queue and stats in parallel
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-29T23:21:39.478Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Bootstrapped React + Vite + TypeScript frontend with typed API client for all 9 review endpoints, React Router shell, and admin CSS foundation
|
||||
|
||||
**Bootstrapped React + Vite + TypeScript frontend with typed API client for all 9 review endpoints, React Router shell, and admin CSS foundation**
|
||||
|
||||
## What Happened
|
||||
|
||||
Replaced the placeholder frontend with a full React 18 + Vite 6 + TypeScript 5.6 application. Created package.json with react, react-dom, react-router-dom dependencies plus Vite/TypeScript tooling. Added vite.config.ts with React plugin and /api dev proxy to localhost:8001. Set up strict TypeScript config targeting ES2020 with bundler module resolution. Built the app shell with BrowserRouter routes (/admin/review → ReviewQueue, /admin/review/:momentId → MomentDetail, * → redirect). Created src/api/client.ts — a fully typed API client with TypeScript interfaces matching all backend Pydantic schemas and 9 exported functions (fetchQueue, fetchStats, approveMoment, rejectMoment, editMoment, splitMoment, mergeMoments, getReviewMode, setReviewMode). ReviewQueue page fetches real data on mount with loading/error states.
|
||||
|
||||
## Verification
|
||||
|
||||
npm run build succeeds producing dist/index.html. npx tsc --noEmit passes with zero TypeScript errors. API client exports all key functions verified by grep. All 3 slice-level checks pass: test_review.py 24/24, full suite 40/40, route count = 9.
|
||||
|
||||
## 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 'fetchQueue|approveMoment|getReviewMode' frontend/src/api/client.ts` | 0 | ✅ pass | 10ms |
|
||||
| 4 | `cd backend && python -m pytest tests/test_review.py -v` | 0 | ✅ pass | 11220ms |
|
||||
| 5 | `cd backend && python -m pytest tests/ -v` | 0 | ✅ pass | 133380ms |
|
||||
| 6 | `cd backend && python -c 'from routers.review import router; print(len(router.routes))'` | 0 | ✅ pass | 500ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Added src/vite-env.d.ts for Vite type declarations (required for TypeScript). ReviewQueue page fetches real data with loading/error states instead of bare placeholder text.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `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`
|
||||
- `frontend/src/vite-env.d.ts`
|
||||
|
||||
|
||||
## Deviations
|
||||
Added src/vite-env.d.ts for Vite type declarations (required for TypeScript). ReviewQueue page fetches real data with loading/error states instead of bare placeholder text.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chrysopedia Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1888
frontend/package-lock.json
generated
Normal file
1888
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,20 @@
|
|||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "echo 'placeholder — install a framework first'",
|
||||
"build": "echo 'placeholder build' && mkdir -p dist && echo '<!DOCTYPE html><html><head><title>Chrysopedia</title></head><body><h1>Chrysopedia</h1><p>Web UI placeholder</p></body></html>' > dist/index.html"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
frontend/src/App.css
Normal file
194
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #1a1a2e;
|
||||
background: #f4f4f8;
|
||||
}
|
||||
|
||||
/* ── App shell ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-header nav a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 72rem;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e2e8;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ── Status badges ────────────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge--approved {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge--edited {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn--approve {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.btn--approve:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.btn--reject {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn--reject:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Mode toggle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mode-toggle__switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background: #d1d5db;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.mode-toggle__switch--active {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.mode-toggle__switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.mode-toggle__switch--active::after {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
/* ── Loading / empty states ───────────────────────────────────────────────── */
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import ReviewQueue from "./pages/ReviewQueue";
|
||||
import MomentDetail from "./pages/MomentDetail";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Chrysopedia Admin</h1>
|
||||
<nav>
|
||||
<a href="/admin/review">Review Queue</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/admin/review" element={<ReviewQueue />} />
|
||||
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
||||
<Route path="*" element={<Navigate to="/admin/review" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
frontend/src/api/client.ts
Normal file
187
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Typed API client for Chrysopedia review queue endpoints.
|
||||
*
|
||||
* All functions use fetch() with JSON handling and throw on non-OK responses.
|
||||
* Base URL is empty so requests go through the Vite dev proxy or nginx in prod.
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KeyMomentRead {
|
||||
id: string;
|
||||
source_video_id: string;
|
||||
technique_page_id: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
content_type: string;
|
||||
plugins: string[] | null;
|
||||
raw_transcript: string | null;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueItem extends KeyMomentRead {
|
||||
video_filename: string;
|
||||
creator_name: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueResponse {
|
||||
items: ReviewQueueItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReviewStatsResponse {
|
||||
pending: number;
|
||||
approved: number;
|
||||
edited: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface ReviewModeResponse {
|
||||
review_mode: boolean;
|
||||
}
|
||||
|
||||
export interface MomentEditRequest {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
content_type?: string;
|
||||
plugins?: string[];
|
||||
}
|
||||
|
||||
export interface MomentSplitRequest {
|
||||
split_time: number;
|
||||
}
|
||||
|
||||
export interface MomentMergeRequest {
|
||||
target_moment_id: string;
|
||||
}
|
||||
|
||||
export interface QueueParams {
|
||||
status?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = "/api/v1/review";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: string,
|
||||
) {
|
||||
super(`API ${status}: ${detail}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail ?? detail;
|
||||
} catch {
|
||||
// body not JSON — keep statusText
|
||||
}
|
||||
throw new ApiError(res.status, detail);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Queue ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchQueue(
|
||||
params: QueueParams = {},
|
||||
): Promise<ReviewQueueResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
const query = qs.toString();
|
||||
return request<ReviewQueueResponse>(
|
||||
`${BASE}/queue${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<ReviewStatsResponse> {
|
||||
return request<ReviewStatsResponse>(`${BASE}/stats`);
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function approveMoment(id: string): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectMoment(id: string): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/reject`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMoment(
|
||||
id: string,
|
||||
data: MomentEditRequest,
|
||||
): Promise<KeyMomentRead> {
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function splitMoment(
|
||||
id: string,
|
||||
splitTime: number,
|
||||
): Promise<KeyMomentRead[]> {
|
||||
const body: MomentSplitRequest = { split_time: splitTime };
|
||||
return request<KeyMomentRead[]>(`${BASE}/moments/${id}/split`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function mergeMoments(
|
||||
id: string,
|
||||
targetId: string,
|
||||
): Promise<KeyMomentRead> {
|
||||
const body: MomentMergeRequest = { target_moment_id: targetId };
|
||||
return request<KeyMomentRead>(`${BASE}/moments/${id}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mode ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getReviewMode(): Promise<ReviewModeResponse> {
|
||||
return request<ReviewModeResponse>(`${BASE}/mode`);
|
||||
}
|
||||
|
||||
export async function setReviewMode(
|
||||
enabled: boolean,
|
||||
): Promise<ReviewModeResponse> {
|
||||
return request<ReviewModeResponse>(`${BASE}/mode`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ review_mode: enabled }),
|
||||
});
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./App.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
17
frontend/src/pages/MomentDetail.tsx
Normal file
17
frontend/src/pages/MomentDetail.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
|
||||
export default function MomentDetail() {
|
||||
const { momentId } = useParams<{ momentId: string }>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="/admin/review" style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
||||
← Back to queue
|
||||
</Link>
|
||||
<h2 style={{ marginTop: "0.5rem" }}>Moment Detail</h2>
|
||||
<div className="card">
|
||||
<p>Moment ID: <code>{momentId}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/ReviewQueue.tsx
Normal file
96
frontend/src/pages/ReviewQueue.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchQueue,
|
||||
fetchStats,
|
||||
type ReviewQueueItem,
|
||||
type ReviewStatsResponse,
|
||||
} from "../api/client";
|
||||
|
||||
export default function ReviewQueue() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [stats, setStats] = useState<ReviewStatsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [queueRes, statsRes] = await Promise.all([
|
||||
fetchQueue({ status: "pending" }),
|
||||
fetchStats(),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setItems(queueRes.items);
|
||||
setStats(statsRes);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load queue");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="loading">Loading…</div>;
|
||||
if (error) return <div className="loading">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Review Queue</h2>
|
||||
|
||||
{stats && (
|
||||
<div className="card" style={{ display: "flex", gap: "1.5rem" }}>
|
||||
<span>
|
||||
<span className="badge badge--pending">Pending</span> {stats.pending}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--approved">Approved</span>{" "}
|
||||
{stats.approved}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--edited">Edited</span> {stats.edited}
|
||||
</span>
|
||||
<span>
|
||||
<span className="badge badge--rejected">Rejected</span>{" "}
|
||||
{stats.rejected}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="loading">No pending moments to review.</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/admin/review/${item.id}`}
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<div className="card">
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
{item.creator_name} · {item.video_filename} ·{" "}
|
||||
{item.start_time.toFixed(1)}s – {item.end_time.toFixed(1)}s
|
||||
</p>
|
||||
<p style={{ marginTop: "0.25rem" }}>
|
||||
<span
|
||||
className={`badge badge--${item.review_status}`}
|
||||
>
|
||||
{item.review_status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.app.tsbuildinfo
Normal file
1
frontend/tsconfig.app.tsbuildinfo
Normal file
|
|
@ -0,0 +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"}
|
||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue