From 825eb14f2e496fad046bdd706dff91cc7bfafff8 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Mon, 5 Oct 2026 10:54:08 +0200 Subject: [PATCH] Phase 13: questions, sounds and videos on the scoreboard A game can now carry a list of items the host puts on the scoreboard one at a time: a question (shown above the standings, with an optional picture and an answer to reveal), a sound (played through the scoreboard's speakers) or a video such as a karaoke track (takes over the screen). - Media is a YouTube link, an https link to a file, or an upload to the private game-media bucket. The scoreboard API signs a URL only for the item that is on screen. - What is on screen is state on the event, like the timer, so the ping still carries no data. An unrevealed answer and a sound's or video's name never reach the scoreboard payload. - Control room: click an item to show it, or "Random" to have one picked (prefers items not played yet). Show/hide answer, pause, take off screen; shortcuts Q / A / X. - Duplicating a game or event copies its items and files; deleting removes the files. Needs supabase/migrations/0006_screen_items.sql applied before deploying. database.types.ts was edited by hand to match; re-run gen:types after. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 35 ++- docs/BUILD_PLAN.md | 11 + docs/DATA_MODEL.md | 6 +- docs/PRD.md | 36 ++- docs/UI.md | 22 ++ scripts/check-rls.mjs | 2 +- src/app/admin/actions.ts | 61 ++++- src/app/admin/item-actions.ts | 158 +++++++++++ src/app/admin/live-actions.ts | 130 ++++++++- src/components/admin/control-room.tsx | 42 ++- src/components/admin/games-editor.tsx | 2 + src/components/admin/items-editor.tsx | 306 ++++++++++++++++++++++ src/components/admin/screen-panel.tsx | 134 ++++++++++ src/components/scoreboard/on-screen.tsx | 179 +++++++++++++ src/components/scoreboard/scoreboard.tsx | 77 +++--- src/lib/database.types.ts | 67 +++++ src/lib/live-view.ts | 30 +++ src/lib/media.server.ts | 34 +++ src/lib/queries/event.ts | 6 +- src/lib/queries/scoring.ts | 13 +- src/lib/reorder.ts | 9 + src/lib/scoreboard-sounds.test.ts | 1 + src/lib/scoreboard.server.ts | 31 ++- src/lib/scoreboard.test.ts | 24 ++ src/lib/scoreboard.ts | 6 + src/lib/screen.test.ts | 255 ++++++++++++++++++ src/lib/screen.ts | 192 ++++++++++++++ src/lib/validation/admin.test.ts | 55 +++- src/lib/validation/admin.ts | 29 ++ supabase/migrations/0006_screen_items.sql | 74 ++++++ 30 files changed, 1967 insertions(+), 60 deletions(-) create mode 100644 src/app/admin/item-actions.ts create mode 100644 src/components/admin/items-editor.tsx create mode 100644 src/components/admin/screen-panel.tsx create mode 100644 src/components/scoreboard/on-screen.tsx create mode 100644 src/lib/media.server.ts create mode 100644 src/lib/reorder.ts create mode 100644 src/lib/screen.test.ts create mode 100644 src/lib/screen.ts create mode 100644 supabase/migrations/0006_screen_items.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cc3dd1..1defdcb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,8 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`. ## Supabase clients (`src/lib/supabase/`) -- `browser.ts` — anon key, used for Realtime subscribe + admin auth session. +- `browser.ts` — anon key, used for Realtime subscribe + admin auth session (which is also what + uploads an admin's media file straight to storage). - `server.ts` — cookie-based, for admin server actions (RLS applies as the admin user). - `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation. @@ -35,6 +36,9 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa - Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.) - Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`. - Buzz checks: the token's team exists, its event's current game has `buzzer_enabled`, isn't done, and nobody has been marked correct for this question. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op. +- Uploaded media lives in the private `game-media` bucket under `{eventId}/…`. Storage policies + allow the event's admins only; anon has none. The scoreboard API hands out a signed URL (12 h) + for the one item that is on screen, and nothing else. Links (YouTube, files) must be `https://`. - Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game. ## Realtime (Broadcast "ping then refetch") @@ -81,9 +85,18 @@ type ScoreboardPayload = { }>; answeringTeamId: string | null; // earliest unmarked buzz; null once someone got it } | null; + // null with nothing on screen; sent regardless of score visibility (see "On screen") + onScreen: + | { kind: 'question'; id: string; shownAt: string; text: string; + imageUrl: string | null; answer: string | null } // answer only once revealed + | { kind: 'sound'; id: string; shownAt: string; media: ScreenMedia; paused: boolean; + answer: string | null } + | { kind: 'video'; id: string; shownAt: string; media: ScreenMedia; paused: boolean } + | null; updatedAt: string; // server clock; screens use it to correct their own }; ``` +`ScreenMedia` = `{ type: 'youtube'; videoId; startSeconds }` or `{ type: 'file'; url }`. During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing. When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live. A missing or invalid snapshot is treated as hidden, never as live. @@ -113,6 +126,26 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live and step. The score is written before the mark, so a refused point leaves the answer unmarked. - A buzz is refused once any buzz of the game is marked correct, until the host clears. +## On screen (questions, sounds, videos) +- A game's items are rows in `game_items`. What is on screen is state on `events` + (`screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused`), like the timer. + An item only counts while its game is the current game; switching game clears it. +- `src/lib/screen.ts` holds the rules as pure functions: `buildOnScreen` (the sanitised payload + part — no unrevealed answer, no sound/video name), `parseYouTube`, `pickRandom`, + `nextQuestion`, and the upload checks. Server actions in `live-actions.ts` (show, random, + answer, pause, clear) and `item-actions.ts` (setup CRUD). +- Rule 4 still holds: the ping carries nothing; the scoreboard refetches and plays what the + payload says. Each player is mounted with a key of item id + `shownAt`, so showing an item + again restarts it, and it keeps the media URL it started with (a signed URL differs in every + payload; following it would restart the file on every refetch). +- Playback: files use `