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:
jlightner 2026-03-29 23:21:53 +00:00
parent b43e4a079a
commit 5fcbb95c58
14 changed files with 2491 additions and 2 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,20 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "echo 'placeholder — install a framework first'", "dev": "vite",
"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" "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
View 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
View 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
View 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
View 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>,
);

View 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>
);
}

View 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} &middot; {item.video_filename} &middot;{" "}
{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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View 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
View file

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

14
frontend/vite.config.ts Normal file
View 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,
},
},
},
});