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 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:54:08 +02:00
co-authored by Claude Opus 5.5
parent c27a3890f8
commit 825eb14f2e
30 changed files with 1967 additions and 60 deletions
+34 -1
View File
@@ -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 `<video>` / `<audio>`; YouTube uses the `youtube-nocookie.com` embed, with
mute and pause sent as `postMessage` player commands. The control room decides play/pause and
restart only — there is no shared playback position, so a scoreboard opened mid-song starts
the song from the beginning.
- Uploads go from the admin's browser to storage (a server action can't carry 50 MB); the
action then records the path, after checking it is inside that event's folder. Deleting an
item, game or event deletes its files; duplicating copies them.
## Folder layout
```
src/
+11
View File
@@ -89,5 +89,16 @@ in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question"
run the control room alongside A, but has no Delete button and can't change the co-hosts; removing B
makes the event disappear from B's list.
## Phase 13 — Questions, sounds and videos on the scoreboard
- Apply `supabase/migrations/0006_screen_items.sql` (table, event columns, the `game-media`
bucket and its policies), then `npm run gen:types` and `npm run check:rls`.
- Rules in `src/lib/screen.ts` (tested); setup editor per game with links and uploads;
control-room panel (show, random, answer, pause, take off screen; `Q` / `A` / `X`).
- Scoreboard: question above the standings, sound banner, video takeover.
**Done when:** a quiz game shows its questions one by one with `Q` and reveals an answer with `A`
(the answer is not in the scoreboard's network tab before that); an uploaded mp3 plays on the
scoreboard; a karaoke game with several YouTube links plays the one I click full screen, "Random
video" picks one that hasn't been played, and "Take off screen" brings the standings back.
## Stretch (only if asked)
Participants within teams · CSV export of results.
+4 -2
View File
@@ -1,10 +1,10 @@
# Data model
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`).
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`, `0006_screen_items.sql`).
| Table | Purpose | Key columns |
|---|---|---|
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at` |
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at`, `screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused` |
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order`, `buzzer_token` |
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id`, `buzzer_enabled` |
| `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` |
@@ -13,6 +13,7 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
| `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` |
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at`, `result` (correct/wrong/NULL) |
| `event_cohosts` | Admins an event is shared with | `event_id`, `email` (lower case), unique together |
| `game_items` | Questions, sounds and videos a game can put on the scoreboard | `game_id`, `kind` (question/sound/video), `title`, `answer`, `media_url`, `storage_path`, `sort_order`, `played_at` |
## Notes
- `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts.
@@ -22,4 +23,5 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
- Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones.
- Buzzer answers (`0004_buzz_result.sql`): `buzzes.result` is the host's mark. The team answering is the earliest buzz with `result IS NULL`, unless some buzz is `correct` (question decided). The point for a correct answer is not stored on the buzz: it is +1 on the admin's `scores` row for the game's first criterion.
- Co-hosts (`0005_cohosts.sql`): a share is keyed by email, not user id, so an admin can be added before they have ever logged in. RLS matches it against the email in the login token. `is_event_admin(event)` = owner or co-host and guards every table inside an event; deleting the event and editing `event_cohosts` stay owner-only. A trigger stops `events.owner_id` from changing.
- On-screen items (`0006_screen_items.sql`): `title` is the question's text, or the host's name for a sound or video. Media is `media_url` (https link) or `storage_path` (a file in the private `game-media` storage bucket, `{event id}/{uuid}.{ext}`) — never both; a sound or video needs one of them. `played_at` is set when an item goes on screen and only feeds the random pick. `events.screen_item_id` is the item on screen (set null if the item is deleted); `screen_shown_at` changes on every show so screens restart it. The bucket has storage policies for the event's admins only, a 50 MB limit and audio/video/image types.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+35 -1
View File
@@ -40,6 +40,7 @@ live scoreboard, and judges score from their phones via links. Versatile enough
- Quick presets: "Karaoke (5 × 1–5)", "Single score (0–10)", "Points (number)", "Win/Lose (0–1)".
- Admin notes + judge notes (multiline).
- "Teams buzz in" checkbox: this game uses the team buzzers (see 4.7).
- Questions, sounds and videos to put on the scoreboard during the game (see 4.8).
- Judges (judged games only):
- Nominate judges by name → each gets a personal link (copy button + QR code).
- Toggle the open link on/off; anyone opening it types their name and becomes a judge.
@@ -73,8 +74,11 @@ This is the screen used during the night. Must be fast and hard to mess up.
- "Open scoreboard" button (new tab) + "Reveal mode" button (see 4.3).
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first;
"Got it" / "Wrong" for the team answering, "Undo" on a marked team, and "Next question".
- On the scoreboard (games with questions, sounds or videos): the game's list, a button per item
to show or play it, "Random" per kind, show/hide answer, pause/resume, take off screen (see 4.8).
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
`G` got it, `W` wrong, `B` next question (clears the buzzers), `?` help.
`G` got it, `W` wrong, `B` next question (clears the buzzers), `Q` show the next question,
`A` show/hide the answer, `X` take it off screen, `?` help.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
@@ -150,6 +154,34 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
whether scores are visible, frozen or hidden (the point itself follows the usual visibility rules).
- The phone asks the browser to keep the screen on while the page is open.
### 4.8 Questions, sounds and videos on the scoreboard
A game can carry a list of things the host puts on the scoreboard, one at a time.
- Three kinds:
- **Question** — text, an optional picture and an optional answer. Shows above the standings.
- **Sound** — plays through the scoreboard's speakers; the screen only says "Listen…".
Optional answer (e.g. the song's name).
- **Video** — a karaoke track or a clip. Takes over the whole screen until taken off.
- Media is a link or an upload:
- a YouTube link (for a sound, only the audio is used), or an `https://` link to a file;
- or a file uploaded from the setup page: image for a question, audio for a sound, video for
a video, up to 50 MB each.
- Setup: per game, add / edit / reorder / delete items. Duplicating a game or an event copies
them, uploaded files included.
- Control room: the current game's list. Click an item to put it on screen; clicking the one
already showing starts it again. Only one item is on screen at a time, and switching game
clears it.
- **Random** (per kind, when there are at least two): picks one for the host — e.g. the next
karaoke song. It prefers items that haven't been shown yet and never repeats the one on
screen. Shown items get a ✓; "Clear the ✓ ticks" starts over.
- **Show answer** (`A`) reveals the answer under the question or sound; again to hide it.
- **Pause / Resume** for a sound or video. **Take off screen** (`X`) returns to the scoreboard.
- `Q` shows the next question in list order.
- Nothing here is a score, so it shows whether scores are visible, frozen or hidden.
- Secrets stay on the server: an answer is not sent to the scoreboard until it is revealed, and a
sound's or video's name is never sent (it is often the answer).
- Sound needs the same "Turn sound on" click as the sound effects (4.6). Until then a video
plays muted and the screen says the sound is off.
## 5. Non-functional
- Live latency target < 1s on normal Wi-Fi.
- Works with ~10 teams, ~20 games, ~10 judges per game without lag.
@@ -162,3 +194,5 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
- Buzzers for individual contestants, a configurable points value per correct answer, and
penalties for a wrong answer (use the bonus/penalty panel).
- Multiple-choice questions, questions on the judges' or teams' phones, importing a list of
questions from a file, and seeking within a video from the control room.
+22
View File
@@ -102,6 +102,28 @@ On the scoreboard the buzzer is a banner in the answering team's colour above th
In the control room the answering team's row is highlighted (Floodlight) and carries the two
buttons "Got it +1" and "Wrong"; marked rows show ✓ / ✗ and an "Undo".
### On screen: questions, sounds, videos
```
┌──────────────────────────────────────────────────────────────┐
│ Worship Game Night Game 5 of 8: Bible quiz │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Who built the ark? │ │
│ │ ▓ Noah ▓ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ 1 ████ Lions 142 │
│ 2 ████ Eagles 128 │
└──────────────────────────────────────────────────────────────┘
```
- Question: a bordered panel above the standings, text in Big Shoulders, sized down as it gets
longer; an optional picture above the text. The revealed answer is a Floodlight block that
pops in. The standings rows shrink to make room.
- Sound: a slim panel "♪ Listen…" ("Paused" while paused), with the answer under it once revealed.
- Video: fills the screen. The header and standings make way and the page padding shrinks; the
timer, the buzzer banner and the footer stay.
- While the browser still blocks sound, a Floodlight note says "Sound is off — click “Turn sound on”".
- Control room: a list like the buzzer panel — the item on screen is highlighted (Floodlight),
shown items carry a ✓, answers are visible to the host in small text.
## Copy
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
Errors say what happened + what to do: "This link has been turned off. Ask the host for a new one."
+1 -1
View File
@@ -2,7 +2,7 @@
// Run with: npm run check:rls
import { createClient } from "@supabase/supabase-js";
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes", "event_cohosts"];
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes", "event_cohosts", "game_items"];
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+48 -13
View File
@@ -5,8 +5,10 @@ import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { serverEnv } from "@/lib/env.server";
import { copyMedia, removeMedia } from "@/lib/media.server";
import { criteriaPresets, type PresetKey } from "@/lib/presets";
import { notify } from "@/lib/realtime";
import { moved } from "@/lib/reorder";
import { idSchema } from "@/lib/tokens";
import {
cohostEmailSchema,
@@ -32,16 +34,6 @@ const eventPath = (eventId: string) => `/admin/events/${eventId}`;
// New teams cycle through these so they're distinguishable without any effort.
const TEAM_COLORS = ["#E63946", "#3A86FF", "#2EC4B6", "#FFB703", "#8338EC", "#FB5607", "#06D6A0", "#FF006E"];
/** Returns the row order after moving `id` one place up or down. */
function moved<T extends { id: string }>(rows: T[], id: string, direction: "up" | "down"): T[] {
const from = rows.findIndex((row) => row.id === id);
const to = direction === "up" ? from - 1 : from + 1;
if (from < 0 || to < 0 || to >= rows.length) return rows;
const next = [...rows];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
// ---------- events ----------
export async function createEvent(_previous: ActionResult | null, formData: FormData): Promise<ActionResult> {
@@ -88,10 +80,19 @@ export async function deleteEvent(eventId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
// Deleting the event cascades to its items, so note their uploaded files first.
const uploads = await supabase
.from("game_items")
.select("storage_path, games!inner()")
.eq("games.event_id", eventId)
.not("storage_path", "is", null);
if (uploads.error) return dbFail("Deleting the event", uploads.error.message);
// RLS lets only the owner delete; for a co-host the delete matches nothing instead of failing.
const { data, error } = await supabase.from("events").delete().eq("id", eventId).select("id");
if (error) return dbFail("Deleting the event", error.message);
if (data.length === 0) return OWNER_ONLY;
await removeMedia(supabase, uploads.data.map((item) => item.storage_path));
revalidatePath("/admin");
return ok;
@@ -139,7 +140,7 @@ export async function removeCohost(eventId: string, cohostId: string): Promise<A
return ok;
}
/** Copies teams, games and criteria — not scores, judges or adjustments. */
/** Copies teams, games, criteria and on-screen items — not scores, judges or adjustments. */
export async function duplicateEvent(eventId: string): Promise<ActionResult> {
const { supabase, userId } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
@@ -147,7 +148,7 @@ export async function duplicateEvent(eventId: string): Promise<ActionResult> {
const [source, teams, games] = await Promise.all([
supabase.from("events").select("name, event_date").eq("id", eventId).maybeSingle(),
supabase.from("teams").select("name, color, sort_order").eq("event_id", eventId),
supabase.from("games").select("*, criteria(*)").eq("event_id", eventId),
supabase.from("games").select("*, criteria(*), game_items(*)").eq("event_id", eventId),
]);
const loadError = source.error ?? teams.error ?? games.error;
if (loadError) return dbFail("Copying the event", loadError.message);
@@ -283,7 +284,7 @@ type GameRowWithCriteria = NonNullable<
function loadGame(supabase: Supabase, eventId: string, gameId: string) {
return supabase
.from("games")
.select("*, criteria(*)")
.select("*, criteria(*), game_items(*)")
.eq("id", gameId)
.eq("event_id", eventId)
.maybeSingle();
@@ -332,6 +333,30 @@ async function insertGameCopy(
);
if (error) return error.message;
}
// Questions, sounds and videos. An uploaded file is copied, so each item owns its own.
const items = [];
for (const item of game.game_items) {
let storagePath: string | null = null;
if (item.storage_path) {
const copied = await copyMedia(supabase, item.storage_path, eventId);
if (copied.error !== null) return copied.error;
storagePath = copied.path;
}
items.push({
game_id: copy.data.id,
kind: item.kind,
title: item.title,
answer: item.answer,
media_url: item.media_url,
storage_path: storagePath,
sort_order: item.sort_order,
});
}
if (items.length) {
const { error } = await supabase.from("game_items").insert(items);
if (error) return error.message;
}
return null;
}
@@ -468,8 +493,18 @@ export async function deleteGame(eventId: string, gameId: string): Promise<Actio
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
// Deleting the game cascades to its items, so note their uploaded files first.
const uploads = await supabase
.from("game_items")
.select("storage_path, games!inner()")
.eq("game_id", gameId)
.eq("games.event_id", eventId)
.not("storage_path", "is", null);
if (uploads.error) return dbFail("Deleting the game", uploads.error.message);
const { error } = await supabase.from("games").delete().eq("id", gameId).eq("event_id", eventId);
if (error) return dbFail("Deleting the game", error.message);
await removeMedia(supabase, uploads.data.map((item) => item.storage_path));
revalidatePath(eventPath(eventId));
await notify({ eventId, buzzer: true });
+158
View File
@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth";
import { removeMedia } from "@/lib/media.server";
import { loadItems } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { moved } from "@/lib/reorder";
import { isStoragePathOf } from "@/lib/screen";
import { idSchema } from "@/lib/tokens";
import { firstError, itemSchema, MAX_ITEMS_PER_GAME, type ItemDraft } from "@/lib/validation/admin";
import type { ActionResult } from "./actions";
// Server actions for a game's questions, sounds and videos (setup page).
// The file itself is uploaded by the browser straight to storage; these only record its path.
const ok: ActionResult = { ok: true };
const fail = (error: string): ActionResult => ({ ok: false, error });
const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`);
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
/** The game must be in this event; RLS alone would allow a game from another of your events. */
async function gameInEvent(supabase: Supabase, eventId: string, gameId: string): Promise<boolean> {
const { data } = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
return data !== null;
}
/** The setup page lists the items; the control room and the scoreboard may be showing one. */
async function changed(eventId: string): Promise<ActionResult> {
revalidatePath(`/admin/events/${eventId}`);
revalidatePath(`/admin/events/${eventId}/live`);
await notify({ eventId });
return ok;
}
export async function addItem(eventId: string, gameId: string, input: ItemDraft): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
const parsed = itemSchema.safeParse(input);
if (!parsed.success) return fail(firstError(parsed.error));
const item = parsed.data;
// The path comes from the browser: it must point into this event's own folder.
if (item.storagePath && !isStoragePathOf(eventId, item.storagePath)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const existing = await supabase.from("game_items").select("sort_order").eq("game_id", gameId);
if (existing.error) return dbFail("Adding it", existing.error.message);
if (existing.data.length >= MAX_ITEMS_PER_GAME) {
return fail(`A game can have at most ${MAX_ITEMS_PER_GAME} questions, sounds and videos.`);
}
const { error } = await supabase.from("game_items").insert({
game_id: gameId,
kind: item.kind,
title: item.title,
answer: item.kind === "video" ? null : item.answer || null,
media_url: item.mediaUrl || null,
storage_path: item.storagePath,
sort_order: Math.max(-1, ...existing.data.map((row) => row.sort_order)) + 1,
});
if (error) return dbFail("Adding it", error.message);
return changed(eventId);
}
/** Edits the text, the answer and the link. An uploaded file is replaced by deleting and re-adding. */
export async function updateItem(
eventId: string,
gameId: string,
itemId: string,
input: Pick<ItemDraft, "title" | "answer" | "mediaUrl">,
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const current = await supabase
.from("game_items")
.select("kind, storage_path")
.eq("id", itemId)
.eq("game_id", gameId)
.maybeSingle();
if (current.error) return dbFail("Saving it", current.error.message);
if (!current.data) return BAD_ID;
const parsed = itemSchema.safeParse({
kind: current.data.kind,
title: input.title,
answer: input.answer,
// An item with an upload keeps it; it can't also have a link.
mediaUrl: current.data.storage_path ? "" : input.mediaUrl,
storagePath: current.data.storage_path,
});
if (!parsed.success) return fail(firstError(parsed.error));
const item = parsed.data;
const { error } = await supabase
.from("game_items")
.update({
title: item.title,
answer: item.kind === "video" ? null : item.answer || null,
media_url: item.mediaUrl || null,
})
.eq("id", itemId)
.eq("game_id", gameId);
if (error) return dbFail("Saving it", error.message);
return changed(eventId);
}
export async function deleteItem(eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const { data, error } = await supabase
.from("game_items")
.delete()
.eq("id", itemId)
.eq("game_id", gameId)
.select("storage_path");
if (error) return dbFail("Deleting it", error.message);
await removeMedia(supabase, data.map((row) => row.storage_path));
return changed(eventId);
}
export async function moveItem(
eventId: string,
gameId: string,
itemId: string,
direction: "up" | "down",
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const { data, error } = await loadItems(supabase, gameId);
if (error) return dbFail("Reordering", error.message);
// Rewrite every position so gaps or duplicates in sort_order heal themselves.
const results = await Promise.all(
moved(data, itemId, direction).map((item, index) =>
item.sort_order === index
? null
: supabase.from("game_items").update({ sort_order: index }).eq("id", item.id),
),
);
const failed = results.find((result) => result?.error);
if (failed?.error) return dbFail("Reordering", failed.error.message);
return changed(eventId);
}
+128 -2
View File
@@ -6,10 +6,11 @@ import { z } from "zod";
import { requireAdmin } from "@/lib/auth";
import type { Json } from "@/lib/database.types";
import { bumpScore, checkMark, type BuzzResult } from "@/lib/buzzer";
import { loadBuzzes, loadEventData } from "@/lib/queries/scoring";
import { loadBuzzes, loadEventData, loadItems } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { buildFrozenSnapshot } from "@/lib/scoreboard";
import { toScoreboardInput } from "@/lib/scoreboard.server";
import { ITEM_KINDS, pickRandom, type ItemKind } from "@/lib/screen";
import {
durationPatch,
MAX_TIMER_SECONDS,
@@ -48,7 +49,12 @@ export async function setCurrentGame(eventId: string, gameId: string): Promise<A
.eq("id", gameId)
.eq("event_id", eventId)
.eq("status", "upcoming"),
supabase.from("events").update({ current_game_id: gameId }).eq("id", eventId).select("id"),
// Whatever the last game had on screen goes with it.
supabase
.from("events")
.update({ current_game_id: gameId, ...CLEAR_SCREEN })
.eq("id", eventId)
.select("id"),
]);
const error = game.error ?? event.error;
if (error) return dbFail("Switching game", error.message);
@@ -80,6 +86,126 @@ export async function setGameStatus(
return ok;
}
// ---------- on screen: questions, sounds, videos ----------
const CLEAR_SCREEN = {
screen_item_id: null,
screen_shown_at: null,
screen_answer_shown: false,
screen_paused: false,
};
const NOT_CURRENT = fail("That game isn't the current one any more. Reload the page.");
/**
* Puts one of the current game's items on the scoreboard, from the start and with its answer
* hidden. Showing the item that is already up starts it again.
*/
async function putOnScreen(supabase: Supabase, eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const shownAt = new Date().toISOString();
// Only while its game is the current one: this is also what ties the item to this event.
const event = await supabase
.from("events")
.select("id")
.eq("id", eventId)
.eq("current_game_id", gameId)
.maybeSingle();
if (event.error) return dbFail("Putting it on screen", event.error.message);
if (!event.data) return NOT_CURRENT;
const item = await supabase
.from("game_items")
.update({ played_at: shownAt })
.eq("id", itemId)
.eq("game_id", gameId)
.select("id");
if (item.error) return dbFail("Putting it on screen", item.error.message);
if (item.data.length === 0) return BAD_ID;
const { error } = await supabase
.from("events")
.update({ ...CLEAR_SCREEN, screen_item_id: itemId, screen_shown_at: shownAt })
.eq("id", eventId)
.eq("current_game_id", gameId);
if (error) return dbFail("Putting it on screen", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
export async function showItem(eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
return putOnScreen(supabase, eventId, gameId, itemId);
}
/** "Pick one for me": a random item of this kind, preferring ones not played yet. */
export async function showRandomItem(eventId: string, gameId: string, kind: ItemKind): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId) || !ITEM_KINDS.includes(kind)) return BAD_ID;
const [items, event] = await Promise.all([
loadItems(supabase, gameId),
supabase.from("events").select("screen_item_id").eq("id", eventId).maybeSingle(),
]);
const loadError = items.error ?? event.error;
if (loadError) return dbFail("Picking one", loadError.message);
if (!event.data) return BAD_ID;
const pick = pickRandom(items.data ?? [], kind, event.data.screen_item_id);
if (!pick) return fail(`This game has no ${kind}s yet. Add some on the setup page.`);
return putOnScreen(supabase, eventId, gameId, pick.id);
}
/** Changes how the item on screen is shown; `what` names the action in an error message. */
async function updateScreen(
eventId: string,
what: string,
patch: Partial<typeof CLEAR_SCREEN> | { screen_answer_shown: boolean } | { screen_paused: boolean },
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const { error } = await supabase.from("events").update(patch).eq("id", eventId);
if (error) return dbFail(what, error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
/** Takes the question, sound or video off the scoreboard. */
export async function clearScreen(eventId: string): Promise<ActionResult> {
return updateScreen(eventId, "Clearing the screen", CLEAR_SCREEN);
}
export async function setAnswerShown(eventId: string, shown: boolean): Promise<ActionResult> {
return updateScreen(eventId, "Showing the answer", { screen_answer_shown: shown === true });
}
export async function setScreenPaused(eventId: string, paused: boolean): Promise<ActionResult> {
return updateScreen(eventId, "Pausing", { screen_paused: paused === true });
}
/** Forgets which items have been played, so "pick one for me" starts over. */
export async function resetPlayed(eventId: string, gameId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
// The game must be in this event; RLS alone would allow a game from another of your events.
const game = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
if (game.error) return dbFail("Resetting", game.error.message);
if (!game.data) return BAD_ID;
const { error } = await supabase.from("game_items").update({ played_at: null }).eq("game_id", gameId);
if (error) return dbFail("Resetting", error.message);
revalidatePath(livePath(eventId));
return ok;
}
// ---------- buzzers ----------
/** Clears who buzzed, ready for the next question. */
+41 -1
View File
@@ -6,11 +6,14 @@ import { useCallback, useEffect, useState } from "react";
import {
clearBuzzes,
clearScreen,
markBuzz,
pauseTimer,
setAnswerShown,
setCurrentGame,
setGameStatus,
setVisibility,
showItem,
startTimer,
} from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
@@ -18,6 +21,7 @@ import { formatScore } from "@/lib/criteria";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import type { LiveView } from "@/lib/live-view";
import { channelNames } from "@/lib/realtime-channels";
import { nextQuestion } from "@/lib/screen";
import { BonusPanel } from "./bonus-panel";
import { BuzzerPanel } from "./buzzer-panel";
@@ -25,6 +29,7 @@ import { ErrorText } from "./error-text";
import { JudgedPanel } from "./judged-panel";
import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel";
import { ScoringGrid } from "./scoring-grid";
import { ScreenPanel } from "./screen-panel";
import { TimerPanel } from "./timer-panel";
import { useAction } from "./use-action";
@@ -39,6 +44,9 @@ const shortcuts = [
["G", "Buzzer: the answering team got it"],
["W", "Buzzer: wrong, pass to the next team"],
["B", "Buzzer: next question (clears the buzzers)"],
["Q", "Scoreboard: show the next question"],
["A", "Scoreboard: show or hide the answer"],
["X", "Scoreboard: take the question, sound or video off screen"],
["?", "Show or hide this help"],
] as const;
@@ -92,6 +100,23 @@ export function ControlRoom({ view }: { view: LiveView }) {
[answeringTeamId, currentGame, event.id, pending, run],
);
const screen = currentGame?.screen;
const onScreenItem = currentGame?.items.find((item) => item.id === screen?.itemId);
const showNextQuestion = useCallback(() => {
if (!currentGame || pending) return;
const question = nextQuestion(currentGame.items, screen?.itemId ?? null);
if (question) run(() => showItem(event.id, currentGame.id, question.id));
}, [currentGame, event.id, pending, run, screen?.itemId]);
const hasAnswer = Boolean(onScreenItem?.answer);
const toggleAnswer = useCallback(() => {
if (screen && hasAnswer && !pending) run(() => setAnswerShown(event.id, !screen.answerShown));
}, [event.id, hasAnswer, pending, run, screen]);
const takeOffScreen = useCallback(() => {
if (screen && !pending) run(() => clearScreen(event.id));
}, [event.id, pending, run, screen]);
useEffect(() => {
const onKeyDown = (keyEvent: KeyboardEvent) => {
// Never steal keys from a field the host is typing in.
@@ -107,12 +132,26 @@ export function ControlRoom({ view }: { view: LiveView }) {
else if (key === "b") clearBuzzers();
else if (key === "g") markAnswer("correct");
else if (key === "w") markAnswer("wrong");
else if (key === "q") showNextQuestion();
else if (key === "a") toggleAnswer();
else if (key === "x") takeOffScreen();
else if (key === "?") setShowHelp((shown) => !shown);
else if (key === "escape") setShowHelp(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [clearBuzzers, cycleVisibility, goTo, markAnswer, nextGame?.id, previousGame?.id, toggleTimer]);
}, [
clearBuzzers,
cycleVisibility,
goTo,
markAnswer,
nextGame?.id,
previousGame?.id,
showNextQuestion,
takeOffScreen,
toggleAnswer,
toggleTimer,
]);
const teamsById = new Map(teams.map((team) => [team.id, team]));
@@ -235,6 +274,7 @@ export function ControlRoom({ view }: { view: LiveView }) {
{currentGame ? (
<>
{currentGame.items.length > 0 && <ScreenPanel eventId={event.id} game={currentGame} />}
{currentGame.buzzer && (
<BuzzerPanel eventId={event.id} game={currentGame} buzzer={currentGame.buzzer} />
)}
+2
View File
@@ -10,6 +10,7 @@ import type { GameWithCriteria } from "@/lib/queries/event";
import { ErrorText } from "./error-text";
import { GameForm } from "./game-form";
import { ItemsEditor } from "./items-editor";
import { JudgesEditor } from "./judges-editor";
import { useAction } from "./use-action";
@@ -132,6 +133,7 @@ function GameCard({
{/* Re-mount after each save so the form picks up ids of newly created criteria. */}
<GameForm key={game.updated_at} eventId={eventId} game={game} />
<ItemsEditor eventId={eventId} game={game} />
{game.scoring_mode === "judged" && <JudgesEditor eventId={eventId} game={game} />}
</div>
</details>
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import { addItem, deleteItem, moveItem, updateItem } from "@/app/admin/item-actions";
import type { ActionResult } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { Field, inputClass } from "@/components/ui/field";
import type { Tables } from "@/lib/database.types";
import type { GameWithCriteria } from "@/lib/queries/event";
import {
MEDIA_BUCKET,
parseYouTube,
storagePathFor,
uploadAccept,
uploadProblem,
type ItemKind,
} from "@/lib/screen";
import { createClient } from "@/lib/supabase/browser";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
type Item = Tables<"game_items">;
export const kindLabel: Record<ItemKind, string> = { question: "Question", sound: "Sound", video: "Video" };
const titleLabel: Record<ItemKind, string> = {
question: "Question",
sound: "Name (only you see it)",
video: "Name (only you see it)",
};
const linkLabel: Record<ItemKind, string> = {
question: "Picture link (optional)",
sound: "Link to the sound",
video: "Link to the video",
};
const linkHint: Record<ItemKind, string> = {
question: "A link to an image, starting with https://",
sound: "A YouTube link (only the sound plays) or a link to an audio file.",
video: "A YouTube link, or a link to a video file.",
};
function sourceLabel(item: Item): string | null {
if (item.storage_path) return "Uploaded file";
if (!item.media_url) return null;
return parseYouTube(item.media_url) ? "YouTube" : "Link";
}
/** A game's questions, sounds and videos: what the host can put on the scoreboard. */
export function ItemsEditor({ eventId, game }: { eventId: string; game: GameWithCriteria }) {
const items = game.game_items;
return (
<section className="flex flex-col gap-3 rounded-md border border-ink/15 bg-chalk p-3">
<div>
<h3 className="text-lg font-bold">On the scoreboard: questions, sounds and videos</h3>
<p className="text-sm text-ink/70">
In the control room you click one to show it. A question appears above the scores, a sound
plays through the scoreboard&apos;s speakers, and a video (like a karaoke track) takes over
the whole screen.
</p>
</div>
{items.length > 0 && (
<ol className="flex flex-col gap-2">
{items.map((item, index) => (
<ItemRow
key={item.id}
eventId={eventId}
gameId={game.id}
item={item}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</ol>
)}
<AddItemForm eventId={eventId} gameId={game.id} />
</section>
);
}
function ItemRow({
eventId,
gameId,
item,
isFirst,
isLast,
}: {
eventId: string;
gameId: string;
item: Item;
isFirst: boolean;
isLast: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ title: item.title, answer: item.answer ?? "", mediaUrl: item.media_url ?? "" });
const { pending, error, run } = useAction();
const source = sourceLabel(item);
return (
<li className="rounded-md bg-white p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-ink/10 px-2 py-0.5 text-sm font-bold">{kindLabel[item.kind]}</span>
<span className="min-w-40 flex-1">
<span className="font-bold">{item.title}</span>
{item.answer && <span className="block text-sm text-ink/70">Answer: {item.answer}</span>}
</span>
{source && <span className="text-sm text-ink/70">{source}</span>}
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} up`}
disabled={pending || isFirst}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "up"))}
>
↑
</Button>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} down`}
disabled={pending || isLast}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "down"))}
>
↓
</Button>
<Button variant="secondary" size="sm" aria-expanded={editing} onClick={() => setEditing(!editing)}>
{editing ? "Close" : "Edit"}
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${item.title}"? This can't be undone.`)) {
run(() => deleteItem(eventId, gameId, item.id));
}
}}
>
Delete
</Button>
</div>
{editing && (
<form
className="mt-2 grid gap-3 border-t border-ink/15 pt-2 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(() => updateItem(eventId, gameId, item.id, draft), () => setEditing(false));
}}
>
<Field label={titleLabel[item.kind]} className="sm:col-span-2">
<input
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
required
maxLength={500}
className={inputClass}
/>
</Field>
{item.kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”.">
<input
value={draft.answer}
onChange={(event) => setDraft({ ...draft, answer: event.target.value })}
maxLength={500}
className={inputClass}
/>
</Field>
)}
{item.storage_path ? (
<p className="self-end text-sm text-ink/70">
Uses an uploaded file. To change the file, delete this and add it again.
</p>
) : (
<Field label={linkLabel[item.kind]} hint={linkHint[item.kind]}>
<input
type="url"
value={draft.mediaUrl}
onChange={(event) => setDraft({ ...draft, mediaUrl: event.target.value })}
maxLength={2000}
className={inputClass}
/>
</Field>
)}
<div className="sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? "Saving…" : "Save"}
</Button>
</div>
</form>
)}
<ErrorText>{error}</ErrorText>
</li>
);
}
function AddItemForm({ eventId, gameId }: { eventId: string; gameId: string }) {
const [kind, setKind] = useState<ItemKind>("question");
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
const [mediaUrl, setMediaUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
// Bumped to empty the file input, which React can't control directly.
const [fileKey, setFileKey] = useState(0);
const { pending, error, run } = useAction();
const clearFile = () => {
setFile(null);
setFileKey((key) => key + 1);
};
/** Uploads the chosen file (if any) straight to storage, then records the item. */
const save = async (): Promise<ActionResult> => {
const draft = { kind, title, answer, mediaUrl: file ? "" : mediaUrl };
if (!file) return addItem(eventId, gameId, { ...draft, storagePath: null });
const problem = uploadProblem(kind, file);
const storagePath = storagePathFor(eventId, file.name, crypto.randomUUID());
if (problem || !storagePath) return { ok: false, error: problem ?? "That file can't be uploaded." };
const storage = createClient().storage.from(MEDIA_BUCKET);
const upload = await storage.upload(storagePath, file, { contentType: file.type });
if (upload.error) return { ok: false, error: `The upload failed: ${upload.error.message}. Try again.` };
const result = await addItem(eventId, gameId, { ...draft, storagePath });
// Don't leave a file behind that no item points to.
if (!result.ok) await storage.remove([storagePath]);
return result;
};
return (
<form
className="grid gap-3 border-t border-ink/15 pt-3 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(save, () => {
setTitle("");
setAnswer("");
setMediaUrl("");
clearFile();
});
}}
>
<Field label="Add a">
<select
value={kind}
onChange={(event) => {
setKind(event.target.value as ItemKind);
// A file chosen for another kind would be the wrong type.
clearFile();
}}
className={inputClass}
>
<option value="question">Question</option>
<option value="sound">Sound</option>
<option value="video">Video (karaoke, clip)</option>
</select>
</Field>
<Field label={titleLabel[kind]}>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={500}
className={inputClass}
placeholder={kind === "question" ? "Who built the ark?" : kind === "sound" ? "Mystery song 1" : "Oceans — karaoke"}
/>
</Field>
{kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”." className="sm:col-span-2">
<input value={answer} onChange={(event) => setAnswer(event.target.value)} maxLength={500} className={inputClass} />
</Field>
)}
<Field label={linkLabel[kind]} hint={linkHint[kind]}>
<input
type="url"
value={mediaUrl}
onChange={(event) => setMediaUrl(event.target.value)}
maxLength={2000}
disabled={file !== null}
className={`${inputClass} disabled:bg-ink/5`}
placeholder="https://"
/>
</Field>
<Field label="…or upload a file" hint="Up to 50 MB. Used instead of the link.">
<input
key={fileKey}
type="file"
accept={uploadAccept(kind)}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className={`${inputClass} py-2`}
/>
</Field>
<div className="flex flex-wrap items-center gap-3 sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? (file ? "Uploading…" : "Adding…") : `Add ${kindLabel[kind].toLowerCase()}`}
</Button>
<ErrorText>{error}</ErrorText>
</div>
</form>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import {
clearScreen,
resetPlayed,
setAnswerShown,
setScreenPaused,
showItem,
showRandomItem,
} from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import type { LiveGame } from "@/lib/live-view";
import { ITEM_KINDS, type ItemKind } from "@/lib/screen";
import { ErrorText } from "./error-text";
import { kindLabel } from "./items-editor";
import { useAction } from "./use-action";
const showLabel: Record<ItemKind, string> = { question: "Show", sound: "Play", video: "Play" };
const plural: Record<ItemKind, string> = { question: "question", sound: "sound", video: "video" };
/**
* The current game's questions, sounds and videos. Clicking one puts it on the scoreboard;
* "Random" picks one for the host, preferring ones that haven't been played yet.
*/
export function ScreenPanel({ eventId, game }: { eventId: string; game: LiveGame }) {
const { pending, error, run } = useAction();
const { items, screen } = game;
const onScreen = items.find((item) => item.id === screen?.itemId);
const isMedia = onScreen !== undefined && onScreen.kind !== "question";
return (
<div className="rounded-md border border-ink/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-bold">On the scoreboard</h3>
<div className="flex flex-wrap gap-2">
{onScreen?.answer && screen && (
<Button disabled={pending} onClick={() => run(() => setAnswerShown(eventId, !screen.answerShown))}>
{screen.answerShown ? "Hide answer (A)" : "Show answer (A)"}
</Button>
)}
{isMedia && screen && (
<Button
variant="secondary"
disabled={pending}
onClick={() => run(() => setScreenPaused(eventId, !screen.paused))}
>
{screen.paused ? "Resume" : "Pause"}
</Button>
)}
<Button variant="secondary" disabled={pending || !onScreen} onClick={() => run(() => clearScreen(eventId))}>
Take off screen (X)
</Button>
</div>
</div>
<p role="status" className="mt-1">
{onScreen ? (
<>
Showing: <strong>{onScreen.title}</strong>
{screen?.paused && isMedia && " — paused"}
{onScreen.answer && (screen?.answerShown ? " — the room sees the answer" : " — answer still hidden")}
</>
) : (
"Nothing extra on screen — the room sees the scoreboard."
)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{ITEM_KINDS.map((kind) => {
// With a single item there is nothing to choose between.
if (items.filter((item) => item.kind === kind).length < 2) return null;
return (
<Button
key={kind}
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => showRandomItem(eventId, game.id, kind))}
>
Random {plural[kind]}
</Button>
);
})}
{items.some((item) => item.played) && (
<Button
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => resetPlayed(eventId, game.id))}
>
Clear the ✓ ticks
</Button>
)}
</div>
<ol className="mt-2 flex flex-col gap-1">
{items.map((item) => {
const showing = item.id === screen?.itemId;
return (
<li
key={item.id}
aria-current={showing ? "true" : undefined}
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
showing ? "bg-floodlight font-bold" : ""
}`}
>
<span className="w-20 shrink-0 text-sm">{kindLabel[item.kind]}</span>
<span className={`min-w-40 flex-1 ${item.played && !showing ? "text-ink/70" : ""}`}>
{item.title}
{item.answer && <span className="block text-sm font-normal">Answer: {item.answer}</span>}
</span>
{item.played && !showing && (
<span className="text-sm" title="Already shown">
✓ <span className="sr-only">already shown</span>
</span>
)}
<Button
variant={showing ? "secondary" : "primary"}
size="sm"
disabled={pending}
aria-label={`${showing ? "Restart" : showLabel[item.kind]}: ${item.title}`}
onClick={() => run(() => showItem(eventId, game.id, item.id))}
>
{showing ? (item.kind === "question" ? "Showing" : "Restart") : showLabel[item.kind]}
</Button>
</li>
);
})}
</ol>
<ErrorText>{error}</ErrorText>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { OnScreen, ScreenMedia } from "@/lib/screen";
// What the host has put on the scoreboard: a question above the standings, a sound, or a
// video that takes over the screen.
//
// Each of these is mounted with a key of item + shownAt. That restarts it when the host shows
// it again, and lets it keep the media URL it started with: an upload's signed URL is
// different in every payload, and following it would restart the file on every refetch.
type Question = Extract<OnScreen, { kind: "question" }>;
type Sound = Extract<OnScreen, { kind: "sound" }>;
type Video = Extract<OnScreen, { kind: "video" }>;
export const onScreenKey = (item: OnScreen) => `${item.id}:${item.shownAt}`;
const SOUND_OFF_NOTE = "Sound is off — click “Turn sound on”";
/** Long questions get smaller type so they still fit above the standings. */
function questionSize(length: number): string {
if (length <= 60) return "text-[clamp(1.5rem,8vh,7rem)]";
if (length <= 140) return "text-[clamp(1.25rem,6vh,5rem)]";
if (length <= 280) return "text-[clamp(1.125rem,4.5vh,3.5rem)]";
return "text-[clamp(1rem,3.4vh,2.5rem)]";
}
export function QuestionPanel({ question }: { question: Question }) {
const [imageUrl] = useState(question.imageUrl);
return (
<section
role="status"
className="buzz-pop flex max-h-[62vh] min-h-0 shrink-0 flex-col items-center justify-center gap-[1.5vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[2vh] text-center"
>
{imageUrl && (
// A plain <img>: the picture comes from any host or a signed storage URL, which
// next/image would need configuring for and can't cache anyway.
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt="" className="max-h-[30vh] min-h-0 rounded-[1vh] object-contain" />
)}
<p className={`font-display leading-[1.1] font-bold text-balance ${questionSize(question.text.length)}`}>
{question.text}
</p>
{question.answer && <Answer text={question.answer} />}
</section>
);
}
function Answer({ text }: { text: string }) {
return (
<p className="buzz-pop max-w-full rounded-[1vh] bg-floodlight px-[2.5vw] py-[1vh] font-display text-[clamp(1.25rem,6vh,5rem)] leading-[1.1] font-bold text-ink">
{text}
</p>
);
}
/** A sound has nothing to look at: a slim banner says something is playing. */
export function SoundBanner({ sound, audible }: { sound: Sound; audible: boolean }) {
return (
<section
role="status"
className="buzz-pop relative flex shrink-0 flex-col items-center gap-[1vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[1.5vh] text-center"
>
<p className="font-display text-[clamp(1.25rem,6vh,5rem)] leading-none font-bold">
<span aria-hidden>♪ </span>
{sound.paused ? "Paused" : "Listen…"}
</p>
{!audible && <p className="text-[clamp(0.875rem,2.4vh,1.75rem)] text-floodlight">{SOUND_OFF_NOTE}</p>}
{sound.answer && <Answer text={sound.answer} />}
{/* A YouTube link used as a sound: the player is there, but nobody sees the video. */}
<div aria-hidden className="pointer-events-none absolute top-0 left-0 size-[200px] opacity-0">
<Player media={sound.media} paused={sound.paused} audible={audible} audioOnly />
</div>
</section>
);
}
/** A video (karaoke, a clip) replaces the standings and fills the screen. */
export function VideoStage({ video, audible }: { video: Video; audible: boolean }) {
return (
<section aria-label="Video" className="relative min-h-0 flex-1 overflow-hidden rounded-[1vh] bg-black">
<Player media={video.media} paused={video.paused} audible={audible} />
{!audible && (
<p className="absolute top-[2vh] left-1/2 -translate-x-1/2 rounded-full bg-stage/90 px-[2vw] py-[1vh] text-[clamp(0.875rem,2.4vh,1.75rem)] font-bold text-floodlight">
{SOUND_OFF_NOTE}
</p>
)}
</section>
);
}
type PlayerProps = {
media: ScreenMedia;
paused: boolean;
/** False until someone has clicked the page: browsers only allow muted playback before that. */
audible: boolean;
audioOnly?: boolean;
};
function Player(props: PlayerProps) {
// Pinned: see the note at the top of this file.
const [media] = useState(props.media);
return media.type === "youtube" ? (
<YouTubePlayer {...props} videoId={media.videoId} startSeconds={media.startSeconds} />
) : (
<FilePlayer {...props} url={media.url} />
);
}
function FilePlayer({ url, paused, audible, audioOnly }: PlayerProps & { url: string }) {
const element = useRef<HTMLVideoElement & HTMLAudioElement>(null);
useEffect(() => {
const media = element.current;
if (!media) return;
// Set here, not as a prop: React doesn't keep the `muted` attribute in sync.
media.muted = !audible;
if (paused) media.pause();
// Rejected if the browser still blocks playback; the next change tries again.
else void media.play().catch(() => {});
}, [paused, audible]);
return audioOnly ? (
<audio ref={element} src={url} preload="auto" />
) : (
<video ref={element} src={url} preload="auto" playsInline className="size-full object-contain" />
);
}
const YOUTUBE_ORIGIN = "https://www.youtube-nocookie.com";
function YouTubePlayer({
videoId,
startSeconds,
paused,
audible,
}: PlayerProps & { videoId: string; startSeconds: number }) {
const frame = useRef<HTMLIFrameElement>(null);
// The URL carries the state the player starts in; later changes are sent as commands.
// Changing the URL instead would reload the video from the start.
const [src] = useState(() => {
const params = new URLSearchParams({
autoplay: paused ? "0" : "1",
mute: audible ? "0" : "1",
start: String(startSeconds),
enablejsapi: "1",
playsinline: "1",
rel: "0",
});
return `${YOUTUBE_ORIGIN}/embed/${videoId}?${params}`;
});
// YouTube's embed takes player commands as postMessage JSON once enablejsapi=1 is set.
const sync = useCallback(() => {
const send = (func: string) =>
frame.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args: [] }), YOUTUBE_ORIGIN);
send(audible ? "unMute" : "mute");
send(paused ? "pauseVideo" : "playVideo");
}, [audible, paused]);
useEffect(() => {
sync();
}, [sync]);
return (
<iframe
ref={frame}
src={src}
title="Video"
onLoad={sync}
allow="autoplay; encrypted-media; fullscreen"
referrerPolicy="strict-origin-when-cross-origin"
className="size-full border-0"
/>
);
}
+45 -32
View File
@@ -11,6 +11,7 @@ import type { ScoreboardPayload, ScoreboardTeam } from "@/lib/scoreboard";
import { soundsForUpdate } from "@/lib/scoreboard-sounds";
import { CountUp } from "./count-up";
import { onScreenKey, QuestionPanel, SoundBanner, VideoStage } from "./on-screen";
import { ScoreboardTimer } from "./scoreboard-timer";
const MOVE_MS = 600;
@@ -104,13 +105,19 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
const revealing = payload.reveal?.active ?? false;
const hidden = payload.visibility === "hidden" && !revealing;
const { currentGame } = payload;
const { currentGame, onScreen } = payload;
// A video takes over: the header and the standings make way, and the padding shrinks.
const video = onScreen?.kind === "video" ? onScreen : null;
// Media may only play with sound once someone has clicked the page ("Turn sound on").
const audible = sound.state === "on";
return (
<div
className={`flex h-dvh flex-col gap-[2.5vh] overflow-hidden bg-stage px-[4vw] py-[3.5vh] text-white ${idle ? "cursor-none" : ""}`}
className={`flex h-dvh flex-col overflow-hidden bg-stage text-white ${
video ? "gap-[1vh] p-[1vh]" : "gap-[2.5vh] px-[4vw] py-[3.5vh]"
} ${idle ? "cursor-none" : ""}`}
>
<header className="flex items-baseline justify-between gap-[3vw] font-display text-[clamp(1.25rem,5vh,4.5rem)] leading-none font-bold">
<header hidden={video !== null} className="flex items-baseline justify-between gap-[3vw] font-display text-[clamp(1.25rem,5vh,4.5rem)] leading-none font-bold">
<h1 className="truncate">{payload.eventName}</h1>
{currentGame && (
<p className="shrink-0 text-floodlight">
@@ -126,40 +133,46 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
{payload.buzzer && <BuzzBanner buzzer={payload.buzzer} />}
{hidden && (
{onScreen?.kind === "question" && <QuestionPanel key={onScreenKey(onScreen)} question={onScreen} />}
{onScreen?.kind === "sound" && <SoundBanner key={onScreenKey(onScreen)} sound={onScreen} audible={audible} />}
{video && <VideoStage key={onScreenKey(video)} video={video} audible={audible} />}
{hidden && !video && (
<p className="self-center rounded-full border-2 border-alert px-[2.5vw] py-[1vh] text-[clamp(1rem,3.2vh,2.5rem)] font-bold">
Scores are hidden — final reveal soon
</p>
)}
<ol className="flex min-h-0 flex-1 flex-col justify-center gap-[1.2vh]">
{payload.teams.length === 0 && (
<li className="self-center text-[clamp(1rem,3.2vh,2.5rem)] text-white/70">No teams yet</li>
)}
{payload.teams.map((team, index) =>
revealing && team.rank === undefined ? (
// Keyed by slot, not team: a mystery row must say nothing about who is in it.
<li
key={`slot-${index}`}
aria-label="Not revealed yet"
className="flex max-h-[20vh] min-h-0 flex-1 basis-0 items-center justify-center rounded-[1vh] border-2 border-dashed border-white/25 font-display text-[4vh] text-white/40"
>
?
</li>
) : (
<TeamRow
key={team.id}
team={team}
showScore={!hidden}
drumRoll={revealing && !revealedOnLoad.has(team.id)}
register={(element) => {
if (element) rowElements.current.set(team.id, element);
else rowElements.current.delete(team.id);
}}
/>
),
)}
</ol>
{!video && (
<ol className="flex min-h-0 flex-1 flex-col justify-center gap-[1.2vh]">
{payload.teams.length === 0 && (
<li className="self-center text-[clamp(1rem,3.2vh,2.5rem)] text-white/70">No teams yet</li>
)}
{payload.teams.map((team, index) =>
revealing && team.rank === undefined ? (
// Keyed by slot, not team: a mystery row must say nothing about who is in it.
<li
key={`slot-${index}`}
aria-label="Not revealed yet"
className="flex max-h-[20vh] min-h-0 flex-1 basis-0 items-center justify-center rounded-[1vh] border-2 border-dashed border-white/25 font-display text-[4vh] text-white/40"
>
?
</li>
) : (
<TeamRow
key={team.id}
team={team}
showScore={!hidden}
drumRoll={revealing && !revealedOnLoad.has(team.id)}
register={(element) => {
if (element) rowElements.current.set(team.id, element);
else rowElements.current.delete(team.id);
}}
/>
),
)}
</ol>
)}
<footer
className={`flex items-center justify-end gap-[2vw] text-[clamp(0.875rem,2.2vh,1.5rem)] transition-opacity duration-500 ${idle ? "opacity-70" : ""}`}
+67
View File
@@ -186,6 +186,10 @@ export type Database = {
reveal_count: number;
scoreboard_token: string;
scoreboard_visibility: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown: boolean;
screen_item_id: string | null;
screen_paused: boolean;
screen_shown_at: string | null;
timer_duration_ms: number;
timer_ends_at: string | null;
timer_remaining_ms: number | null;
@@ -204,6 +208,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown?: boolean;
screen_item_id?: string | null;
screen_paused?: boolean;
screen_shown_at?: string | null;
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
@@ -222,6 +230,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown?: boolean;
screen_item_id?: string | null;
screen_paused?: boolean;
screen_shown_at?: string | null;
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
@@ -236,6 +248,60 @@ export type Database = {
referencedRelation: "games";
referencedColumns: ["id"];
},
{
foreignKeyName: "events_screen_item_id_fkey";
columns: ["screen_item_id"];
isOneToOne: false;
referencedRelation: "game_items";
referencedColumns: ["id"];
},
];
};
game_items: {
Row: {
answer: string | null;
created_at: string;
game_id: string;
id: string;
kind: Database["public"]["Enums"]["game_item_kind"];
media_url: string | null;
played_at: string | null;
sort_order: number;
storage_path: string | null;
title: string;
};
Insert: {
answer?: string | null;
created_at?: string;
game_id: string;
id?: string;
kind: Database["public"]["Enums"]["game_item_kind"];
media_url?: string | null;
played_at?: string | null;
sort_order?: number;
storage_path?: string | null;
title: string;
};
Update: {
answer?: string | null;
created_at?: string;
game_id?: string;
id?: string;
kind?: Database["public"]["Enums"]["game_item_kind"];
media_url?: string | null;
played_at?: string | null;
sort_order?: number;
storage_path?: string | null;
title?: string;
};
Relationships: [
{
foreignKeyName: "game_items_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
];
};
games: {
@@ -464,6 +530,7 @@ export type Database = {
Enums: {
buzz_result: "correct" | "wrong";
criterion_input: "blocks" | "number";
game_item_kind: "question" | "sound" | "video";
game_status: "upcoming" | "live" | "done";
judge_source: "nominated" | "open_link";
points_mode: "raw" | "placement";
+30
View File
@@ -10,6 +10,7 @@ import {
type Standing,
} from "@/lib/scoring";
import type { Visibility } from "@/lib/scoreboard";
import { activeItem, type ItemKind } from "@/lib/screen";
import { timerState, type TimerState } from "@/lib/timer";
// The control room's data, shaped on the server so the client component holds no score math
@@ -38,6 +39,16 @@ export type LiveCell = CellResult & {
adminValue: number | null;
};
export type LiveItem = {
id: string;
kind: ItemKind;
/** The question's text, or the host's name for a sound or video. */
title: string;
answer: string | null;
/** Has been on screen at least once since the ticks were last reset. */
played: boolean;
};
export type LiveGame = {
id: string;
title: string;
@@ -50,6 +61,10 @@ export type LiveGame = {
spotlightTeamId: string | null;
/** Null when this game doesn't use buzzers. */
buzzer: BuzzerView | null;
/** Questions, sounds and videos, in list order. */
items: LiveItem[];
/** What the host has put on the scoreboard, or null. */
screen: { itemId: string; answerShown: boolean; paused: boolean } | null;
/** Judged games only; never includes judge tokens. */
judges: LiveJudge[];
criteria: LiveCriterion[];
@@ -111,6 +126,7 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView {
.map((score) => [`${score.team_id}|${score.criterion_id}`, score.value]),
);
const gameResult = result.games[game.id];
const onScreen = activeItem(event, data.items);
currentGame = {
id: game.id,
title: game.title,
@@ -122,6 +138,20 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView {
judgingLocked: game.judging_locked,
spotlightTeamId: game.spotlight_team_id,
buzzer: buzzerView(game, data.teams, data.buzzes),
items: data.items
.filter((item) => item.game_id === game.id)
.map((item) => ({
id: item.id,
kind: item.kind,
title: item.title,
answer: item.answer,
played: item.played_at !== null,
})),
screen: onScreen && {
itemId: onScreen.id,
answerShown: event.screen_answer_shown,
paused: event.screen_paused,
},
judges: data.judges
.filter((judge) => judge.game_id === game.id)
.map((judge) => ({
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
import { MEDIA_BUCKET, storagePathFor } from "@/lib/screen";
type Client = SupabaseClient<Database>;
/**
* Deletes uploaded files whose items are gone. Never fails the caller: the rows are already
* deleted, and a leftover file is only wasted space that nothing links to.
*/
export async function removeMedia(supabase: Client, paths: Array<string | null>): Promise<void> {
const files = paths.filter((path): path is string => path !== null);
if (files.length === 0) return;
const { error } = await supabase.storage.from(MEDIA_BUCKET).remove(files);
if (error) console.error("Could not delete uploaded files", error.message);
}
/**
* Copies an upload into `eventId`'s folder, so a duplicated item owns its own file and
* deleting one never breaks the other. Returns the new path, or an error message.
*/
export async function copyMedia(
supabase: Client,
path: string,
eventId: string,
): Promise<{ path: string; error: null } | { path: null; error: string }> {
const copyPath = storagePathFor(eventId, path, crypto.randomUUID());
if (!copyPath) return { path: null, error: "an uploaded file has an unexpected name" };
const { error } = await supabase.storage.from(MEDIA_BUCKET).copy(path, copyPath);
return error ? { path: null, error: error.message } : { path: copyPath, error: null };
}
+4 -2
View File
@@ -7,6 +7,7 @@ type Client = SupabaseClient<Database>;
export type GameWithCriteria = Tables<"games"> & {
criteria: Tables<"criteria">[];
judges: Tables<"judges">[];
game_items: Tables<"game_items">[];
};
export type EventSetup = {
@@ -27,12 +28,13 @@ export async function loadEventSetup(supabase: Client, eventId: string): Promise
.order("created_at"),
supabase
.from("games")
.select("*, criteria(*), judges(*)")
.select("*, criteria(*), judges(*), game_items(*)")
.eq("event_id", eventId)
.order("sort_order")
.order("created_at")
.order("sort_order", { referencedTable: "criteria" })
.order("created_at", { referencedTable: "judges" }),
.order("created_at", { referencedTable: "judges" })
.order("sort_order", { referencedTable: "game_items" }),
]);
const error = event.error ?? teams.error ?? games.error;
+11 -2
View File
@@ -15,6 +15,8 @@ export type EventData = {
adjustments: Tables<"adjustments">[];
/** The current game's buzzes only, in buzz order. Not scoring data. */
buzzes: Tables<"buzzes">[];
/** The current game's questions, sounds and videos only, in list order. Not scoring data. */
items: Tables<"game_items">[];
};
/** One game's buzzes, first buzz first. The id breaks an exact tie the same way every time. */
@@ -22,6 +24,11 @@ export function loadBuzzes(supabase: Client, gameId: string) {
return supabase.from("buzzes").select("*").eq("game_id", gameId).order("buzzed_at").order("id");
}
/** One game's questions, sounds and videos, in the host's order. */
export function loadItems(supabase: Client, gameId: string) {
return supabase.from("game_items").select("*").eq("game_id", gameId).order("sort_order").order("created_at");
}
const PAGE_SIZE = 1000;
// `games!inner()` joins through games purely to filter by event, so child rows load in
@@ -51,7 +58,7 @@ async function loadAllScores(supabase: Client, eventId: string): Promise<Tables<
* after the token has been validated).
*/
export async function loadEventData(supabase: Client, event: Tables<"events">): Promise<EventData> {
const [teams, games, adjustments, criteria, judges, buzzes, scores] = await Promise.all([
const [teams, games, adjustments, criteria, judges, buzzes, items, scores] = await Promise.all([
supabase.from("teams").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("games").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("adjustments").select("*").eq("event_id", event.id).order("created_at"),
@@ -68,10 +75,11 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
.order("created_at")
.overrideTypes<Tables<"judges">[], { merge: false }>(),
event.current_game_id ? loadBuzzes(supabase, event.current_game_id) : { data: [], error: null },
event.current_game_id ? loadItems(supabase, event.current_game_id) : { data: [], error: null },
loadAllScores(supabase, event.id),
]);
const error =
teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error ?? buzzes.error;
teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error ?? buzzes.error ?? items.error;
if (error) throw new Error(`Could not load the event: ${error.message}`);
return {
@@ -82,6 +90,7 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
criteria: criteria.data ?? [],
judges: judges.data ?? [],
buzzes: buzzes.data ?? [],
items: items.data ?? [],
scores,
};
}
+9
View File
@@ -0,0 +1,9 @@
/** Returns the row order after moving `id` one place up or down. */
export function moved<T extends { id: string }>(rows: T[], id: string, direction: "up" | "down"): T[] {
const from = rows.findIndex((row) => row.id === id);
const to = direction === "up" ? from - 1 : from + 1;
if (from < 0 || to < 0 || to >= rows.length) return rows;
const next = [...rows];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
+1
View File
@@ -19,6 +19,7 @@ const payload = (overrides: Partial<ScoreboardPayload> = {}): ScoreboardPayload
teams: [team("a", 1), team("b", 2), team("c", 3)],
timer: null,
buzzer: null,
onScreen: null,
updatedAt: "2026-10-03T12:00:00.000Z",
...overrides,
});
+29 -2
View File
@@ -5,10 +5,35 @@ import type { Tables } from "@/lib/database.types";
import { loadEventData, toScoringInput, type EventData } from "@/lib/queries/scoring";
import { computeEvent } from "@/lib/scoring";
import { buildScoreboardPayload, type ScoreboardInput, type ScoreboardPayload } from "@/lib/scoreboard";
import { activeItem, buildOnScreen, MEDIA_BUCKET, type OnScreen } from "@/lib/screen";
import { createAdminClient } from "@/lib/supabase/admin";
import { scoreboardTokenSchema } from "@/lib/tokens";
export function toScoreboardInput(data: EventData): ScoreboardInput {
// Long enough for a whole game night: a screen keeps the URL it got when the item went up.
const SIGNED_URL_SECONDS = 12 * 60 * 60;
/**
* The item on screen, with a temporary URL for its upload if it has one. The bucket is
* private, so this is the only way a file reaches the room — and only while it is on screen.
*/
async function loadOnScreen(
supabase: ReturnType<typeof createAdminClient>,
data: EventData,
): Promise<OnScreen | null> {
const item = activeItem(data.event, data.items);
if (!item) return null;
let signedUrl: string | null = null;
if (item.storage_path) {
const signed = await supabase.storage.from(MEDIA_BUCKET).createSignedUrl(item.storage_path, SIGNED_URL_SECONDS);
// The scores must still load; buildOnScreen leaves out media it has no URL for.
if (signed.error) console.error("Could not sign the on-screen file", signed.error.message);
signedUrl = signed.data?.signedUrl ?? null;
}
return buildOnScreen(data.event, item, signedUrl);
}
export function toScoreboardInput(data: EventData, onScreen: OnScreen | null = null): ScoreboardInput {
const { event } = data;
return {
event: {
@@ -28,6 +53,7 @@ export function toScoreboardInput(data: EventData): ScoreboardInput {
data.teams,
data.buzzes,
),
onScreen,
now: new Date().toISOString(),
};
}
@@ -49,5 +75,6 @@ export async function getScoreboardPayload(token: string): Promise<ScoreboardPay
if (error) throw new Error(`Could not load the scoreboard: ${error.message}`);
if (!event) return null;
return buildScoreboardPayload(toScoreboardInput(await loadEventData(supabase, event)));
const data = await loadEventData(supabase, event);
return buildScoreboardPayload(toScoreboardInput(data, await loadOnScreen(supabase, data)));
}
+24
View File
@@ -43,6 +43,7 @@ const input = (event: Partial<ScoreboardInput["event"]> = {}): ScoreboardInput =
standing("sharks", 4, 5),
],
buzzer: null,
onScreen: null,
now: "2026-10-03T12:00:00.000Z",
});
@@ -201,3 +202,26 @@ describe("buzzer", () => {
}
});
});
describe("on screen", () => {
const onScreen = {
kind: "question" as const,
id: "i1",
shownAt: "2026-10-03T11:59:00.000Z",
text: "Who built the ark?",
imageUrl: null,
answer: null,
};
it("is null with nothing on screen", () => {
expect(buildScoreboardPayload(input()).onScreen).toBeNull();
});
it("is sent whatever the score visibility, without adding any scores", () => {
for (const visibility of ["visible", "frozen", "hidden"] as const) {
const payload = buildScoreboardPayload({ ...input({ visibility }), onScreen });
expect(payload.onScreen).toEqual(onScreen);
if (visibility !== "visible") expect(hasNoScores(payload.teams)).toBe(true);
}
});
});
+6
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { BuzzerView } from "@/lib/buzzer";
import type { Standing } from "@/lib/scoring";
import type { OnScreen } from "@/lib/screen";
import { timerState, type TimerRow, type TimerState } from "@/lib/timer";
// The ONE place that decides what the public scoreboard may see (CLAUDE.md rule 3).
@@ -28,6 +29,8 @@ export type ScoreboardPayload = {
timer: TimerState | null;
/** Null unless the current game uses buzzers. Not a score either, so it is always live. */
buzzer: BuzzerView | null;
/** The question, sound or video the host has put on screen. Not a score, so always live. */
onScreen: OnScreen | null;
/** The server's clock when this was built; screens use it to correct their own clocks. */
updatedAt: string;
};
@@ -49,6 +52,8 @@ export type ScoreboardInput = {
standings: Standing[];
/** The current game's buzzer state (see buzzerView), or null when it has no buzzers. */
buzzer: BuzzerView | null;
/** Already sanitised (see buildOnScreen): no hidden answer, no sound or video name. */
onScreen: OnScreen | null;
now: string;
};
@@ -134,6 +139,7 @@ export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayloa
teams: teamsFor(input),
timer: event.timer.timer_visible ? timerState(event.timer, Date.parse(input.now)) : null,
buzzer: input.buzzer,
onScreen: input.onScreen,
updatedAt: input.now,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
activeItem,
buildOnScreen,
isHttpsUrl,
isStoragePathOf,
MAX_UPLOAD_BYTES,
nextQuestion,
parseYouTube,
pickRandom,
storagePathFor,
uploadProblem,
type ItemKind,
} from "./screen";
const EVENT = "11111111-1111-4111-8111-111111111111";
const FILE = "22222222-2222-4222-8222-222222222222";
describe("parseYouTube", () => {
it.each([
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtube.com/watch?feature=share&v=dQw4w9WgXcQ",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
"https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=abc",
"https://youtu.be/dQw4w9WgXcQ?si=xyz",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/live/dQw4w9WgXcQ",
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
])("finds the video id in %s", (link) => {
expect(parseYouTube(link)).toEqual({ videoId: "dQw4w9WgXcQ", startSeconds: 0 });
});
it("reads a start time in seconds or as 1m30s", () => {
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=90")?.startSeconds).toBe(90);
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=90s")?.startSeconds).toBe(90);
expect(parseYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s")?.startSeconds).toBe(90);
expect(parseYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s")?.startSeconds).toBe(3723);
expect(parseYouTube("https://www.youtube.com/embed/dQw4w9WgXcQ?start=12")?.startSeconds).toBe(12);
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=soon")?.startSeconds).toBe(0);
});
it.each([
"not a link",
"https://example.com/watch?v=dQw4w9WgXcQ",
"https://notyoutube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=short",
"https://www.youtube.com/playlist?list=PL123",
"https://www.youtube.com/",
"https://example.com/song.mp3",
])("is null for %s", (link) => {
expect(parseYouTube(link)).toBeNull();
});
});
describe("links and uploads", () => {
it("accepts https links only", () => {
expect(isHttpsUrl("https://example.com/a.mp3")).toBe(true);
expect(isHttpsUrl("http://example.com/a.mp3")).toBe(false);
expect(isHttpsUrl("javascript:alert(1)")).toBe(false);
expect(isHttpsUrl("example.com")).toBe(false);
});
it("builds a storage path inside the event's folder", () => {
expect(storagePathFor(EVENT, "My Song (final).MP3", FILE)).toBe(`${EVENT}/${FILE}.mp3`);
expect(storagePathFor(EVENT, "no-extension", FILE)).toBeNull();
expect(storagePathFor(EVENT, "weird.extension-name", FILE)).toBeNull();
});
it("only trusts a path in this event's own folder", () => {
expect(isStoragePathOf(EVENT, `${EVENT}/${FILE}.mp4`)).toBe(true);
expect(isStoragePathOf(FILE, `${EVENT}/${FILE}.mp4`)).toBe(false);
expect(isStoragePathOf(EVENT, `${EVENT}/../${FILE}.mp4`)).toBe(false);
expect(isStoragePathOf(EVENT, `${EVENT}/${FILE}`)).toBe(false);
});
it("checks the file type per kind, and the size", () => {
const file = (type: string, size = 1000, name = "clip.bin") => ({ name, type, size });
expect(uploadProblem("sound", file("audio/mpeg"))).toBeNull();
expect(uploadProblem("video", file("video/mp4"))).toBeNull();
expect(uploadProblem("question", file("image/png"))).toBeNull();
expect(uploadProblem("sound", file("video/mp4"))).toBe("Choose an audio file.");
expect(uploadProblem("video", file("image/png"))).toBe("Choose a video file.");
expect(uploadProblem("question", file(""))).toBe("Choose an image.");
expect(uploadProblem("video", file("video/mp4", MAX_UPLOAD_BYTES + 1))).toMatch(/over 50 MB/);
expect(uploadProblem("video", file("video/mp4", MAX_UPLOAD_BYTES))).toBeNull();
expect(uploadProblem("video", file("video/mp4", 1000, "clip"))).toMatch(/extension/);
});
});
const item = (overrides: Partial<Parameters<typeof buildOnScreen>[1] & object> = {}) => ({
id: "i1",
game_id: "g1",
kind: "question" as ItemKind,
title: "Who built the ark?",
answer: "Noah" as string | null,
media_url: null as string | null,
storage_path: null as string | null,
...overrides,
});
const screen = (overrides: Partial<Parameters<typeof buildOnScreen>[0]> = {}) => ({
current_game_id: "g1",
screen_item_id: "i1",
screen_shown_at: "2026-10-05T19:00:00.000Z",
screen_answer_shown: false,
screen_paused: false,
...overrides,
});
describe("activeItem", () => {
const items = [item(), item({ id: "i2", game_id: "g2" })];
it("is the item the event points at", () => {
expect(activeItem(screen(), items)?.id).toBe("i1");
});
it("is nothing when the screen is clear, the item is gone, or there is no current game", () => {
expect(activeItem(screen({ screen_item_id: null }), items)).toBeNull();
expect(activeItem(screen({ screen_item_id: "deleted" }), items)).toBeNull();
expect(activeItem(screen({ current_game_id: null }), items)).toBeNull();
});
it("ignores an item from a game that isn't the current one", () => {
expect(activeItem(screen({ screen_item_id: "i2" }), items)).toBeNull();
});
});
describe("buildOnScreen", () => {
it("is null with nothing on screen", () => {
expect(buildOnScreen(screen(), null, null)).toBeNull();
});
it("sends a question without its answer until the host reveals it", () => {
const hidden = buildOnScreen(screen(), item(), null);
expect(hidden).toEqual({
kind: "question",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
text: "Who built the ark?",
imageUrl: null,
answer: null,
});
expect(JSON.stringify(hidden)).not.toContain("Noah");
expect(buildOnScreen(screen({ screen_answer_shown: true }), item(), null)).toMatchObject({ answer: "Noah" });
});
it("has no answer to reveal when the question has none", () => {
expect(buildOnScreen(screen({ screen_answer_shown: true }), item({ answer: null }), null)).toMatchObject({
answer: null,
});
});
it("gives a question its picture from a link or an upload, but never a YouTube player", () => {
expect(buildOnScreen(screen(), item({ media_url: "https://example.com/ark.png" }), null)).toMatchObject({
imageUrl: "https://example.com/ark.png",
});
expect(buildOnScreen(screen(), item({ storage_path: "e/f.png" }), "https://signed/ark")).toMatchObject({
imageUrl: "https://signed/ark",
});
expect(buildOnScreen(screen(), item({ media_url: "https://youtu.be/dQw4w9WgXcQ" }), null)).toMatchObject({
imageUrl: null,
});
// An upload that couldn't be signed: the question still shows, without the picture.
expect(buildOnScreen(screen(), item({ storage_path: "e/f.png" }), null)).toMatchObject({ imageUrl: null });
});
it("never sends a sound's or video's name, which would give the answer away", () => {
const sound = item({ kind: "sound", title: "Amazing Grace", answer: "Amazing Grace", media_url: "https://example.com/a.mp3" });
const video = item({ kind: "video", title: "Oceans karaoke", answer: null, media_url: "https://youtu.be/dQw4w9WgXcQ?t=5" });
const soundOnScreen = buildOnScreen(screen({ screen_paused: true }), sound, null);
expect(soundOnScreen).toEqual({
kind: "sound",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
media: { type: "file", url: "https://example.com/a.mp3" },
paused: true,
answer: null,
});
expect(JSON.stringify(soundOnScreen)).not.toContain("Amazing");
expect(buildOnScreen(screen({ screen_answer_shown: true }), sound, null)).toMatchObject({ answer: "Amazing Grace" });
const videoOnScreen = buildOnScreen(screen(), video, null);
expect(videoOnScreen).toEqual({
kind: "video",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
media: { type: "youtube", videoId: "dQw4w9WgXcQ", startSeconds: 5 },
paused: false,
});
expect(JSON.stringify(videoOnScreen)).not.toContain("Oceans");
});
it("plays an upload from its signed URL, and shows nothing if it couldn't be signed", () => {
const upload = item({ kind: "video", storage_path: "e/f.mp4" });
expect(buildOnScreen(screen(), upload, "https://signed/clip")).toMatchObject({
media: { type: "file", url: "https://signed/clip" },
});
expect(buildOnScreen(screen(), upload, null)).toBeNull();
expect(buildOnScreen(screen(), item({ kind: "sound" }), null)).toBeNull();
});
});
describe("pickRandom", () => {
const song = (id: string, played = false, kind: ItemKind = "video") => ({
id,
kind,
played_at: played ? "2026-10-05T19:00:00.000Z" : null,
});
const first = () => 0;
const last = () => 0.999;
it("picks among the items of that kind only", () => {
const items = [song("q", false, "question"), song("a"), song("b")];
expect(pickRandom(items, "video", null, first)?.id).toBe("a");
expect(pickRandom(items, "video", null, last)?.id).toBe("b");
expect(pickRandom(items, "sound", null)).toBeNull();
});
it("prefers songs that haven't been played", () => {
const items = [song("a", true), song("b"), song("c", true)];
expect(pickRandom(items, "video", null, first)?.id).toBe("b");
expect(pickRandom(items, "video", null, last)?.id).toBe("b");
});
it("starts over once every song has been played, without repeating the one on screen", () => {
const items = [song("a", true), song("b", true), song("c", true)];
expect(pickRandom(items, "video", "a", first)?.id).toBe("b");
expect(pickRandom(items, "video", "c", last)?.id).toBe("b");
});
it("repeats the song on screen only when it is the only one", () => {
expect(pickRandom([song("a", true)], "video", "a")?.id).toBe("a");
});
});
describe("nextQuestion", () => {
const entry = (id: string, played = false, kind: ItemKind = "question") => ({ id, kind, played });
const items = [entry("q1", true), entry("v1", false, "video"), entry("q2"), entry("q3")];
it("follows the list from the question on screen, skipping other kinds", () => {
expect(nextQuestion(items, "q1")?.id).toBe("q2");
expect(nextQuestion(items, "q2")?.id).toBe("q3");
expect(nextQuestion(items, "q3")).toBeNull();
});
it("starts at the first unplayed question when none is on screen", () => {
expect(nextQuestion(items, null)?.id).toBe("q2");
expect(nextQuestion(items, "v1")?.id).toBe("q2");
expect(nextQuestion([entry("q1", true)], null)).toBeNull();
});
});
+192
View File
@@ -0,0 +1,192 @@
// Types and rules for what a game puts on the scoreboard: questions, sounds and videos.
// Shared by the setup page, the control room and the scoreboard API. Pure: no DB access.
export const ITEM_KINDS = ["question", "sound", "video"] as const;
export type ItemKind = (typeof ITEM_KINDS)[number];
export const MEDIA_BUCKET = "game-media";
/** Mirrors the bucket's file_size_limit in 0006_screen_items.sql. */
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
/** What an item of each kind may upload. A question's file is its picture. */
const UPLOAD_TYPE: Record<ItemKind, { prefix: string; what: string }> = {
question: { prefix: "image/", what: "an image" },
sound: { prefix: "audio/", what: "an audio file" },
video: { prefix: "video/", what: "a video file" },
};
export const uploadAccept = (kind: ItemKind) => `${UPLOAD_TYPE[kind].prefix}*`;
/** Why this file can't be uploaded for this kind of item, or null when it can. */
export function uploadProblem(kind: ItemKind, file: { name: string; type: string; size: number }): string | null {
const { prefix, what } = UPLOAD_TYPE[kind];
if (!file.type.startsWith(prefix)) return `Choose ${what}.`;
if (file.size > MAX_UPLOAD_BYTES) return "That file is over 50 MB. Use a smaller one, or a link instead.";
if (!fileExtension(file.name)) return "That file's name has no usable extension, like .mp3 or .mp4.";
return null;
}
function fileExtension(name: string): string | null {
return /\.([a-z0-9]{1,5})$/.exec(name.toLowerCase())?.[1] ?? null;
}
/** Where an upload lives in the bucket: "{eventId}/{uuid}.{ext}". Null without a usable extension. */
export function storagePathFor(eventId: string, fileName: string, uuid: string): string | null {
const extension = fileExtension(fileName);
return extension ? `${eventId}/${uuid}.${extension}` : null;
}
const STORAGE_PATH = /^[0-9a-f-]{36}\/[0-9a-f-]{36}\.[a-z0-9]{1,5}$/;
/** Is this a well-formed upload path inside this event's own folder? */
export function isStoragePathOf(eventId: string, path: string): boolean {
return STORAGE_PATH.test(path) && path.startsWith(`${eventId}/`);
}
/** Links must be https: the scoreboard is served over https and would block anything else. */
export function isHttpsUrl(value: string): boolean {
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
/** "90", "90s" or "1m30s" → seconds; anything else → 0. */
function startSeconds(value: string | null): number {
if (!value) return 0;
if (/^\d+s?$/.test(value)) return parseInt(value, 10);
const parts = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
if (!parts) return 0;
return Number(parts[1] ?? 0) * 3600 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0);
}
/** The video id (and start time, if the link has one) of a YouTube link; null for any other link. */
export function parseYouTube(link: string): { videoId: string; startSeconds: number } | null {
let url: URL;
try {
url = new URL(link);
} catch {
return null;
}
const host = url.hostname.replace(/^(www|m|music)\./, "");
const [first, second] = url.pathname.split("/").filter(Boolean);
let videoId: string | null | undefined;
if (host === "youtu.be") videoId = first;
else if (host === "youtube.com" || host === "youtube-nocookie.com") {
if (first === "watch") videoId = url.searchParams.get("v");
else if (first === "embed" || first === "shorts" || first === "live") videoId = second;
}
if (!videoId || !YOUTUBE_ID.test(videoId)) return null;
return { videoId, startSeconds: startSeconds(url.searchParams.get("t") ?? url.searchParams.get("start")) };
}
// ---------- what the scoreboard gets ----------
export type ScreenMedia =
| { type: "youtube"; videoId: string; startSeconds: number }
/** A link to a file, or a signed URL for an upload. */
| { type: "file"; url: string };
/**
* The one item on screen. A sound's or video's name is never part of this, and an answer only
* once the host has revealed it: the room can read the scoreboard's network tab.
*/
export type OnScreen =
| { kind: "question"; id: string; shownAt: string; text: string; imageUrl: string | null; answer: string | null }
| { kind: "sound"; id: string; shownAt: string; media: ScreenMedia; paused: boolean; answer: string | null }
| { kind: "video"; id: string; shownAt: string; media: ScreenMedia; paused: boolean };
type ScreenFacts = {
current_game_id: string | null;
screen_item_id: string | null;
screen_shown_at: string | null;
screen_answer_shown: boolean;
screen_paused: boolean;
};
type ItemFacts = {
id: string;
game_id: string;
kind: ItemKind;
title: string;
answer: string | null;
media_url: string | null;
storage_path: string | null;
};
/** The item on screen, or null. An item only counts while its game is the current game. */
export function activeItem<Item extends { id: string; game_id: string }>(
event: Pick<ScreenFacts, "current_game_id" | "screen_item_id">,
items: Item[],
): Item | null {
if (!event.screen_item_id || !event.current_game_id) return null;
return (
items.find((item) => item.id === event.screen_item_id && item.game_id === event.current_game_id) ?? null
);
}
/**
* The scoreboard's view of the item on screen. `signedUrl` is the temporary URL for the item's
* upload, if it has one. A sound or video whose media can't be resolved is not shown at all.
*/
export function buildOnScreen(event: ScreenFacts, item: ItemFacts | null, signedUrl: string | null): OnScreen | null {
if (!item) return null;
let media: ScreenMedia | null = null;
if (item.storage_path) media = signedUrl ? { type: "file", url: signedUrl } : null;
else if (item.media_url) {
const youTube = parseYouTube(item.media_url);
media = youTube ? { type: "youtube", ...youTube } : { type: "file", url: item.media_url };
}
const shown = { id: item.id, shownAt: event.screen_shown_at ?? "" };
const answer = event.screen_answer_shown ? item.answer : null;
if (item.kind === "question") {
return { kind: "question", ...shown, text: item.title, imageUrl: media?.type === "file" ? media.url : null, answer };
}
if (!media) return null;
if (item.kind === "sound") return { kind: "sound", ...shown, media, paused: event.screen_paused, answer };
return { kind: "video", ...shown, media, paused: event.screen_paused };
}
// ---------- choosing what to show ----------
type Pickable = { id: string; kind: ItemKind; played_at: string | null };
/**
* A random item of this kind for "pick one for me": one that hasn't been played yet while
* there are any, and never the one already on screen unless it is the only one.
* `random` is Math.random's contract: a number in [0, 1).
*/
export function pickRandom<Item extends Pickable>(
items: Item[],
kind: ItemKind,
onScreenId: string | null,
random: () => number = Math.random,
): Item | null {
const ofKind = items.filter((item) => item.kind === kind);
const others = ofKind.filter((item) => item.id !== onScreenId);
const unplayed = others.filter((item) => item.played_at === null);
const pool = unplayed.length ? unplayed : others.length ? others : ofKind;
return pool[Math.floor(random() * pool.length)] ?? null;
}
/**
* The question to show next, in list order: the one after the question on screen, or — with
* no question on screen — the first unplayed one. Null at the end of the list.
*/
export function nextQuestion<Item extends { id: string; kind: ItemKind; played: boolean }>(
items: Item[],
onScreenId: string | null,
): Item | null {
const questions = items.filter((item) => item.kind === "question");
const current = questions.findIndex((item) => item.id === onScreenId);
if (current >= 0) return questions[current + 1] ?? null;
return questions.find((item) => !item.played) ?? null;
}
+54 -1
View File
@@ -2,7 +2,15 @@ import { describe, expect, it } from "vitest";
import { criteriaPresets } from "@/lib/presets";
import { cohostEmailSchema, criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
import {
cohostEmailSchema,
criterionSchema,
gameSchema,
itemSchema,
parsePlacementPoints,
type GameDraft,
type ItemDraft,
} from "./admin";
const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const;
@@ -84,3 +92,48 @@ describe("parsePlacementPoints", () => {
expect(parsePlacementPoints("10, x")).toEqual([10, Number.NaN]);
});
});
describe("itemSchema", () => {
const item = (overrides: Partial<ItemDraft> = {}): ItemDraft => ({
kind: "question",
title: "Who built the ark?",
answer: "",
mediaUrl: "",
storagePath: null,
...overrides,
});
const itemMessage = (overrides: Partial<ItemDraft>) => {
const result = itemSchema.safeParse(item(overrides));
return result.success ? null : result.error.issues[0].message;
};
it("accepts a bare question, and one with a picture", () => {
expect(itemMessage({})).toBeNull();
expect(itemMessage({ answer: "Noah", mediaUrl: " https://example.com/ark.png " })).toBeNull();
expect(itemMessage({ storagePath: "event/file.png" })).toBeNull();
});
it("needs a question or a name", () => {
expect(itemMessage({ title: " " })).toBe("Type the question.");
expect(itemMessage({ kind: "video", title: "", mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toMatch(/Give it a name/);
});
it("needs something to play for a sound or a video", () => {
expect(itemMessage({ kind: "sound" })).toBe("Paste a link or choose a file for this sound.");
expect(itemMessage({ kind: "video" })).toBe("Paste a link or choose a file for this video.");
expect(itemMessage({ kind: "sound", storagePath: "event/file.mp3" })).toBeNull();
expect(itemMessage({ kind: "video", mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toBeNull();
});
it("takes https links only, and a link or a file but not both", () => {
expect(itemMessage({ kind: "sound", mediaUrl: "http://example.com/a.mp3" })).toMatch(/https/);
expect(itemMessage({ kind: "sound", mediaUrl: "a.mp3" })).toMatch(/https/);
expect(itemMessage({ kind: "sound", mediaUrl: "https://example.com/a.mp3", storagePath: "event/file.mp3" })).toBe(
"Use a link or a file, not both.",
);
});
it("won't use a YouTube link as a question's picture", () => {
expect(itemMessage({ mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toMatch(/must be an image/);
});
});
+29
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { isHttpsUrl, ITEM_KINDS, parseYouTube } from "@/lib/screen";
import { idSchema } from "@/lib/tokens";
// Limits mirror the CHECK constraints in supabase/migrations/0001_init.sql so that
@@ -91,6 +92,34 @@ export const gameSchema = z.object({
criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20),
});
export const MAX_ITEMS_PER_GAME = 200;
/** A question, sound or video for a game. Media is a link or an uploaded file's path, never both. */
export const itemSchema = z
.object({
kind: z.enum(ITEM_KINDS),
title: optionalText(500, "The text"),
answer: optionalText(500, "The answer"),
mediaUrl: z.string().trim().max(2000, "That link is too long."),
storagePath: z.string().nullable(),
})
.superRefine((item, context) => {
const issue = (message: string) => context.addIssue({ code: "custom", message });
if (!item.title) {
return issue(item.kind === "question" ? "Type the question." : "Give it a name, so you can find it in the list.");
}
if (item.mediaUrl && item.storagePath) return issue("Use a link or a file, not both.");
if (item.mediaUrl && !isHttpsUrl(item.mediaUrl)) return issue("The link must start with https://");
if (item.kind === "question") {
if (item.mediaUrl && parseYouTube(item.mediaUrl)) {
issue("A question's picture must be an image, not a YouTube link. Add the video as its own item.");
}
} else if (!item.mediaUrl && !item.storagePath) {
issue(`Paste a link or choose a file for this ${item.kind}.`);
}
});
export type ItemDraft = z.input<typeof itemSchema>;
export type CriterionDraft = z.input<typeof criterionSchema>;
export type GameDraft = z.input<typeof gameSchema>;
+74
View File
@@ -0,0 +1,74 @@
-- Game Night Live — things a game can put on the scoreboard: questions, sounds and videos.
-- Run after 0005_cohosts.sql.
--
-- A game has an ordered list of items. The host puts one on screen at a time from the control
-- room; which one is a column on the event, like the timer. A question shows above the
-- standings, a sound plays through the scoreboard's speakers, a video (karaoke) takes over
-- the screen.
--
-- Media is either a link (YouTube, or a file hosted elsewhere) or a file uploaded to the
-- private "game-media" bucket. The scoreboard never reads the bucket itself: its API hands out
-- a short-lived signed URL, and only for the item that is on screen.
create type game_item_kind as enum ('question', 'sound', 'video');
create table game_items (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
kind game_item_kind not null,
-- The question's text, or the host's name for a sound or video (never sent to the scoreboard).
title text not null check (length(title) between 1 and 500),
-- Secret until the host reveals it.
answer text check (length(answer) between 1 and 500),
media_url text check (media_url ~ '^https://' and length(media_url) <= 2000),
-- "{event id}/{random uuid}.{ext}" in the game-media bucket.
storage_path text unique check (storage_path ~ '^[0-9a-f-]{36}/[0-9a-f-]{36}\.[a-z0-9]{1,5}$'),
sort_order int not null default 0,
-- Last time it went on screen; "pick a random one" prefers items without it.
played_at timestamptz,
created_at timestamptz not null default now(),
constraint game_items_one_source check (media_url is null or storage_path is null),
constraint game_items_media_needed
check (kind = 'question' or media_url is not null or storage_path is not null)
);
create index on game_items(game_id, sort_order);
-- What is on the scoreboard right now. Only counts while the item's game is the current game.
alter table events
add column screen_item_id uuid references game_items(id) on delete set null,
-- Changes every time an item is put on screen, so showing the same one again restarts it.
add column screen_shown_at timestamptz,
add column screen_answer_shown boolean not null default false,
add column screen_paused boolean not null default false;
-- ---------- RLS ----------
-- Same rule as every other table: the event's admins only, anon has no policies.
alter table game_items enable row level security;
create policy admin_all on game_items for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
-- ---------- storage ----------
-- Private bucket, 50 MB per file (the free plan's ceiling).
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('game-media', 'game-media', false, 52428800, array['audio/*', 'video/*', 'image/*'])
on conflict (id) do nothing;
-- The event a file belongs to, from the first folder of its path; null for any other shape.
create or replace function media_event(object_name text) returns uuid
language sql immutable as $$
select case
when object_name ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/'
then left(object_name, 36)::uuid
end
$$;
-- Admins upload from the browser with their own session. Still nothing for anon.
create policy game_media_read on storage.objects for select to authenticated
using (bucket_id = 'game-media' and is_event_admin(media_event(name)));
create policy game_media_insert on storage.objects for insert to authenticated
with check (bucket_id = 'game-media' and is_event_admin(media_event(name)));
create policy game_media_delete on storage.objects for delete to authenticated
using (bucket_id = 'game-media' and is_event_admin(media_event(name)));