From 75a3185bd2154c17913a4e5c4297f9e7a163acf5 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:57:40 +0100 Subject: [PATCH 01/14] tier 2 --- README.md | 2 +- SUBMISSION.md | 2 +- apps/web/index.html | 6 + apps/web/package.json | 1 + apps/web/src/App.tsx | 38 +++--- apps/web/src/components/QueryFeedback.tsx | 39 ++++++ apps/web/src/components/ScoreTimeline.tsx | 49 +++++++ apps/web/src/components/SessionPicker.tsx | 37 +++++ apps/web/src/components/ThemeToggle.tsx | 19 +++ apps/web/src/components/ui/skeleton.tsx | 80 +++++++++++ apps/web/src/hooks/useTheme.ts | 28 ++++ apps/web/src/index.css | 10 ++ apps/web/src/lib/api.ts | 16 ++- apps/web/src/routes/History.tsx | 159 ++++++++++++++++------ apps/web/src/routes/Overview.tsx | 128 +++++++++++++---- apps/web/src/routes/SessionDetail.tsx | 32 ++++- docs/HOMEWORK-SPEC.md | 4 +- docs/ROADMAP.md | 40 +++--- docs/checklists/01-homework-rubric.md | 20 +-- docs/checklists/03-known-gaps.md | 30 ++-- docs/checklists/04-enhancements.md | 20 +-- package-lock.json | 28 ++++ tests/e2e/dashboard.spec.ts | 2 +- 23 files changed, 638 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/components/QueryFeedback.tsx create mode 100644 apps/web/src/components/ScoreTimeline.tsx create mode 100644 apps/web/src/components/SessionPicker.tsx create mode 100644 apps/web/src/components/ThemeToggle.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/hooks/useTheme.ts diff --git a/README.md b/README.md index 5602d28..89c362f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ All bodies are Zod-validated (strict); errors return `{ error, issues? }`. Every ## Frontend performance optimisation -Route-level code splitting (lazy routes); **TanStack Query** for caching, request dedupe, and **optimistic** score updates with rollback; **virtualize** the history list when it grows past ~50 rows (see [PERFORMANCE.md](docs/PERFORMANCE.md)); player avatars use **`srcset`** where the CDN supports it; fixed `width`/`height` to avoid layout shift; `loading="lazy"` + `decoding="async"`; video uses `preload="metadata"` only; a bundle-size budget enforced in CI (`npm run size`). +Route-level code splitting (lazy routes); **TanStack Query** for caching, request dedupe, and **optimistic** score updates with rollback; **virtualised** history list (`@tanstack/react-virtual`) with cursor pagination; player avatars use **`srcset`** where the CDN supports it; fixed `width`/`height` to avoid layout shift; `loading="lazy"` + `decoding="async"`; video uses `preload="metadata"` only; a bundle-size budget enforced in CI (`npm run size`). ## Efficient handling of images & video diff --git a/SUBMISSION.md b/SUBMISSION.md index 0d9a330..63c150f 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -18,7 +18,7 @@ Frontend performance · efficient image/video handling · what changes at scale ## Notable extras -RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · full docs hub + ADRs + diagrams. +RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · session picker + end-session lifecycle · virtualised history with cursor pagination · score timeline on detail · light/dark theme · full docs hub + ADRs + diagrams. ## Systematic checklists & roadmap diff --git a/apps/web/index.html b/apps/web/index.html index d2eb7c5..93fbf92 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -10,6 +10,12 @@ rel="stylesheet" /> Oche — Game Session Dashboard +
diff --git a/apps/web/package.json b/apps/web/package.json index 6825133..07a4b35 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@oche/shared": "*", "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-virtual": "^3.14.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^19.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0b01db1..7aecac7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { ThemeToggle } from '@/components/ThemeToggle'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -21,23 +22,26 @@ export default function App() { - +
+ + +
diff --git a/apps/web/src/components/QueryFeedback.tsx b/apps/web/src/components/QueryFeedback.tsx new file mode 100644 index 0000000..ccea0d1 --- /dev/null +++ b/apps/web/src/components/QueryFeedback.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; + +export function QueryError({ + message, + onRetry, + retrying, + action, +}: { + message: string; + onRetry: () => void; + retrying?: boolean; + action?: ReactNode; +}) { + return ( +
+

{message}

+
+ + {action} +
+
+ ); +} + +export function QueryEmpty({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) { + return ( +
+

{title}

+ {hint ?

{hint}

: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/apps/web/src/components/ScoreTimeline.tsx b/apps/web/src/components/ScoreTimeline.tsx new file mode 100644 index 0000000..6984ce7 --- /dev/null +++ b/apps/web/src/components/ScoreTimeline.tsx @@ -0,0 +1,49 @@ +import type { Player, ScoreEvent } from '@oche/shared'; + +function formatWhen(iso: string) { + return new Date(iso).toLocaleString(undefined, { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function ScoreTimeline({ events, players }: { events: ScoreEvent[]; players: Player[] }) { + const names = new Map(players.map((p) => [p.id, p.name])); + if (!events.length) return null; + + return ( +
+

+ Score history +

+
    + {events.map((e) => { + const name = names.get(e.playerId) ?? 'Player'; + const deltaLabel = e.delta > 0 ? `+${e.delta}` : String(e.delta); + return ( +
  1. +
    + + {name}{' '} + = 0 ? 'text-[var(--color-oche)]' : 'text-[var(--color-amber)]'}> + {deltaLabel} + {' '} + {' '} + {e.newScore} + + +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/SessionPicker.tsx b/apps/web/src/components/SessionPicker.tsx new file mode 100644 index 0000000..35184c5 --- /dev/null +++ b/apps/web/src/components/SessionPicker.tsx @@ -0,0 +1,37 @@ +import type { SessionSummary } from '@oche/shared'; +import { cn } from '@/lib/utils'; + +export function SessionPicker({ + sessions, + selectedId, + onSelect, +}: { + sessions: SessionSummary[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + return ( +
+ {sessions.map((s) => { + const selected = s.id === selectedId; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..4578ded --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -0,0 +1,19 @@ +import { Button } from '@/components/ui/button'; +import { useTheme } from '@/hooks/useTheme'; + +export function ThemeToggle() { + const { theme, toggle } = useTheme(); + + return ( + + ); +} diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..3f9495f --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,80 @@ +import { cn } from '@/lib/utils'; + +export function Skeleton({ className }: { className?: string }) { + return
; +} + +export function SessionOverviewSkeleton() { + return ( +
+
+
+ + + +
+ +
+
    + {[0, 1, 2].map((i) => ( +
  • + + + + + +
  • + ))} +
+
+ ); +} + +export function HistoryListSkeleton() { + return ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • + + + + + +
  • + ))} +
+ ); +} + +export function SessionDetailSkeleton() { + return ( +
+
+ + + +
+ +
    + {[0, 1].map((i) => ( +
  • + + + + + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts new file mode 100644 index 0000000..89e4640 --- /dev/null +++ b/apps/web/src/hooks/useTheme.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export type Theme = 'dark' | 'light'; + +function readTheme(): Theme { + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem('oche-theme'); + return stored === 'light' ? 'light' : 'dark'; +} + +function applyTheme(theme: Theme) { + document.documentElement.dataset.theme = theme; + localStorage.setItem('oche-theme', theme); +} + +export function useTheme() { + const [theme, setThemeState] = useState(readTheme); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + return { + theme, + toggle: () => setThemeState((t) => (t === 'dark' ? 'light' : 'dark')), + setTheme: setThemeState, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f8d281e..c516bfb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -19,6 +19,16 @@ html { color-scheme: dark; } + html[data-theme='light'] { + color-scheme: light; + --color-canvas: #f5f3ec; + --color-surface: #ffffff; + --color-line: #e0ddd4; + --color-chalk: #0e1116; + --color-muted: #5c6570; + --color-oche: #7da812; + --color-amber: #c87d0a; + } body { margin: 0; background: var(--color-canvas); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 34fbd1c..6f067c5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { CreateSessionInput, Session, SessionSummary } from '@oche/shared'; +import type { CreateSessionInput, PatchSessionInput, Session, SessionSummary } from '@oche/shared'; const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; @@ -14,12 +14,22 @@ async function req(path: string, init?: RequestInit): Promise { return res.json() as Promise; } +export type ListSessionsOptions = { cursor?: string; limit?: number }; + export const api = { - listSessions: () => req<{ data: SessionSummary[]; nextCursor: string | null }>('/sessions'), + listSessions: (opts?: ListSessionsOptions) => { + const params = new URLSearchParams(); + if (opts?.cursor) params.set('cursor', opts.cursor); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return req<{ data: SessionSummary[]; nextCursor: string | null }>(`/sessions${qs ? `?${qs}` : ''}`); + }, getSession: (id: string) => req(`/sessions/${id}`), createSession: (body: CreateSessionInput) => req('/sessions', { method: 'POST', body: JSON.stringify(body) }), + patchSession: (id: string, body: PatchSessionInput) => + req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) => - req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }), + api.patchSession(id, { scores }), wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`, }; diff --git a/apps/web/src/routes/History.tsx b/apps/web/src/routes/History.tsx index 45071c7..22762f9 100644 --- a/apps/web/src/routes/History.tsx +++ b/apps/web/src/routes/History.tsx @@ -1,7 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; +import { QueryEmpty, QueryError } from '@/components/QueryFeedback'; +import { Button } from '@/components/ui/button'; +import { HistoryListSkeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +const ROW_HEIGHT = 72; + function formatWhen(iso: string) { return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', @@ -10,49 +17,119 @@ function formatWhen(iso: string) { }); } -/** Match history: paginated list; each row links to detail + video. */ +/** Match history: virtualised list with cursor pagination. */ export function History() { - const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions }); + const parentRef = useRef(null); + + const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } = + useInfiniteQuery({ + queryKey: ['sessions', 'history'], + queryFn: ({ pageParam }) => api.listSessions({ cursor: pageParam ?? undefined, limit: 20 }), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + }); + + const rows = useMemo(() => data?.pages.flatMap((p) => p.data) ?? [], [data?.pages]); - if (isLoading) return

Loading history…

; - if (error) return

Couldn’t load history.

; - if (!data?.data.length) return

No completed sessions yet.

; + const virtualCount = hasNextPage ? rows.length + 1 : rows.length; + + const virtualizer = useVirtualizer({ + count: virtualCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 6, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const last = virtualItems.at(-1); + if (!last) return; + if (last.index >= rows.length - 1 && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) return ; + if (error) { + return ( + void refetch()} + retrying={isRefetching} + /> + ); + } + if (!rows.length) { + return ( + + View live sessions + + } + /> + ); + } return ( -
    - {data.data.map((s) => ( -
  • - - {s.videoPoster ? ( - - ) : ( - - No video - - )} - - {s.title} - - {formatWhen(s.createdAt)} · {s.playerCount} players · {s.status} - - - -
  • - ))} -
+
+
    + {virtualItems.map((item) => { + const isLoader = item.index >= rows.length; + const s = rows[item.index]; + + return ( +
  • + {isLoader ? ( +
    + {isFetchingNextPage ? 'Loading more…' : 'Scroll for more'} +
    + ) : ( + + {s!.videoPoster ? ( + + ) : ( + + No video + + )} + + {s!.title} + + {formatWhen(s!.createdAt)} · {s!.playerCount} players · {s!.status} + + + + )} +
  • + ); + })} +
+
); } diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx index a145a47..97c85cf 100644 --- a/apps/web/src/routes/Overview.tsx +++ b/apps/web/src/routes/Overview.tsx @@ -1,38 +1,77 @@ import type { Session } from '@oche/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { PlayerAvatar } from '@/components/PlayerAvatar'; +import { QueryEmpty, QueryError } from '@/components/QueryFeedback'; import { SessionId } from '@/components/SessionId'; +import { SessionPicker } from '@/components/SessionPicker'; +import { Button } from '@/components/ui/button'; +import { SessionOverviewSkeleton } from '@/components/ui/skeleton'; import { useLiveSession } from '@/hooks/useLiveSession'; import { api } from '@/lib/api'; -import { Link } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -/** Primary screen: the most recent active session with editable, live scores. */ +/** Primary screen: active sessions with picker, editable live scores, and end-session. */ export function Overview() { - const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions }); - const firstActive = data?.data.find((s) => s.status === 'active') ?? data?.data[0]; + const { data, isLoading, error, refetch, isRefetching } = useQuery({ + queryKey: ['sessions'], + queryFn: () => api.listSessions(), + }); + + const activeSessions = useMemo(() => data?.data.filter((s) => s.status === 'active') ?? [], [data?.data]); - if (isLoading) return

Loading sessions…

; - if (error) - return

Couldn’t load sessions. Check the API is running.

; - if (!firstActive) + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { + if (!activeSessions.length) { + setSelectedId(null); + return; + } + if (!selectedId || !activeSessions.some((s) => s.id === selectedId)) { + setSelectedId(activeSessions[0]!.id); + } + }, [activeSessions, selectedId]); + + if (isLoading) return ; + if (error) { return ( -
-

No sessions yet.

- -
+ void refetch()} + retrying={isRefetching} + /> ); + } + if (!activeSessions.length) { + return ( + + Create a session + + } + /> + ); + } - return ; + return ( + <> + {activeSessions.length > 1 ? ( + + ) : null} + + + ); } function SessionPanel({ id }: { id: string }) { const qc = useQueryClient(); const [pulse, setPulse] = useState(null); const [saveError, setSaveError] = useState(null); + const [ending, setEnding] = useState(false); const { connected } = useLiveSession(id, (m) => { if (m.type === 'score') { @@ -42,10 +81,17 @@ function SessionPanel({ id }: { id: string }) { if (m.type === 'status') { qc.invalidateQueries({ queryKey: ['session', id] }); qc.invalidateQueries({ queryKey: ['sessions'] }); + qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); } }); - const { data: session } = useQuery({ + const { + data: session, + isLoading, + error, + refetch, + isRefetching, + } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id), refetchInterval: connected ? false : 5_000, @@ -71,11 +117,34 @@ function SessionPanel({ id }: { id: string }) { } } - if (!session) return null; + async function endSession() { + setSaveError(null); + setEnding(true); + try { + await api.patchSession(id, { status: 'completed' }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); + } catch { + setSaveError('Could not end session — try again.'); + } finally { + setEnding(false); + } + } + + if (isLoading) return ; + if (error || !session) { + return ( + void refetch()} + retrying={isRefetching} + /> + ); + } return (
-
+

{session.title}

@@ -83,9 +152,16 @@ function SessionPanel({ id }: { id: string }) { {connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'}

- - {session.status} - +
+ + {session.status} + + {session.status === 'active' ? ( + + ) : null} +
{saveError ? ( @@ -134,9 +210,9 @@ function cnPulse(active: boolean) { typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? '' : 'transition-colors duration-300'; - return [ + return cn( 'flex items-center justify-between rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-3', motion, - active ? 'border-[var(--color-oche)]' : '', - ].join(' '); + active && 'border-[var(--color-oche)]', + ); } diff --git a/apps/web/src/routes/SessionDetail.tsx b/apps/web/src/routes/SessionDetail.tsx index ccac4d2..13af61c 100644 --- a/apps/web/src/routes/SessionDetail.tsx +++ b/apps/web/src/routes/SessionDetail.tsx @@ -1,23 +1,43 @@ import { useQuery } from '@tanstack/react-query'; -import { useParams } from 'react-router-dom'; -import { SessionId } from '@/components/SessionId'; +import { Link, useParams } from 'react-router-dom'; import { PlayerAvatar } from '@/components/PlayerAvatar'; +import { QueryError } from '@/components/QueryFeedback'; +import { ScoreTimeline } from '@/components/ScoreTimeline'; +import { SessionId } from '@/components/SessionId'; +import { Button } from '@/components/ui/button'; +import { SessionDetailSkeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; -/** Session detail: scoreboard + game video (poster, metadata preload, byte-range via CDN). */ +/** Session detail: scoreboard, score timeline, and game video. */ export function SessionDetail() { const { id = '' } = useParams(); const { data: session, isLoading, error, + refetch, + isRefetching, } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id), + enabled: Boolean(id), }); - if (isLoading) return

Loading…

; - if (error || !session) return

Session not found.

; + if (isLoading) return ; + if (error || !session) { + return ( + void refetch()} + retrying={isRefetching} + action={ + + } + /> + ); + } return (
@@ -58,6 +78,8 @@ export function SessionDetail() { ))} + +
); } diff --git a/docs/HOMEWORK-SPEC.md b/docs/HOMEWORK-SPEC.md index 73b5eef..f692da6 100644 --- a/docs/HOMEWORK-SPEC.md +++ b/docs/HOMEWORK-SPEC.md @@ -67,9 +67,9 @@ Provide a short explanation covering: | Requirement | Oche implementation | Status | | ------------------ | ------------------------------------------------ | ------------------------------------- | | React dashboard | `apps/web` — Vite, React 19, TanStack Query | Done | -| Session ID | Used internally; **not shown in UI yet** | Gap | +| Session ID | `SessionId` component on Overview + Detail | Done | | Players + scores | `Overview.tsx` | Done | -| Player photo | `PlayerAvatar`; **seed has no photos** | Partial | +| Player photo | `PlayerAvatar` + ui-avatars in seed | Done | | Status | active/completed badge | Done | | Responsive UI | Tailwind, mobile-friendly layout | Done | | Update scores | PATCH + optimistic UI | Done | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3b18346..9bb7ede 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Project roadmap — Oche take-home -**Last updated:** Tier 1 complete — verify staging deploy next. +**Last updated:** Tier 2 complete — verify staging deploy next. **Goal:** Close homework gaps, verify staging/prod, then polish for interview. Use checkbox files in [checklists/](./checklists/) to track progress. This doc is the **narrative + full tier reference**. @@ -24,13 +24,13 @@ Use checkbox files in [checklists/](./checklists/) to track progress. This doc i ### What needs attention before submit -| Priority | Item | Doc | -| -------- | ------------------------------------------------------------ | ------------------------------------------------------ | -| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🟡 | README virtualization claim — implement or soften wording | [03-known-gaps](./checklists/03-known-gaps.md) | -| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🟡 | Re-seed locally (`npm run db:seed`) for player photos | — | -| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | +| Priority | Item | Doc | +| -------- | --------------------------------------------------------- | ------------------------------------------------------ | +| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | README virtualization claim — implement or soften wording | Done (Tier 2) | +| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | Re-seed locally (`npm run db:seed`) for player photos | — | +| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | --- @@ -54,18 +54,18 @@ Detailed checkboxes: [checklists/04-enhancements.md](./checklists/04-enhancement --- -### Tier 2 — UX & dashboard polish - -| # | Enhancement | Effort | Notes | -| --- | ------------------------------------ | ------ | ------------------------------------- | -| 2.1 | Session picker / tabs | M | Multiple active sessions per owner | -| 2.2 | “End session” → `status: completed` | S | PATCH + WS broadcast | -| 2.3 | Score history timeline | M | Render `scoreEvents` on SessionDetail | -| 2.4 | History pagination / infinite scroll | M | Wire API `nextCursor` | -| 2.5 | History list virtualization | M | `@tanstack/react-virtual` | -| 2.6 | Empty / error states with actions | S | Create session, retry API | -| 2.7 | Loading skeletons | S | Replace plain “Loading…” | -| 2.8 | Dark mode toggle | M | CSS variables already in place | +### Tier 2 — UX & dashboard polish (**complete**) + +| # | Enhancement | Effort | Notes | +| --- | ------------------------------------ | ------ | ------------------------------------ | +| 2.1 | Session picker / tabs | M | ✅ `SessionPicker` on Overview | +| 2.2 | “End session” → `status: completed` | S | ✅ PATCH + WS broadcast | +| 2.3 | Score history timeline | M | ✅ `ScoreTimeline` on SessionDetail | +| 2.4 | History pagination / infinite scroll | M | ✅ `useInfiniteQuery` + `nextCursor` | +| 2.5 | History list virtualization | M | ✅ `@tanstack/react-virtual` | +| 2.6 | Empty / error states with actions | S | ✅ `QueryFeedback` | +| 2.7 | Loading skeletons | S | ✅ `ui/skeleton.tsx` | +| 2.8 | Dark mode toggle | M | ✅ `useTheme` + light palette | --- diff --git a/docs/checklists/01-homework-rubric.md b/docs/checklists/01-homework-rubric.md index 9509f7e..445dcbb 100644 --- a/docs/checklists/01-homework-rubric.md +++ b/docs/checklists/01-homework-rubric.md @@ -10,16 +10,16 @@ Legend: `[x]` = implemented in repo (verify manually before interview). `[ ]` = ### A1. Session overview (live screen) -| | Item | Where / how to verify | -| --- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | -| [x] | React SPA | `apps/web` — Vite + React 19 | -| [x] | **Session ID visible** in UI | `SessionId` on Overview + SessionDetail — truncated UUID + copy button | -| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | -| [x] | **Player photo** shown | Seed sets `photoUrl` via ui-avatars; `PlayerAvatar` renders with srcset | -| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | -| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | -| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | -| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | +| | Item | Where / how to verify | +| --- | ---------------------------- | ------------------------------------------------------------------------ | +| [x] | React SPA | `apps/web` — Vite + React 19 | +| [x] | **Session ID visible** in UI | `SessionId` on Overview + SessionDetail — truncated UUID + copy button | +| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | +| [x] | **Player photo** shown | Seed sets `photoUrl` via ui-avatars; `PlayerAvatar` renders with srcset | +| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | +| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | +| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | +| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | ### A2. Match history diff --git a/docs/checklists/03-known-gaps.md b/docs/checklists/03-known-gaps.md index 1a10c14..406157a 100644 --- a/docs/checklists/03-known-gaps.md +++ b/docs/checklists/03-known-gaps.md @@ -8,13 +8,13 @@ Priority: 🔴 fix/explain before demo · 🟡 should fix if time · 🟢 defer ## UI vs homework spec -| Pri | Issue | Detail | Action | -| --- | --------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| ✅ | ~~Session ID not shown~~ | Fixed: `SessionId` on Overview + SessionDetail | — | -| ✅ | ~~Player photos empty~~ | Fixed: ui-avatars URLs in seed | Re-run `npm run db:seed` if DB predates change | -| ✅ | ~~No “create session” UI~~ | Fixed: `/sessions/new` form | — | -| 🟡 | No session picker | Overview always uses first active session | **Fix:** dropdown if multiple actives | -| 🟡 | Can’t mark completed in UI | PATCH supports `status` | **Fix:** “End session” button | +| Pri | Issue | Detail | Action | +| --- | -------------------------- | ---------------------------------------------- | ---------------------------------------------- | +| ✅ | ~~Session ID not shown~~ | Fixed: `SessionId` on Overview + SessionDetail | — | +| ✅ | ~~Player photos empty~~ | Fixed: ui-avatars URLs in seed | Re-run `npm run db:seed` if DB predates change | +| ✅ | ~~No “create session” UI~~ | Fixed: `/sessions/new` form | — | +| 🟡 | No session picker | Fixed: tabs when multiple actives | — | +| 🟡 | Can’t mark completed in UI | Fixed: “End session” on Overview | — | --- @@ -22,14 +22,14 @@ Priority: 🔴 fix/explain before demo · 🟡 should fix if time · 🟢 defer Claims in README, PERFORMANCE, MEDIA that are **design or partial** — don’t overstate in interview. -| Pri | Claim | Reality | Action | -| --- | ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------- | -| 🔴 | History list “virtualization” | Not implemented (`History.tsx` maps all rows) | **Fix** with `@tanstack/react-virtual` · **Or edit** README to say “planned at scale” | -| ✅ | ~~Photos: srcset~~ | Fixed: `media-src.ts` + `PlayerAvatar` srcset | — | -| 🟡 | `preconnect` to media origin | Only Google Fonts in `index.html` | **Fix:** preconnect to API/R2 domain in prod build | -| 🟡 | Media Capabilities API for video | `SessionDetail` uses static `` | **Fix** or **edit** MEDIA.md | -| 🟡 | HLS renditions | Schema + `` support; seed has MP4 only | **Explain:** offline `transcode-local.mjs` path | -| 🟢 | Queue + Container transcoding | Documented as paid-tier design | OK as “at scale” | +| Pri | Claim | Reality | Action | +| --- | --------------------------------- | ---------------------------------------------- | -------------------------------------------------- | +| ✅ | ~~History list “virtualization”~~ | Fixed: `@tanstack/react-virtual` on History | — | +| ✅ | ~~Photos: srcset~~ | Fixed: `media-src.ts` + `PlayerAvatar` srcset | — | +| 🟡 | `preconnect` to media origin | Only Google Fonts in `index.html` | **Fix:** preconnect to API/R2 domain in prod build | +| 🟡 | Media Capabilities API for video | `SessionDetail` uses static `` | **Fix** or **edit** MEDIA.md | +| 🟡 | HLS renditions | Schema + `` support; seed has MP4 only | **Explain:** offline `transcode-local.mjs` path | +| 🟢 | Queue + Container transcoding | Documented as paid-tier design | OK as “at scale” | --- diff --git a/docs/checklists/04-enhancements.md b/docs/checklists/04-enhancements.md index e234541..dcf4294 100644 --- a/docs/checklists/04-enhancements.md +++ b/docs/checklists/04-enhancements.md @@ -21,16 +21,16 @@ Legend: **Impact** (interviewer signal) · **Effort** (S/M/L) ## Tier 2 — UX & dashboard polish -| | Enhancement | Impact | Effort | Notes | -| --- | ------------------------------------ | --------------------- | ------ | ---------------------------------- | -| [ ] | Session picker / tabs | Multi-active venues | M | When several `active` sessions | -| [ ] | “End session” → `status: completed` | Complete lifecycle | S | PATCH + WS broadcast | -| [ ] | Score history timeline | Richer detail view | M | Use `scoreEvents` on SessionDetail | -| [ ] | History pagination / infinite scroll | Scale UX | M | Wire `nextCursor` from API | -| [ ] | History list virtualization | Matches README | M | `@tanstack/react-virtual` | -| [ ] | Empty / error states with actions | UX | S | “Create session”, retry API | -| [ ] | Loading skeletons | Perceived performance | S | Replace “Loading…” text | -| [ ] | Dark mode toggle | Polish | M | CSS variables already themed | +| | Enhancement | Impact | Effort | Notes | +| --- | ------------------------------------ | --------------------- | ------ | -------------------------------- | +| [x] | Session picker / tabs | Multi-active venues | M | `SessionPicker` on Overview | +| [x] | “End session” → `status: completed` | Complete lifecycle | S | PATCH + WS broadcast | +| [x] | Score history timeline | Richer detail view | M | `ScoreTimeline` on SessionDetail | +| [x] | History pagination / infinite scroll | Scale UX | M | `useInfiniteQuery` + nextCursor | +| [x] | History list virtualization | Matches README | M | `@tanstack/react-virtual` | +| [x] | Empty / error states with actions | UX | S | `QueryFeedback` components | +| [x] | Loading skeletons | Perceived performance | S | `ui/skeleton.tsx` | +| [x] | Dark mode toggle | Polish | M | `useTheme` + light palette | --- diff --git a/package-lock.json b/package-lock.json index 6149d4f..38b3a03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "@oche/shared": "*", "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-virtual": "^3.14.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^19.0.0", @@ -3057,6 +3058,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz", + "integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz", + "integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 20463cf..e0df38b 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -53,7 +53,7 @@ test('score edit persists after reload', async ({ page }) => { test('match history navigates to a session and shows the video region', async ({ page }) => { await page.goto('/history'); - await expect(page.getByText('Loading history')).toBeHidden({ timeout: 20_000 }); + await expect(page.getByLabel('Match history')).toBeVisible({ timeout: 20_000 }); await page.locator('main ul a').first().click(); await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 20_000 }); await expect(page.locator('video, p').filter({ hasText: /video/i })).toBeVisible(); From e53c1b7b152b5d292377972e7d2b6f01dca9a555 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 13:06:43 +0100 Subject: [PATCH 02/14] Implement media upload features and enhance session management - Added MediaUpload component for handling video and photo uploads with progress tracking and cancellation. - Introduced SessionMediaSection for attaching media to sessions, including video and poster uploads. - Enhanced CreateSession route to support optional player photo uploads during session creation. - Updated SessionDetail to display video and media upload options. - Improved media handling in the API, allowing for video URLs, posters, and player photos to be updated in sessions. - Added support for new video MIME types and updated OpenAPI specifications accordingly. - Implemented responsive image handling for session posters using PosterThumb component. --- SUBMISSION.md | 2 +- apps/api/src/lib/media-policy.ts | 13 +- apps/api/src/openapi/spec.ts | 15 +++ apps/api/src/services/sessions.ts | 22 +++- apps/web/src/components/MediaUpload.tsx | 111 ++++++++++++++++++ apps/web/src/components/PosterThumb.tsx | 19 +++ .../src/components/SessionMediaSection.tsx | 46 ++++++++ apps/web/src/components/SessionVideo.tsx | 83 +++++++++++++ apps/web/src/lib/media-src.ts | 25 ++++ apps/web/src/lib/upload-media.ts | 56 +++++++++ apps/web/src/routes/CreateSession.tsx | 101 +++++++++++----- apps/web/src/routes/History.tsx | 11 +- apps/web/src/routes/SessionDetail.tsx | 19 +-- apps/web/vite.config.d.ts.map | 2 +- apps/web/vite.config.js | 18 ++- apps/web/vite.config.ts | 18 ++- docs/ROADMAP.md | 16 +-- docs/checklists/03-known-gaps.md | 16 +-- docs/checklists/04-enhancements.md | 16 +-- package.json | 1 + packages/shared/src/__tests__/schema.test.ts | 8 ++ packages/shared/src/schema.ts | 24 +++- scripts/transcode-local.mjs | 104 ++++++++++++++-- 23 files changed, 653 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/components/MediaUpload.tsx create mode 100644 apps/web/src/components/PosterThumb.tsx create mode 100644 apps/web/src/components/SessionMediaSection.tsx create mode 100644 apps/web/src/components/SessionVideo.tsx create mode 100644 apps/web/src/lib/upload-media.ts diff --git a/SUBMISSION.md b/SUBMISSION.md index 63c150f..09458b7 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -18,7 +18,7 @@ Frontend performance · efficient image/video handling · what changes at scale ## Notable extras -RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · session picker + end-session lifecycle · virtualised history with cursor pagination · score timeline on detail · light/dark theme · full docs hub + ADRs + diagrams. +RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · R2 media upload from the UI (progress + cancel) · offline FFmpeg transcode script · Media Capabilities video source pick · session picker + end-session lifecycle · virtualised history · full docs hub + ADRs + diagrams. ## Systematic checklists & roadmap diff --git a/apps/api/src/lib/media-policy.ts b/apps/api/src/lib/media-policy.ts index 912833d..71aacaf 100644 --- a/apps/api/src/lib/media-policy.ts +++ b/apps/api/src/lib/media-policy.ts @@ -1,6 +1,11 @@ /** Allowed MIME types and size limits for uploads. */ export const PHOTO_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']); -export const VIDEO_MIMES = new Set(['video/mp4', 'video/webm']); +export const VIDEO_MIMES = new Set([ + 'video/mp4', + 'video/webm', + 'application/vnd.apple.mpegurl', + 'application/x-mpegURL', +]); export const MAX_PHOTO_BYTES = 5 * 1024 * 1024; export const MAX_VIDEO_BYTES = 100 * 1024 * 1024; @@ -14,7 +19,8 @@ export function classifyMedia(mime: string): MediaKind | null { } export function maxBytesFor(kind: MediaKind): number { - return kind === 'photo' ? MAX_PHOTO_BYTES : MAX_VIDEO_BYTES; + if (kind === 'photo') return MAX_PHOTO_BYTES; + return MAX_VIDEO_BYTES; } export function extensionFor(mime: string): string { @@ -31,6 +37,9 @@ export function extensionFor(mime: string): string { return 'mp4'; case 'video/webm': return 'webm'; + case 'application/vnd.apple.mpegurl': + case 'application/x-mpegURL': + return 'm3u8'; default: return 'bin'; } diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts index e674a1e..71eca22 100644 --- a/apps/api/src/openapi/spec.ts +++ b/apps/api/src/openapi/spec.ts @@ -133,6 +133,21 @@ export const openApiDocument = { }, }, }, + videoUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + videoPoster: { type: 'string', description: 'HTTPS URL or R2 key' }, + hlsUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + playerPhotos: { + type: 'array', + maxItems: 12, + items: { + type: 'object', + required: ['playerId', 'photoUrl'], + properties: { + playerId: { type: 'string', format: 'uuid' }, + photoUrl: { type: 'string' }, + }, + }, + }, }, }, }, diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts index 8f8f9ad..2413c83 100644 --- a/apps/api/src/services/sessions.ts +++ b/apps/api/src/services/sessions.ts @@ -143,7 +143,27 @@ export async function patchSession( }); } - if (input.scores?.length || input.status) { + for (const photo of input.playerPhotos ?? []) { + await tx + .update(players) + .set({ photoUrl: photo.photoUrl }) + .where(and(eq(players.id, photo.playerId), eq(players.sessionId, sessionId))); + } + + const mediaPatch: Partial = {}; + if (input.videoUrl !== undefined) mediaPatch.videoUrl = input.videoUrl; + if (input.videoPoster !== undefined) mediaPatch.videoPoster = input.videoPoster; + if (input.hlsUrl !== undefined) mediaPatch.hlsUrl = input.hlsUrl; + + const hasMediaPatch = Object.keys(mediaPatch).length > 0; + if (hasMediaPatch) { + await tx + .update(sessions) + .set({ ...mediaPatch, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + } + + if (input.scores?.length || input.status || input.playerPhotos?.length || hasMediaPatch) { await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId)); } diff --git a/apps/web/src/components/MediaUpload.tsx b/apps/web/src/components/MediaUpload.tsx new file mode 100644 index 0000000..c8085af --- /dev/null +++ b/apps/web/src/components/MediaUpload.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { uploadMedia, type UploadProgress } from '@/lib/upload-media'; + +type MediaUploadProps = { + accept: string; + label: string; + hint?: string; + disabled?: boolean; + onUploaded: (result: { key: string; kind: 'photo' | 'video' }) => void | Promise; +}; + +/** File picker with upload progress bar and cancel (XHR abort). */ +export function MediaUpload({ accept, label, hint, disabled, onUploaded }: MediaUploadProps) { + const inputRef = useRef(null); + const abortRef = useRef(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + + setError(null); + setBusy(true); + setProgress({ loaded: 0, total: file.size, percent: 0 }); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const result = await uploadMedia(file, { + signal: controller.signal, + onProgress: setProgress, + }); + await onUploaded(result); + setProgress(null); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + if (msg !== 'Upload cancelled') setError(msg); + setProgress(null); + } finally { + abortRef.current = null; + setBusy(false); + } + } + + function cancel() { + abortRef.current?.abort(); + } + + return ( +
+
+
+

{label}

+ {hint ?

{hint}

: null} +
+ +
+ + void onFileChange(e)} + /> + + {progress ? ( +
+
+
+
+
+ {progress.percent}% + +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/PosterThumb.tsx b/apps/web/src/components/PosterThumb.tsx new file mode 100644 index 0000000..7fe1b19 --- /dev/null +++ b/apps/web/src/components/PosterThumb.tsx @@ -0,0 +1,19 @@ +import { posterSrcSet } from '@/lib/media-src'; + +/** History row thumbnail with responsive srcset when derivable. */ +export function PosterThumb({ src, alt = '' }: { src: string; alt?: string }) { + const srcSet = posterSrcSet(src); + return ( + {alt} + ); +} diff --git a/apps/web/src/components/SessionMediaSection.tsx b/apps/web/src/components/SessionMediaSection.tsx new file mode 100644 index 0000000..edfb625 --- /dev/null +++ b/apps/web/src/components/SessionMediaSection.tsx @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { MediaUpload } from '@/components/MediaUpload'; +import { api } from '@/lib/api'; + +/** Upload game video / poster and attach to a session via PATCH. */ +export function SessionMediaSection({ sessionId, hasVideo }: { sessionId: string; hasVideo: boolean }) { + const qc = useQueryClient(); + const [notice, setNotice] = useState(null); + + async function attach(fields: { videoUrl?: string; videoPoster?: string; hlsUrl?: string }) { + setNotice(null); + await api.patchSession(sessionId, fields); + await qc.invalidateQueries({ queryKey: ['session', sessionId] }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); + setNotice('Media attached to this session.'); + } + + return ( +
+

+ Session media +

+ {!hasVideo ? ( +

No video yet — upload a clip or poster below.

+ ) : null} + + attach({ videoUrl: result.key })} + /> + + attach({ videoPoster: result.key })} + /> + + {notice ?

{notice}

: null} +
+ ); +} diff --git a/apps/web/src/components/SessionVideo.tsx b/apps/web/src/components/SessionVideo.tsx new file mode 100644 index 0000000..58e9f4a --- /dev/null +++ b/apps/web/src/components/SessionVideo.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; + +type VideoSource = { src: string; type: string }; + +function buildCandidates(videoUrl: string, hlsUrl?: string | null): VideoSource[] { + const out: VideoSource[] = []; + if (hlsUrl) out.push({ src: hlsUrl, type: 'application/vnd.apple.mpegurl' }); + const isWebm = videoUrl.includes('.webm'); + out.push({ src: videoUrl, type: isWebm ? 'video/webm' : 'video/mp4' }); + return out; +} + +async function rankByMediaCapabilities(sources: VideoSource[]): Promise { + if ( + !('mediaCapabilities' in navigator) || + typeof navigator.mediaCapabilities?.decodingInfo !== 'function' + ) { + return sources; + } + + const scored = await Promise.all( + sources.map(async (source) => { + try { + const info = await navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: source.type, + width: 1280, + height: 720, + bitrate: 2_500_000, + framerate: 30, + }, + }); + const score = (info.supported ? 2 : 0) + (info.smooth ? 1 : 0) + (info.powerEfficient ? 1 : 0); + return { source, score }; + } catch { + return { source, score: 1 }; + } + }), + ); + + return scored.sort((a, b) => b.score - a.score).map((row) => row.source); +} + +/** Video player that orders sources via Media Capabilities when available. */ +export function SessionVideo({ + videoUrl, + videoPoster, + hlsUrl, +}: { + videoUrl: string; + videoPoster?: string | null; + hlsUrl?: string | null; +}) { + const [sources, setSources] = useState(() => buildCandidates(videoUrl, hlsUrl)); + + useEffect(() => { + let cancelled = false; + const candidates = buildCandidates(videoUrl, hlsUrl); + void rankByMediaCapabilities(candidates).then((ranked) => { + if (!cancelled) setSources(ranked.length ? ranked : candidates); + }); + return () => { + cancelled = true; + }; + }, [videoUrl, hlsUrl]); + + return ( + + ); +} diff --git a/apps/web/src/lib/media-src.ts b/apps/web/src/lib/media-src.ts index 3e19102..6e0d1aa 100644 --- a/apps/web/src/lib/media-src.ts +++ b/apps/web/src/lib/media-src.ts @@ -8,3 +8,28 @@ export function avatarSrcSet(url: string): string | undefined { const large = u.toString(); return `${small} 40w, ${large} 80w`; } + +/** Poster/thumbnail srcset — ui-avatars density variants or Cloudflare-style width params. */ +export function posterSrcSet(url: string): string | undefined { + if (url.includes('ui-avatars.com')) { + const u = new URL(url); + u.searchParams.set('size', '80'); + const small = u.toString(); + u.searchParams.set('size', '160'); + const large = u.toString(); + return `${small} 80w, ${large} 160w`; + } + + try { + const u = new URL(url); + if (u.searchParams.has('w') || u.searchParams.has('width')) { + const base = u.toString(); + u.searchParams.set('w', '160'); + return `${base} 80w, ${u.toString()} 160w`; + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/apps/web/src/lib/upload-media.ts b/apps/web/src/lib/upload-media.ts new file mode 100644 index 0000000..a53330d --- /dev/null +++ b/apps/web/src/lib/upload-media.ts @@ -0,0 +1,56 @@ +const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; +const OWNER_HEADER = 'demo-key-a'; + +export type UploadResult = { key: string; kind: 'photo' | 'video'; url: string }; +export type UploadProgress = { loaded: number; total: number; percent: number }; + +/** Upload via XHR so we get progress events and can abort mid-flight. */ +export function uploadMedia( + file: File, + opts?: { onProgress?: (p: UploadProgress) => void; signal?: AbortSignal }, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${BASE}/media/upload`); + xhr.setRequestHeader('content-type', file.type); + xhr.setRequestHeader('x-oche-owner', OWNER_HEADER); + + const onAbort = () => xhr.abort(); + opts?.signal?.addEventListener('abort', onAbort, { once: true }); + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable || !opts?.onProgress) return; + opts.onProgress({ + loaded: e.loaded, + total: e.total, + percent: Math.min(100, Math.round((e.loaded / e.total) * 100)), + }); + }; + + xhr.onload = () => { + opts?.signal?.removeEventListener('abort', onAbort); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as UploadResult); + return; + } + try { + const body = JSON.parse(xhr.responseText) as { error: string }; + reject(new Error(body.error)); + } catch { + reject(new Error('Upload failed')); + } + }; + + xhr.onerror = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Network error during upload')); + }; + + xhr.onabort = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Upload cancelled')); + }; + + xhr.send(file); + }); +} diff --git a/apps/web/src/routes/CreateSession.tsx b/apps/web/src/routes/CreateSession.tsx index 5f361e0..39ff269 100644 --- a/apps/web/src/routes/CreateSession.tsx +++ b/apps/web/src/routes/CreateSession.tsx @@ -4,14 +4,17 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { api } from '@/lib/api'; +import { uploadMedia } from '@/lib/upload-media'; -/** Create a session + players (POST /sessions). */ +/** Create a session + players (POST /sessions), with optional player photo uploads. */ export function CreateSession() { const navigate = useNavigate(); const qc = useQueryClient(); const [title, setTitle] = useState(''); const [names, setNames] = useState(['', '']); + const [photos, setPhotos] = useState<(File | null)[]>([null, null]); const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); const create = useMutation({ mutationFn: (body: CreateSessionInput) => api.createSession(body), @@ -27,19 +30,28 @@ export function CreateSession() { setNames((prev) => prev.map((n, idx) => (idx === i ? value : n))); } + function setPhoto(i: number, file: File | null) { + setPhotos((prev) => prev.map((p, idx) => (idx === i ? file : p))); + } + function addPlayer() { - if (names.length < 12) setNames((prev) => [...prev, '']); + if (names.length < 12) { + setNames((prev) => [...prev, '']); + setPhotos((prev) => [...prev, null]); + } } function removePlayer(i: number) { if (names.length <= 1) return; setNames((prev) => prev.filter((_, idx) => idx !== i)); + setPhotos((prev) => prev.filter((_, idx) => idx !== i)); } - function submit(e: React.FormEvent) { + async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); - const players = names.map((name) => name.trim()).filter(Boolean); + const trimmed = names.map((name) => name.trim()); + const players = trimmed.map((name, position) => ({ name, position })).filter((p) => p.name); if (!title.trim()) { setError('Title is required.'); return; @@ -48,21 +60,43 @@ export function CreateSession() { setError('Add at least one player.'); return; } - create.mutate({ - title: title.trim(), - status: 'active', - players: players.map((name, position) => ({ name, score: 0, position })), - }); + + setUploading(true); + try { + const withPhotos = await Promise.all( + players.map(async (p, index) => { + const file = photos[index]; + if (!file) { + return { name: p.name, score: 0, position: p.position }; + } + const uploaded = await uploadMedia(file); + return { name: p.name, score: 0, position: p.position, photoUrl: uploaded.key }; + }), + ); + + create.mutate({ + title: title.trim(), + status: 'active', + players: withPhotos, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Photo upload failed.'); + } finally { + setUploading(false); + } } + const busy = create.isPending || uploading; + return (

New session

- Creates a session via POST /sessions. + Creates a session via POST /sessions. Optional player photos upload + to R2 first.

-
+ void submit(e)} className="mt-6 grid gap-4">