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:
+34
-1
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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)));
|
||||
Reference in New Issue
Block a user