Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825eb14f2e | ||
|
|
c27a3890f8 | ||
|
|
186a112b95 | ||
|
|
bbcf84bbc3 | ||
|
|
48cecfff7f |
+73
-7
@@ -8,7 +8,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|
||||
|---|---|---|
|
||||
| `/login` | admin | Supabase magic link |
|
||||
| `/admin` | admin | Events list |
|
||||
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links |
|
||||
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links, co-hosts |
|
||||
| `/admin/events/[eventId]/live` | admin | Control room |
|
||||
| `/s/[scoreboardToken]` | public | Scoreboard |
|
||||
| `/j/[judgeToken]` | judge | Personal judge page |
|
||||
@@ -17,28 +17,38 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|
||||
| `GET /api/judge/[token]` | judge | Game, criteria, teams, this judge's scores |
|
||||
| `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
|
||||
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` |
|
||||
| `/b/[buzzerToken]` | team | Team buzzer page |
|
||||
| `GET /api/buzzer/[token]` | team | Team, current game, buzz order |
|
||||
| `POST /api/buzzer/[token]/buzz` | team | Buzz in (no body) → the same payload |
|
||||
|
||||
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.
|
||||
|
||||
## Security model
|
||||
- RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access.
|
||||
- RLS ON for every table. Policies only for the authenticated event owner and the co-hosts the owner shared it with. Anon role has no policies → no direct table access.
|
||||
- Co-hosts are rows in `event_cohosts`, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in `ADMIN_EMAILS`, since that person couldn't log in.
|
||||
- Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
|
||||
- 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`.
|
||||
- Light rate limiting on judge PUT (20 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.
|
||||
- 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")
|
||||
Channels (names use tokens/ids; payload is always just `{ type: "refresh", scope }`):
|
||||
- `sb:{scoreboardToken}` — scoreboard listens
|
||||
- `game:{gameId}` — all judges of that game listen (pinged for lock, spotlight, revoke and game edits — not for other judges' scores, which they never see)
|
||||
- `admin:{eventId}` — admin control room listens (judge submissions land here)
|
||||
- `admin:{eventId}` — admin control room listens (judge submissions and buzzes land here)
|
||||
- `buzzer:{eventId}` — team buzzer pages listen (pinged for buzzes, clears, game switches and edits to games/teams — not for scores)
|
||||
|
||||
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.
|
||||
`src/lib/realtime.ts` exposes `notify({ eventId, gameId?, buzzer? })` which looks up the scoreboard token and sends to the right channels from the server. `buzzer: true` adds the buzzer channel; score saves leave it out so they don't cost an extra request.
|
||||
Use Supabase's server-side broadcast (REST endpoint / `httpSend` — check current docs).
|
||||
Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches.
|
||||
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
|
||||
@@ -66,9 +76,27 @@ type ScoreboardPayload = {
|
||||
endsAt: string | null; // set while running
|
||||
durationMs: number;
|
||||
} | null;
|
||||
// null unless the current game uses buzzers; sent regardless of score visibility
|
||||
buzzer: {
|
||||
open: boolean; // false once a team got it, or the game is done
|
||||
buzzes: Array<{ // in buzz order
|
||||
teamId: string; name: string; color: string; afterFirstMs: number;
|
||||
result: 'correct' | 'wrong' | null; // the host's mark
|
||||
}>;
|
||||
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.
|
||||
|
||||
@@ -80,11 +108,49 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
|
||||
change triggers is decided by pure functions in `src/lib/scoreboard-sounds.ts`; the
|
||||
scoreboard compares each new payload with the previous one. No sound data travels over Realtime.
|
||||
|
||||
## Buzzers
|
||||
- A buzz is a row in `buzzes`, stamped by the database's clock (`clock_timestamp()`), so the order
|
||||
doesn't depend on phone clocks or on which server instance took the request. Network delay
|
||||
between a thumb and the database still counts — fair on shared Wi-Fi, not tournament-grade.
|
||||
- `src/lib/buzzer.ts` holds the rules as pure functions (`buzzerView` builds the same
|
||||
`{ open, buzzes }` for the team page, the control room and the scoreboard);
|
||||
`src/lib/buzzer.server.ts` does the token-gated reads and the write.
|
||||
- Buzzes follow rule 4 like everything else: the ping carries no data and each screen refetches.
|
||||
The buzzing phone gets its result in the POST response, so it doesn't wait for the ping.
|
||||
- Clearing = deleting the current game's rows (admin server action).
|
||||
- Marking an answer is the `markBuzz` server action. `checkMark` decides whether the mark is
|
||||
allowed and whether a point moves (+1 for a new "correct", −1 when one is undone);
|
||||
`bumpScore` gives the new cell value. In a direct game the point is written to the admin's
|
||||
score row for the game's first criterion — an ordinary score, so the scoring engine turns it
|
||||
into leaderboard points like any other and the `validate_score` trigger still guards range
|
||||
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/
|
||||
app/ (routes above)
|
||||
components/ (ui/, scoreboard/, judge/, admin/)
|
||||
components/ (ui/, scoreboard/, judge/, buzzer/, admin/)
|
||||
lib/
|
||||
scoring/ (index.ts, *.test.ts)
|
||||
supabase/ (browser.ts, server.ts, admin.ts)
|
||||
|
||||
+36
-1
@@ -65,5 +65,40 @@ human a short "how to test this manually" checklist. Don't start the next phase
|
||||
- Scoreboard sound effects: reveal drum-roll + crash/fanfare, game-change chime, last-10-seconds ticks + buzzer; "Turn sound on" button.
|
||||
**Done when:** a timer started in the control room counts down on the scoreboard with ticks and a buzzer, and a reveal plays its drum-roll.
|
||||
|
||||
## Phase 10 — Team buzzers
|
||||
- Apply `supabase/migrations/0003_buzzer.sql`, then `npm run gen:types` and `npm run check:rls`.
|
||||
- Buzzer rules in `src/lib/buzzer.ts` (tested); `/b/[token]` phone page + `GET`/`POST` buzzer API.
|
||||
- "Teams buzz in" checkbox per game; buzzer links per team in the share panel.
|
||||
- Control room: buzz order + "Clear buzzers" (`B`). Scoreboard: "{Team} buzzed!" banner + ding.
|
||||
**Done when:** two phones on different teams buzz in a buzzer game; the control room and scoreboard
|
||||
show who was first within a second, and "Clear buzzers" resets both phones.
|
||||
|
||||
## Phase 11 — Buzzer answers
|
||||
- Apply `supabase/migrations/0004_buzz_result.sql`, then `npm run gen:types`.
|
||||
- `checkMark` / `bumpScore` / `answeringTeamId` in `src/lib/buzzer.ts` (tested); `markBuzz` server action.
|
||||
- Control room: "Got it" (`G`), "Wrong" (`W`), "Undo", "Next question" (`B`).
|
||||
- Phone and scoreboard follow whose turn it is; "correct" and "wrong" sounds.
|
||||
**Done when:** team A buzzes then team B; "Wrong" hands the turn to B; "Got it" gives B one point
|
||||
in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question" resets the phones.
|
||||
|
||||
## Phase 12 — Co-hosts
|
||||
- Apply `supabase/migrations/0005_cohosts.sql`, then `npm run gen:types` and `npm run check:rls`.
|
||||
- `event_cohosts` table; RLS widened from owner to owner-or-co-host, with delete and sharing owner-only.
|
||||
- "Co-hosts" section on the setup page (`addCohost` / `removeCohost` server actions); "Shared with you" in the events list.
|
||||
**Done when:** admin A shares an event with admin B's email; B sees it in their list, can edit it and
|
||||
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 · co-host admins · CSV export of results.
|
||||
Participants within teams · CSV export of results.
|
||||
|
||||
+11
-4
@@ -1,20 +1,27 @@
|
||||
# Data model
|
||||
|
||||
Full SQL in `supabase/migrations/0001_init.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` |
|
||||
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `judges` | Judges per game | `name`, `token`, `revoked`, `source` (nominated/open_link) |
|
||||
| `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) |
|
||||
| `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.
|
||||
- In **judged** games, an admin row (`judge_id IS NULL`) is an **override** for that cell.
|
||||
- Deleting a criterion/team/judge cascades its scores.
|
||||
- Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`).
|
||||
- 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.
|
||||
|
||||
+83
-5
@@ -7,9 +7,10 @@ live scoreboard, and judges score from their phones via links. Versatile enough
|
||||
## 2. Roles
|
||||
| Role | Access | How they get in |
|
||||
|---|---|---|
|
||||
| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
|
||||
| **Admin** | Everything in the events they own or co-host | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
|
||||
| **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` |
|
||||
| **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) |
|
||||
| **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night |
|
||||
|
||||
## 3. Core concepts
|
||||
- **Event** — one game night. Has teams, games, one scoreboard link, visibility state.
|
||||
@@ -38,11 +39,20 @@ live scoreboard, and judges score from their phones via links. Versatile enough
|
||||
- Criteria list: add/remove/reorder; label, description, input type, min/max/step, weight.
|
||||
- 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.
|
||||
- Revoke a judge (link stops working, their scores excluded).
|
||||
- Share panel: scoreboard link + QR, all judge links per game.
|
||||
- Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team
|
||||
(shown once any game uses buzzers).
|
||||
- Co-hosts: the admin who created an event (its owner) shares it with other admins by email.
|
||||
- The email must already be in `ADMIN_EMAILS`; it can be added before that admin's first login.
|
||||
- A co-host sees the event in their list ("Shared with you") and can do everything in it —
|
||||
setup, control room, scores — except delete it or change who it is shared with.
|
||||
- The owner can remove a co-host at any time. Duplicating a shared event gives the co-host
|
||||
their own copy, which they own.
|
||||
|
||||
### 4.2 Admin — Control room (`/admin/events/[id]/live`)
|
||||
This is the screen used during the night. Must be fast and hard to mess up.
|
||||
@@ -62,7 +72,13 @@ This is the screen used during the night. Must be fast and hard to mess up.
|
||||
Recent adjustments list with undo.
|
||||
- Scoreboard visibility: **Visible / Frozen / Hidden** (see 4.3). Big obvious toggle.
|
||||
- "Open scoreboard" button (new tab) + "Reveal mode" button (see 4.3).
|
||||
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer, `?` help.
|
||||
- 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), `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.
|
||||
@@ -106,8 +122,66 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
|
||||
- Timer: a tick for each of the last 10 seconds (sharper for the last 3) and a buzzer at zero.
|
||||
- Browsers only allow sound after a click on the page, so the scoreboard has a "Turn sound on"
|
||||
button. The choice is remembered, but after a reload one click anywhere is needed again.
|
||||
- Buzzer: a two-note ding when a team starts answering, a rising arpeggio for "got it" and two
|
||||
falling notes for "wrong".
|
||||
- Sounds are synthesised in the browser (no audio files). With reduced motion the drum-roll is skipped.
|
||||
|
||||
### 4.7 Buzzers (`/b/[token]`)
|
||||
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
|
||||
- Every team has one buzzer link for the whole night. Someone on the team opens it on a phone:
|
||||
team name, the current game, and one big "Buzz" button.
|
||||
- The button works only while the current game uses buzzers and isn't done. Otherwise the page
|
||||
says there's no buzzer for this game, and wakes up by itself when one starts.
|
||||
- The server decides the order, using the database's clock — never the phones' clocks.
|
||||
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz and
|
||||
queue up behind the team answering.
|
||||
- The team answering is the earliest buzz the host hasn't marked. The host marks it:
|
||||
- **Got it** (`G`) — the question is decided and the buzzers close. In a direct game the team
|
||||
gets +1 in the game's first criterion (so the point follows the game's multiplier and
|
||||
placement rules like any other score). In a judged game nothing is added: the judges score.
|
||||
- **Wrong** (`W`) — that team is out for this question and the turn passes to the next team
|
||||
in the buzz order. With nobody waiting, the buzzers stay open for the other teams.
|
||||
- **Undo** on a marked team removes the mark, and takes the point back if it was "got it".
|
||||
- **Next question** (`B`) clears everything and opens the buzzers again.
|
||||
- If the point can't be added (the score is at the criterion's maximum, or +1 doesn't fit its
|
||||
step), the answer stays unmarked and the host sees why.
|
||||
- The phone's screen turns the team's colour while it is that team's turn ("You buzzed first!
|
||||
Answer now." / "Your turn! Answer now.") and when it got it. Otherwise it shows the team's
|
||||
place and who is answering, "Not this time" after a wrong answer, or who got it.
|
||||
- The control room lists the order with the gap behind the first buzz ("+0.42s") and each mark.
|
||||
- The scoreboard shows "{Team} buzzed!" in the team's colour with the waiting teams after it,
|
||||
"{Team} to answer" when the turn passes, and "{Team} got it!". It is not a score, so it shows
|
||||
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.
|
||||
@@ -116,5 +190,9 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
|
||||
|
||||
## 6. Out of scope (v1)
|
||||
- Individual contestants within teams (stretch: `participants` per team).
|
||||
- Multiple admins / co-hosts (stretch).
|
||||
- Per-game timers and buzzers for contestants (stretch). The event countdown timer is in 4.5.
|
||||
- Transferring an event to another owner, and read-only co-hosts.
|
||||
- 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.
|
||||
|
||||
+49
@@ -75,6 +75,55 @@ Collapses to stacked panels on tablet.
|
||||
```
|
||||
Selected block filled in team colour. Tap the selected block again to clear.
|
||||
|
||||
### Buzzer (phone)
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ● Lions │
|
||||
│ Bible quiz │
|
||||
│ │
|
||||
│ ╭─────────╮ │
|
||||
│ │ │ │
|
||||
│ │ Buzz │ │
|
||||
│ │ │ │
|
||||
│ ╰─────────╯ │
|
||||
│ │
|
||||
│ Ready. Tap to buzz in. │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
One round button in the team's colour, as big as the screen allows; it reacts on touch-down, not
|
||||
on release. After buzzing it shows the team's place ("1st", "2nd"), then ✓ or ✗ once the host
|
||||
marks the answer. While it's the team's turn to answer, and when it got it, the whole screen
|
||||
turns the team's colour. Greyed out while there is no buzzer game.
|
||||
|
||||
On the scoreboard the buzzer is a banner in the answering team's colour above the standings:
|
||||
"Lions buzzed!" with the waiting teams in small text, "Eagles to answer" when the turn passes,
|
||||
"Eagles got it!" at the end. It pops in on each change; reduced motion shows it without the pop.
|
||||
|
||||
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"];
|
||||
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;
|
||||
|
||||
+103
-19
@@ -4,10 +4,14 @@ import { revalidatePath } from "next/cache";
|
||||
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,
|
||||
eventSchema,
|
||||
firstError,
|
||||
gameSchema,
|
||||
@@ -23,22 +27,13 @@ const dbFail = (what: string, message: string) => fail(`${what} failed: ${messag
|
||||
|
||||
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
|
||||
const BAD_ID = fail("That item no longer exists. Reload the page.");
|
||||
const OWNER_ONLY = fail("Only the person who created this game night can do that.");
|
||||
|
||||
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> {
|
||||
@@ -77,7 +72,7 @@ export async function updateEvent(
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(eventPath(eventId));
|
||||
await notify({ eventId });
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -85,14 +80,67 @@ export async function deleteEvent(eventId: string): Promise<ActionResult> {
|
||||
const { supabase } = await requireAdmin();
|
||||
if (!validIds(eventId)) return BAD_ID;
|
||||
|
||||
const { error } = await supabase.from("events").delete().eq("id", eventId);
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** Copies teams, games and criteria — not scores, judges or adjustments. */
|
||||
// ---------- co-hosts ----------
|
||||
|
||||
/** Shares the event with another admin, by email. Owner only (RLS enforces it). */
|
||||
export async function addCohost(eventId: string, email: string): Promise<ActionResult> {
|
||||
const { supabase, email: ownEmail } = await requireAdmin();
|
||||
if (!validIds(eventId)) return BAD_ID;
|
||||
const parsed = cohostEmailSchema.safeParse(email);
|
||||
if (!parsed.success) return fail(firstError(parsed.error));
|
||||
const cohostEmail = parsed.data;
|
||||
|
||||
if (cohostEmail === ownEmail) return fail("That's your own email — you already have access.");
|
||||
// Sharing with someone who can't log in would look like it worked and then not work.
|
||||
if (!serverEnv.ADMIN_EMAILS.includes(cohostEmail)) {
|
||||
return fail(`${cohostEmail} can't log in yet. Add it to ADMIN_EMAILS first, then share again.`);
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("event_cohosts").insert({ event_id: eventId, email: cohostEmail });
|
||||
if (error?.code === "23505") return fail(`Already shared with ${cohostEmail}.`);
|
||||
if (error?.code === "42501") return OWNER_ONLY;
|
||||
if (error) return dbFail("Sharing the event", error.message);
|
||||
|
||||
revalidatePath(eventPath(eventId));
|
||||
return ok;
|
||||
}
|
||||
|
||||
export async function removeCohost(eventId: string, cohostId: string): Promise<ActionResult> {
|
||||
const { supabase } = await requireAdmin();
|
||||
if (!validIds(eventId, cohostId)) return BAD_ID;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("event_cohosts")
|
||||
.delete()
|
||||
.eq("id", cohostId)
|
||||
.eq("event_id", eventId)
|
||||
.select("id");
|
||||
if (error) return dbFail("Removing the co-host", error.message);
|
||||
if (data.length === 0) return OWNER_ONLY;
|
||||
|
||||
revalidatePath(eventPath(eventId));
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -100,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);
|
||||
@@ -178,7 +226,7 @@ export async function updateTeam(
|
||||
if (error) return dbFail("Saving the team", error.message);
|
||||
|
||||
revalidatePath(eventPath(eventId));
|
||||
await notify({ eventId });
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -190,7 +238,7 @@ export async function deleteTeam(eventId: string, teamId: string): Promise<Actio
|
||||
if (error) return dbFail("Deleting the team", error.message);
|
||||
|
||||
revalidatePath(eventPath(eventId));
|
||||
await notify({ eventId });
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -236,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();
|
||||
@@ -261,6 +309,7 @@ async function insertGameCopy(
|
||||
points_mode: game.points_mode,
|
||||
multiplier: game.multiplier,
|
||||
placement_points: game.placement_points,
|
||||
buzzer_enabled: game.buzzer_enabled,
|
||||
admin_notes: game.admin_notes,
|
||||
judge_notes: game.judge_notes,
|
||||
})
|
||||
@@ -284,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;
|
||||
}
|
||||
|
||||
@@ -345,6 +418,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
|
||||
points_mode: game.pointsMode,
|
||||
multiplier: game.multiplier,
|
||||
placement_points: game.placementPoints,
|
||||
buzzer_enabled: game.buzzerEnabled,
|
||||
admin_notes: game.adminNotes || null,
|
||||
judge_notes: game.judgeNotes || null,
|
||||
})
|
||||
@@ -389,7 +463,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
|
||||
if (failed?.error) return dbFail("Saving the criteria", failed.error.message);
|
||||
|
||||
revalidatePath(eventPath(eventId));
|
||||
await notify({ eventId });
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -419,11 +493,21 @@ 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 });
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { CohostsEditor } from "@/components/admin/cohosts-editor";
|
||||
import { EventDetailsForm } from "@/components/admin/event-details-form";
|
||||
import { GamesEditor } from "@/components/admin/games-editor";
|
||||
import { SharePanel } from "@/components/admin/share-panel";
|
||||
@@ -11,12 +12,16 @@ import { loadEventSetup } from "@/lib/queries/event";
|
||||
import { idSchema } from "@/lib/tokens";
|
||||
|
||||
export default async function EventSetupPage(props: PageProps<"/admin/events/[eventId]">) {
|
||||
const { supabase } = await requireAdmin();
|
||||
const { supabase, userId } = await requireAdmin();
|
||||
const { eventId } = await props.params;
|
||||
if (!idSchema.safeParse(eventId).success) notFound();
|
||||
|
||||
const setup = await loadEventSetup(supabase, eventId);
|
||||
const [setup, cohosts] = await Promise.all([
|
||||
loadEventSetup(supabase, eventId),
|
||||
supabase.from("event_cohosts").select("id, email").eq("event_id", eventId).order("created_at"),
|
||||
]);
|
||||
if (!setup) notFound();
|
||||
if (cohosts.error) throw new Error(`Could not load the co-hosts: ${cohosts.error.message}`);
|
||||
const { event, teams, games } = setup;
|
||||
|
||||
return (
|
||||
@@ -63,7 +68,12 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
|
||||
|
||||
<section className="rounded-lg border border-ink/15 bg-white p-4">
|
||||
<h2 className="text-xl font-bold">Share links</h2>
|
||||
<SharePanel scoreboardToken={event.scoreboard_token} games={games} />
|
||||
<SharePanel scoreboardToken={event.scoreboard_token} teams={teams} games={games} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-ink/15 bg-white p-4">
|
||||
<h2 className="text-xl font-bold">Co-hosts</h2>
|
||||
<CohostsEditor eventId={event.id} cohosts={cohosts.data} isOwner={event.owner_id === userId} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+241
-26
@@ -5,10 +5,12 @@ import { z } from "zod";
|
||||
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import type { Json } from "@/lib/database.types";
|
||||
import { loadEventData } from "@/lib/queries/scoring";
|
||||
import { bumpScore, checkMark, type BuzzResult } from "@/lib/buzzer";
|
||||
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,
|
||||
@@ -47,14 +49,19 @@ 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);
|
||||
if (!event.data?.length) return BAD_ID;
|
||||
|
||||
revalidatePath(livePath(eventId));
|
||||
await notify({ eventId, gameId });
|
||||
await notify({ eventId, gameId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -74,7 +81,148 @@ export async function setGameStatus(
|
||||
if (error) return dbFail("Updating the game", error.message);
|
||||
|
||||
revalidatePath(livePath(eventId));
|
||||
await notify({ eventId, gameId });
|
||||
// Marking a game done closes its buzzers, so the teams' phones need to know.
|
||||
await notify({ eventId, gameId, buzzer: true });
|
||||
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. */
|
||||
export async function clearBuzzes(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("Clearing the buzzers", game.error.message);
|
||||
if (!game.data) return BAD_ID;
|
||||
|
||||
const { error } = await supabase.from("buzzes").delete().eq("game_id", gameId);
|
||||
if (error) return dbFail("Clearing the buzzers", error.message);
|
||||
|
||||
revalidatePath(livePath(eventId));
|
||||
await notify({ eventId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -109,28 +257,8 @@ export async function saveAdminScore(
|
||||
const parsed = scoreSchema.safeParse({ value });
|
||||
if (!parsed.success) return fail("That score isn't a valid number.");
|
||||
|
||||
if (parsed.data.value === null) {
|
||||
const { error } = await supabase
|
||||
.from("scores")
|
||||
.delete()
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterionId)
|
||||
.is("judge_id", null);
|
||||
if (error) return scoreError(error.message);
|
||||
} else {
|
||||
const { error } = await supabase.from("scores").upsert(
|
||||
{
|
||||
game_id: gameId,
|
||||
team_id: teamId,
|
||||
criterion_id: criterionId,
|
||||
judge_id: null,
|
||||
value: parsed.data.value,
|
||||
},
|
||||
{ onConflict: "team_id,criterion_id,judge_id" },
|
||||
);
|
||||
if (error) return scoreError(error.message);
|
||||
}
|
||||
const { error } = await writeAdminScore(supabase, gameId, teamId, criterionId, parsed.data.value);
|
||||
if (error) return scoreError(error.message);
|
||||
|
||||
// No revalidatePath: the control room refreshes itself, so the "saved" tick isn't
|
||||
// held up by a full page re-render.
|
||||
@@ -138,6 +266,93 @@ export async function saveAdminScore(
|
||||
return ok;
|
||||
}
|
||||
|
||||
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
|
||||
|
||||
/** Upserts the admin's row for one cell, or deletes it when `value` is null. */
|
||||
function writeAdminScore(
|
||||
supabase: Supabase,
|
||||
gameId: string,
|
||||
teamId: string,
|
||||
criterionId: string,
|
||||
value: number | null,
|
||||
) {
|
||||
return value === null
|
||||
? supabase
|
||||
.from("scores")
|
||||
.delete()
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterionId)
|
||||
.is("judge_id", null)
|
||||
: supabase.from("scores").upsert(
|
||||
{ game_id: gameId, team_id: teamId, criterion_id: criterionId, judge_id: null, value },
|
||||
{ onConflict: "team_id,criterion_id,judge_id" },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- buzzer answers ----------
|
||||
|
||||
const buzzResultSchema = z.enum(["correct", "wrong"]).nullable();
|
||||
|
||||
/**
|
||||
* Marks the answering team's buzz: "correct" ends the question, "wrong" passes the turn to the
|
||||
* next team that buzzed, null undoes an earlier mark. In a direct game a correct answer also
|
||||
* adds a point to the team's score in the game's first criterion (and undoing takes it back);
|
||||
* in a judged game the judges' scores stand, so only the mark changes.
|
||||
*/
|
||||
export async function markBuzz(
|
||||
eventId: string,
|
||||
gameId: string,
|
||||
teamId: string,
|
||||
result: BuzzResult,
|
||||
): Promise<ActionResult> {
|
||||
const { supabase } = await requireAdmin();
|
||||
if (!validIds(eventId, gameId, teamId) || !buzzResultSchema.safeParse(result).success) return BAD_ID;
|
||||
|
||||
const [game, buzzes, criteria] = await Promise.all([
|
||||
// The game must be in this event; RLS alone would allow a game from another of your events.
|
||||
supabase.from("games").select("scoring_mode").eq("id", gameId).eq("event_id", eventId).maybeSingle(),
|
||||
loadBuzzes(supabase, gameId),
|
||||
supabase.from("criteria").select("id, min_value").eq("game_id", gameId).order("sort_order").limit(1),
|
||||
]);
|
||||
const loadError = game.error ?? buzzes.error ?? criteria.error;
|
||||
if (loadError) return dbFail("Marking the answer", loadError.message);
|
||||
if (!game.data) return BAD_ID;
|
||||
|
||||
const check = checkMark(buzzes.data ?? [], teamId, result);
|
||||
if (!check.ok) return fail(check.error);
|
||||
if (!check.changed) return ok;
|
||||
|
||||
// The point first: if it can't be saved (say the score is at its maximum), the answer
|
||||
// stays unmarked and the host sees why, instead of a "correct" with no point behind it.
|
||||
const criterion = criteria.data?.[0];
|
||||
if (check.pointDelta !== 0 && game.data.scoring_mode === "direct" && criterion) {
|
||||
const current = await supabase
|
||||
.from("scores")
|
||||
.select("value")
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterion.id)
|
||||
.is("judge_id", null)
|
||||
.maybeSingle();
|
||||
if (current.error) return dbFail("Adding the point", current.error.message);
|
||||
|
||||
const next = bumpScore(current.data?.value ?? null, check.pointDelta, criterion.min_value);
|
||||
const { error } = await writeAdminScore(supabase, gameId, teamId, criterion.id, next);
|
||||
if (error) return scoreError(error.message);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("buzzes")
|
||||
.update({ result })
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId);
|
||||
if (error) return dbFail("Marking the answer", error.message);
|
||||
|
||||
revalidatePath(livePath(eventId));
|
||||
await notify({ eventId, gameId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---------- bonus / penalty ----------
|
||||
|
||||
const adjustmentSchema = z.object({
|
||||
|
||||
@@ -7,10 +7,11 @@ import { requireAdmin } from "@/lib/auth";
|
||||
const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" });
|
||||
|
||||
export default async function AdminHome() {
|
||||
const { supabase } = await requireAdmin();
|
||||
const { supabase, userId } = await requireAdmin();
|
||||
// RLS returns the events you own plus the ones shared with you.
|
||||
const { data: events, error } = await supabase
|
||||
.from("events")
|
||||
.select("id, name, event_date")
|
||||
.select("id, name, event_date, owner_id")
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) throw new Error(`Could not load events: ${error.message}`);
|
||||
|
||||
@@ -33,9 +34,14 @@ export default async function AdminHome() {
|
||||
</span>
|
||||
<span className="text-sm text-ink/70">
|
||||
{event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"}
|
||||
{event.owner_id !== userId && " · Shared with you"}
|
||||
</span>
|
||||
</Link>
|
||||
<EventRowActions eventId={event.id} eventName={event.name} />
|
||||
<EventRowActions
|
||||
eventId={event.id}
|
||||
eventName={event.name}
|
||||
canDelete={event.owner_id === userId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buzz } from "@/lib/buzzer.server";
|
||||
import { createRateLimiter } from "@/lib/rate-limit";
|
||||
|
||||
const headers = { "Cache-Control": "no-store" };
|
||||
// A team buzzes once per question; this only stops a stuck phone or a script.
|
||||
const allow = createRateLimiter(5);
|
||||
|
||||
/** Buzzes in for this team. No body: the token says who, the server says when. */
|
||||
export async function POST(_request: Request, context: RouteContext<"/api/buzzer/[token]/buzz">) {
|
||||
const { token } = await context.params;
|
||||
if (!allow(token)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many taps at once. Wait a second and try again." },
|
||||
{ status: 429, headers },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await buzz(token);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
|
||||
return NextResponse.json(result.payload, { headers });
|
||||
} catch (error) {
|
||||
console.error("buzz failed", error);
|
||||
return NextResponse.json({ error: "That buzz didn't go through. Try again." }, { status: 500, headers });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getBuzzerPayload } from "@/lib/buzzer.server";
|
||||
|
||||
const headers = { "Cache-Control": "no-store" };
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext<"/api/buzzer/[token]">) {
|
||||
const { token } = await context.params;
|
||||
try {
|
||||
const result = await getBuzzerPayload(token);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
|
||||
return NextResponse.json(result.payload, { headers });
|
||||
} catch (error) {
|
||||
console.error("buzzer API failed", error);
|
||||
return NextResponse.json({ error: "Could not load the buzzer. Try again." }, { status: 500, headers });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { serverEnv } from "@/lib/env.server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
const otpTypeSchema = z.enum(["magiclink", "email", "signup", "invite", "recovery", "email_change"]);
|
||||
@@ -23,8 +24,9 @@ export async function GET(request: NextRequest) {
|
||||
signedIn = !error;
|
||||
}
|
||||
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = signedIn ? "/admin" : "/login";
|
||||
url.search = signedIn ? "" : "?error=link";
|
||||
return NextResponse.redirect(url);
|
||||
// Built from the configured site URL, not request.nextUrl: behind a reverse proxy or
|
||||
// tunnel a route handler sees the address the server is bound to (e.g. localhost:4322),
|
||||
// and redirecting there leaves the session cookie behind on the public domain.
|
||||
const path = signedIn ? "/admin" : "/login?error=link";
|
||||
return NextResponse.redirect(`${serverEnv.NEXT_PUBLIC_SITE_URL}${path}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { BuzzerMessage, BuzzerView } from "@/components/buzzer/buzzer-view";
|
||||
import { getBuzzerPayload } from "@/lib/buzzer.server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Buzzer",
|
||||
// The link is the team's only credential; keep it out of search engines.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function BuzzerPage(props: PageProps<"/b/[token]">) {
|
||||
const { token } = await props.params;
|
||||
const result = await getBuzzerPayload(token);
|
||||
|
||||
if (!result.ok) return <BuzzerMessage>{result.error}</BuzzerMessage>;
|
||||
return <BuzzerView token={token} initial={result.payload} />;
|
||||
}
|
||||
@@ -119,3 +119,25 @@ body {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scoreboard buzzer: the banner pops in when a team is first to buzz. */
|
||||
@keyframes buzz-pop {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.buzz-pop {
|
||||
animation: buzz-pop 350ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-pop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { clearBuzzes, markBuzz } from "@/app/admin/live-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer";
|
||||
import type { LiveGame } from "@/lib/live-view";
|
||||
|
||||
import { ErrorText } from "./error-text";
|
||||
import { useAction } from "./use-action";
|
||||
|
||||
/**
|
||||
* Who buzzed for the current question, in order. The host marks the answering team:
|
||||
* "Got it" gives the point and ends the question, "Wrong" passes the turn down the order.
|
||||
*/
|
||||
export function BuzzerPanel({ eventId, game, buzzer }: { eventId: string; game: LiveGame; buzzer: BuzzerView }) {
|
||||
const { pending, error, run } = useAction();
|
||||
const { buzzes, answeringTeamId } = buzzer;
|
||||
const decided = buzzes.some((entry) => entry.result === "correct");
|
||||
// Only a direct game takes the point; in a judged game the judges' scores stand.
|
||||
const pointCriterion = game.scoringMode === "direct" ? game.criteria[0] : undefined;
|
||||
|
||||
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">Buzzers</h3>
|
||||
<Button
|
||||
variant={decided ? "primary" : "secondary"}
|
||||
disabled={pending || buzzes.length === 0}
|
||||
onClick={() => run(() => clearBuzzes(eventId, game.id))}
|
||||
>
|
||||
Next question (B)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{buzzes.length === 0 ? (
|
||||
<p role="status" className="mt-1">
|
||||
{buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-2 flex flex-col gap-1">
|
||||
{buzzes.map((entry, index) => {
|
||||
const answering = entry.teamId === answeringTeamId;
|
||||
return (
|
||||
<li
|
||||
key={entry.teamId}
|
||||
aria-current={answering ? "true" : undefined}
|
||||
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
|
||||
answering ? "bg-floodlight text-xl font-bold" : entry.result === "wrong" ? "text-ink/70" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-5 tabular-nums">{index + 1}</span>
|
||||
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className={`min-w-0 flex-1 truncate ${entry.result === "wrong" ? "line-through" : ""}`}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-sm font-normal tabular-nums">
|
||||
{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}
|
||||
</span>
|
||||
|
||||
{answering && (
|
||||
<>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "correct"))}
|
||||
>
|
||||
{pointCriterion ? "Got it +1 (G)" : "Got it (G)"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "wrong"))}
|
||||
>
|
||||
Wrong (W)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{entry.result && (
|
||||
<>
|
||||
<strong className={entry.result === "correct" ? "" : "text-alert-strong"}>
|
||||
{entry.result === "correct" ? "✓ Got it" : "✗ Wrong"}
|
||||
</strong>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
aria-label={`Undo for ${entry.name}`}
|
||||
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, null))}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<p role="status" className="mt-2 text-sm text-ink/70">
|
||||
{decided
|
||||
? "Answered. Press “Next question” to open the buzzers again."
|
||||
: buzzes.length > 0 && !answeringTeamId
|
||||
? "Nobody is waiting to answer. The buzzers are still open."
|
||||
: pointCriterion
|
||||
? `“Got it” adds 1 to ${pointCriterion.label}. “Wrong” passes to the next team.`
|
||||
: "Judged game: “Got it” adds no point — the judges score. “Wrong” passes to the next team."}
|
||||
</p>
|
||||
<ErrorText>{error}</ErrorText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { addCohost, removeCohost } from "@/app/admin/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { inputClass } from "@/components/ui/field";
|
||||
import type { Tables } from "@/lib/database.types";
|
||||
|
||||
import { ErrorText } from "./error-text";
|
||||
import { useAction } from "./use-action";
|
||||
|
||||
type Cohost = Pick<Tables<"event_cohosts">, "id" | "email">;
|
||||
|
||||
/** Who the event is shared with. Only the owner can change the list; co-hosts just see it. */
|
||||
export function CohostsEditor({
|
||||
eventId,
|
||||
cohosts,
|
||||
isOwner,
|
||||
}: {
|
||||
eventId: string;
|
||||
cohosts: Cohost[];
|
||||
isOwner: boolean;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const { pending, error, run } = useAction();
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<p className="text-sm text-ink/70">
|
||||
{isOwner
|
||||
? "Co-hosts can set up and run this game night with you. They can't delete it or change who it's shared with."
|
||||
: "This game night was shared with you. Only the person who created it can change who it's shared with."}
|
||||
</p>
|
||||
|
||||
{cohosts.length === 0 ? (
|
||||
<p>Not shared with anyone yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{cohosts.map((cohost) => (
|
||||
<li key={cohost.id} className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="min-w-0 break-all">{cohost.email}</span>
|
||||
{isOwner && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Stop sharing with ${cohost.email}?`)) {
|
||||
run(() => removeCohost(eventId, cohost.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
run(() => addCohost(eventId, email), () => setEmail(""));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
aria-label="Co-host's email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
maxLength={254}
|
||||
placeholder="name@example.com"
|
||||
className={`${inputClass} max-w-xs`}
|
||||
/>
|
||||
<Button type="submit" disabled={pending}>
|
||||
Share
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<ErrorText>{error}</ErrorText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,15 @@ import { useRouter } from "next/navigation";
|
||||
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";
|
||||
@@ -16,12 +21,15 @@ 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";
|
||||
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";
|
||||
|
||||
@@ -33,6 +41,12 @@ const shortcuts = [
|
||||
["P", "Previous game"],
|
||||
["H", "Cycle what the room sees: visible → frozen → hidden"],
|
||||
["T", "Start or pause the timer"],
|
||||
["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;
|
||||
|
||||
@@ -71,6 +85,38 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
run(() => (timer.status === "running" ? pauseTimer(event.id) : startTimer(event.id)));
|
||||
}, [event.id, pending, run, timer.status]);
|
||||
|
||||
const buzzerGameId = currentGame?.buzzer?.buzzes.length ? currentGame.id : undefined;
|
||||
const clearBuzzers = useCallback(() => {
|
||||
if (buzzerGameId && !pending) run(() => clearBuzzes(event.id, buzzerGameId));
|
||||
}, [buzzerGameId, event.id, pending, run]);
|
||||
|
||||
const answeringTeamId = currentGame?.buzzer?.answeringTeamId;
|
||||
const markAnswer = useCallback(
|
||||
(result: "correct" | "wrong") => {
|
||||
if (currentGame && answeringTeamId && !pending) {
|
||||
run(() => markBuzz(event.id, currentGame.id, answeringTeamId, result));
|
||||
}
|
||||
},
|
||||
[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.
|
||||
@@ -83,12 +129,29 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
else if (key === "p") goTo(previousGame?.id);
|
||||
else if (key === "h") cycleVisibility();
|
||||
else if (key === "t") toggleTimer();
|
||||
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);
|
||||
}, [cycleVisibility, goTo, 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]));
|
||||
|
||||
@@ -211,6 +274,10 @@ 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} />
|
||||
)}
|
||||
{/* Keyed by game so half-saved edits never carry over to another game. */}
|
||||
<ScoringGrid key={currentGame.id} eventId={event.id} game={currentGame} teams={teams} />
|
||||
{currentGame.scoringMode === "judged" && (
|
||||
|
||||
@@ -6,7 +6,16 @@ import { Button } from "@/components/ui/button";
|
||||
import { ErrorText } from "./error-text";
|
||||
import { useAction } from "./use-action";
|
||||
|
||||
export function EventRowActions({ eventId, eventName }: { eventId: string; eventName: string }) {
|
||||
export function EventRowActions({
|
||||
eventId,
|
||||
eventName,
|
||||
canDelete,
|
||||
}: {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
/** Only the owner can delete; the server enforces it too. */
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const { pending, error, run } = useAction();
|
||||
|
||||
return (
|
||||
@@ -19,18 +28,20 @@ export function EventRowActions({ eventId, eventName }: { eventId: string; event
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
|
||||
run(() => deleteEvent(eventId));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
|
||||
run(() => deleteEvent(eventId));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<ErrorText>{error}</ErrorText>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ function toDraft(game: GameWithCriteria): Draft {
|
||||
pointsMode: game.points_mode,
|
||||
multiplier: game.multiplier,
|
||||
placementPoints: game.placement_points.join(", "),
|
||||
buzzerEnabled: game.buzzer_enabled,
|
||||
adminNotes: game.admin_notes ?? "",
|
||||
judgeNotes: game.judge_notes ?? "",
|
||||
criteria: game.criteria.map((criterion) => ({
|
||||
@@ -176,6 +177,21 @@ export function GameForm({ eventId, game }: { eventId: string; game: GameWithCri
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<label className="flex min-h-11 items-start gap-3 sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.buzzerEnabled}
|
||||
onChange={(event) => change({ buzzerEnabled: event.target.checked })}
|
||||
className="mt-1 size-5 shrink-0 accent-ink"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold">Teams buzz in</span>
|
||||
<span className="block text-sm text-ink/70">
|
||||
Each team buzzes from its own link (under Share links). The control room and the
|
||||
scoreboard show who was first. Scoring stays as set above.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/env.public", () => ({ publicEnv: { NEXT_PUBLIC_SITE_URL: "https://gamenight.example" } }));
|
||||
|
||||
const { judgeLinksText } = await import("./share-panel");
|
||||
const { buzzerLinksText, judgeLinksText } = await import("./share-panel");
|
||||
|
||||
const judge = (name: string, token: string, revoked = false) =>
|
||||
({ id: token, game_id: "g", name, token, revoked, source: "nominated", created_at: "" }) as const;
|
||||
@@ -18,3 +18,15 @@ describe("judgeLinksText", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buzzerLinksText", () => {
|
||||
it("lists one line per team", () => {
|
||||
const text = buzzerLinksText([
|
||||
{ name: "Lions", buzzer_token: "aaa" },
|
||||
{ name: "Eagles", buzzer_token: "bbb" },
|
||||
]);
|
||||
expect(text).toBe(
|
||||
"Buzzer links\nLions: https://gamenight.example/b/aaa\nEagles: https://gamenight.example/b/bbb",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Tables } from "@/lib/database.types";
|
||||
import { publicEnv } from "@/lib/env.public";
|
||||
import type { GameWithCriteria } from "@/lib/queries/event";
|
||||
|
||||
@@ -18,9 +19,25 @@ export function judgeLinksText(game: Pick<GameWithCriteria, "title" | "judges">)
|
||||
return [`${game.title} — judge links`, ...lines].join("\n");
|
||||
}
|
||||
|
||||
/** Every link for the night in one place: the scoreboard, and each judged game's judges. */
|
||||
export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string; games: GameWithCriteria[] }) {
|
||||
type BuzzerTeam = Pick<Tables<"teams">, "id" | "name" | "buzzer_token">;
|
||||
|
||||
/** Plain text for pasting into WhatsApp: one line per team. */
|
||||
export function buzzerLinksText(teams: Array<Pick<BuzzerTeam, "name" | "buzzer_token">>): string {
|
||||
return ["Buzzer links", ...teams.map((team) => `${team.name}: ${site}/b/${team.buzzer_token}`)].join("\n");
|
||||
}
|
||||
|
||||
/** Every link for the night in one place: the scoreboard, the team buzzers, and each judged game's judges. */
|
||||
export function SharePanel({
|
||||
scoreboardToken,
|
||||
teams,
|
||||
games,
|
||||
}: {
|
||||
scoreboardToken: string;
|
||||
teams: BuzzerTeam[];
|
||||
games: GameWithCriteria[];
|
||||
}) {
|
||||
const judgedGames = games.filter((game) => game.scoring_mode === "judged");
|
||||
const usesBuzzers = games.some((game) => game.buzzer_enabled);
|
||||
const localOnly = /\/\/(localhost|127\.0\.0\.1)/.test(site);
|
||||
|
||||
return (
|
||||
@@ -41,6 +58,32 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* One link per team for the whole night; it only does something during a buzzer game. */}
|
||||
{usesBuzzers && (
|
||||
<div className="flex flex-col gap-2 border-t border-ink/15 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-40 flex-1 text-lg font-bold">Team buzzers</h3>
|
||||
{teams.length > 0 && <CopyAllButton text={buzzerLinksText(teams)} label="Copy all buzzer links" />}
|
||||
</div>
|
||||
{teams.length === 0 ? (
|
||||
<p>No teams yet. Add teams above and each gets a buzzer link.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{teams.map((team) => (
|
||||
<li key={team.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-32 flex-1">{team.name}</span>
|
||||
<ShareLink
|
||||
url={`${site}/b/${team.buzzer_token}`}
|
||||
copyLabel="Copy buzzer link"
|
||||
qrLabel={`QR code for the ${team.name} buzzer`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{judgedGames.length === 0 ? (
|
||||
<p>No judged games yet. Set a game to “Judged” to give judges their own links.</p>
|
||||
) : (
|
||||
@@ -50,7 +93,9 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
|
||||
<div key={game.id} className="flex flex-col gap-2 border-t border-ink/15 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-40 flex-1 text-lg font-bold">{game.title}</h3>
|
||||
{activeJudges.length > 0 && <CopyAllButton text={judgeLinksText(game)} />}
|
||||
{activeJudges.length > 0 && (
|
||||
<CopyAllButton text={judgeLinksText(game)} label="Copy all judge links" />
|
||||
)}
|
||||
</div>
|
||||
{activeJudges.length === 0 && !game.open_link_enabled && (
|
||||
<p>No judges yet. Open this game below to add judges or turn on its open link.</p>
|
||||
@@ -85,7 +130,7 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
|
||||
);
|
||||
}
|
||||
|
||||
function CopyAllButton({ text }: { text: string }) {
|
||||
function CopyAllButton({ text, label }: { text: string; label: string }) {
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
return (
|
||||
@@ -103,7 +148,7 @@ function CopyAllButton({ text }: { text: string }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{state === "copied" ? "Copied ✓" : "Copy all judge links"}
|
||||
{state === "copied" ? "Copied ✓" : label}
|
||||
</Button>
|
||||
{state === "failed" && (
|
||||
<div className="basis-full">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { buzzerMessages, ordinal, type BuzzerPayload } from "@/lib/buzzer";
|
||||
import { readableTextColor } from "@/lib/color";
|
||||
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
|
||||
import { channelNames } from "@/lib/realtime-channels";
|
||||
|
||||
/** A team's phone page: one big button. The server decides who was first. */
|
||||
export function BuzzerView({ token, initial }: { token: string; initial: BuzzerPayload }) {
|
||||
const [payload, setPayload] = useState(initial);
|
||||
// Set when the link stops working (the team was deleted) while the page is open.
|
||||
const [linkOff, setLinkOff] = useState<string | null>(null);
|
||||
const [offline, setOffline] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A press fires on pointer-down and again as a click; only the first may send.
|
||||
const busy = useRef(false);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/buzzer/${token}`, { cache: "no-store" });
|
||||
if (response.status === 404) {
|
||||
setLinkOff(((await response.json()) as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
setPayload((await response.json()) as BuzzerPayload);
|
||||
setOffline(false);
|
||||
} catch {
|
||||
setOffline(true);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Pinged when anyone buzzes, the host clears the buzzers, or the game changes.
|
||||
useRefreshChannel(channelNames.buzzer(payload.eventId), refetch);
|
||||
useScreenAwake();
|
||||
|
||||
const { team, currentGame, buzzer } = payload;
|
||||
const position = buzzer ? buzzer.buzzes.findIndex((entry) => entry.teamId === team.id) : -1;
|
||||
const canBuzz = Boolean(buzzer?.open) && position < 0 && !sending;
|
||||
|
||||
const press = async () => {
|
||||
if (!canBuzz || busy.current) return;
|
||||
busy.current = true;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
// A small kick so the player knows the tap landed, where the phone supports it.
|
||||
navigator.vibrate?.(60);
|
||||
try {
|
||||
const response = await fetch(`/api/buzzer/${token}/buzz`, { method: "POST" });
|
||||
const body = (await response.json().catch(() => ({}))) as BuzzerPayload & { error?: string };
|
||||
if (response.ok) {
|
||||
setPayload(body);
|
||||
setOffline(false);
|
||||
} else if (response.status === 404) {
|
||||
setLinkOff(body.error ?? buzzerMessages.linkOff);
|
||||
} else {
|
||||
setError(body.error ?? "That buzz didn't go through. Try again.");
|
||||
// The game changed or finished under us: show why.
|
||||
if (response.status === 409) void refetch();
|
||||
}
|
||||
} catch {
|
||||
setError("That buzz didn't go through. Check your connection and try again.");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (linkOff) return <BuzzerMessage>{linkOff}</BuzzerMessage>;
|
||||
|
||||
const mine = position >= 0 ? buzzer?.buzzes[position] : undefined;
|
||||
const answering = buzzer?.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
|
||||
const winner = buzzer?.buzzes.find((entry) => entry.result === "correct");
|
||||
const myTurn = answering?.teamId === team.id;
|
||||
|
||||
const status = !buzzer
|
||||
? buzzerMessages.noGame
|
||||
: mine?.result === "correct"
|
||||
? "You got it!"
|
||||
: winner
|
||||
? `${winner.name} got it. Wait for the next question.`
|
||||
: mine?.result === "wrong"
|
||||
? "Not this time. Wait for the next question."
|
||||
: myTurn
|
||||
? position === 0
|
||||
? "You buzzed first! Answer now."
|
||||
: "Your turn! Answer now."
|
||||
: mine
|
||||
? `You buzzed ${ordinal(position + 1)}. ${answering?.name} is answering.`
|
||||
: sending
|
||||
? "Buzzing…"
|
||||
: !buzzer.open
|
||||
? buzzerMessages.closed
|
||||
: answering
|
||||
? `${answering.name} is answering. You can still buzz in.`
|
||||
: "Ready. Tap to buzz in.";
|
||||
|
||||
// The screen lights up while it's this team's moment: answering, or just got it.
|
||||
const won = myTurn || mine?.result === "correct";
|
||||
const textColor = readableTextColor(team.color);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-dvh max-w-xl flex-col items-center gap-4 p-4 pb-8 transition-colors"
|
||||
// The whole screen turns the team's colour, so the table sees at a glance it's their turn.
|
||||
style={won ? { backgroundColor: team.color, color: textColor } : undefined}
|
||||
>
|
||||
<header className="w-full text-center">
|
||||
<h1 className="flex items-center justify-center gap-2 font-display text-4xl font-bold">
|
||||
{!won && (
|
||||
<span aria-hidden className="inline-block size-6 rounded-full" style={{ backgroundColor: team.color }} />
|
||||
)}
|
||||
{team.name}
|
||||
</h1>
|
||||
<p className="text-lg">{currentGame ? currentGame.title : payload.eventName}</p>
|
||||
</header>
|
||||
|
||||
{offline && (
|
||||
<p role="status" className="w-full rounded-md border-2 border-alert bg-white p-3 font-bold text-ink">
|
||||
You're offline. The buzzer won't work until the connection is back.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-1 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canBuzz}
|
||||
onPointerDown={() => void press()}
|
||||
onClick={() => void press()}
|
||||
className={`flex aspect-square w-full max-w-sm touch-manipulation items-center justify-center rounded-full border-8 font-display text-7xl font-bold shadow-xl transition-transform select-none active:scale-95 disabled:shadow-none ${
|
||||
position < 0 ? "disabled:opacity-40" : ""
|
||||
} ${won ? "border-current" : "border-ink"}`}
|
||||
style={{ backgroundColor: team.color, color: textColor }}
|
||||
>
|
||||
{mine?.result === "correct" ? "✓" : mine?.result === "wrong" ? "✗" : mine ? ordinal(position + 1) : "Buzz"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p role="status" className="min-h-16 text-center text-2xl font-bold">
|
||||
{error ? <span className={won ? "" : "text-alert-strong"}>{error}</span> : status}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuzzerMessage({ children }: { children: string }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-xl items-center justify-center p-6 text-center">
|
||||
<p className="text-2xl font-bold">{children}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps the phone's screen on while the buzzer is open: a sleeping phone can't buzz. */
|
||||
function useScreenAwake() {
|
||||
useEffect(() => {
|
||||
let lock: WakeLockSentinel | null = null;
|
||||
let stopped = false;
|
||||
const request = async () => {
|
||||
try {
|
||||
const next = await navigator.wakeLock?.request("screen");
|
||||
if (stopped) void next?.release();
|
||||
else lock = next ?? null;
|
||||
} catch {
|
||||
// Not supported, or refused (e.g. battery saver): the screen just dims as usual.
|
||||
}
|
||||
};
|
||||
// The browser drops the lock whenever the tab is hidden, so take it again on return.
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") void request();
|
||||
};
|
||||
void request();
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
stopped = true;
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
void lock?.release();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import type { BuzzerView } from "@/lib/buzzer";
|
||||
import { readableTextColor } from "@/lib/color";
|
||||
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
|
||||
import { useSound } from "@/lib/hooks/use-sound";
|
||||
@@ -10,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;
|
||||
@@ -45,7 +47,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
||||
() => new Set(initial.teams.filter((team) => team.rank !== undefined).map((team) => team.id)),
|
||||
);
|
||||
|
||||
// Sound effects for what just changed: a new game, a team being revealed.
|
||||
// Sound effects for what just changed: a new game, a team being revealed, a first buzz.
|
||||
const sound = useSound();
|
||||
const { play } = sound;
|
||||
const previousPayload = useRef(initial);
|
||||
@@ -103,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">
|
||||
@@ -123,40 +131,48 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
||||
|
||||
{payload.timer && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
|
||||
|
||||
{hidden && (
|
||||
{payload.buzzer && <BuzzBanner buzzer={payload.buzzer} />}
|
||||
|
||||
{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" : ""}`}
|
||||
@@ -244,6 +260,43 @@ function TeamRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The buzzer's headline: the team answering fills the banner in its colour, with the teams
|
||||
* still waiting after it; once a team got it, the banner says so. Nothing while nobody is
|
||||
* answering (no buzz yet, or everyone so far was wrong).
|
||||
*/
|
||||
function BuzzBanner({ buzzer }: { buzzer: BuzzerView }) {
|
||||
const winner = buzzer.buzzes.find((entry) => entry.result === "correct");
|
||||
const answering = buzzer.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
|
||||
const team = winner ?? answering;
|
||||
if (!team) return null;
|
||||
|
||||
const waiting = winner ? [] : buzzer.buzzes.filter((entry) => entry.result === null && entry !== answering);
|
||||
// The very first buzz "buzzed"; a team that inherits the turn after a wrong answer is "to answer".
|
||||
const headline = winner
|
||||
? `${team.name} got it!`
|
||||
: buzzer.buzzes[0] === answering
|
||||
? `${team.name} buzzed!`
|
||||
: `${team.name} to answer`;
|
||||
|
||||
return (
|
||||
<div
|
||||
// Keyed by the headline so the pop replays whenever the turn or the outcome changes.
|
||||
key={headline}
|
||||
role="status"
|
||||
className="buzz-pop flex shrink-0 items-baseline justify-center gap-[2vw] rounded-[1vh] px-[3vw] py-[1.5vh] font-display leading-none font-bold"
|
||||
style={{ backgroundColor: team.color, color: readableTextColor(team.color) }}
|
||||
>
|
||||
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{headline}</span>
|
||||
{waiting.length > 0 && (
|
||||
<span className="truncate text-[clamp(1rem,3.5vh,3rem)]">
|
||||
then {waiting.map((entry) => entry.name).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** True after `ms` without mouse or keyboard activity (hides the cursor on the projector). */
|
||||
function useIdle(ms: number) {
|
||||
const [idle, setIdle] = useState(false);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
|
||||
import { buzzerMessages, buzzerOpen, buzzerView, type BuzzerPayload } from "@/lib/buzzer";
|
||||
import { loadBuzzes } from "@/lib/queries/scoring";
|
||||
import { notify } from "@/lib/realtime";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { buzzerTokenSchema } from "@/lib/tokens";
|
||||
|
||||
// Everything here uses the service-role client, so the token check at the top of each
|
||||
// function is the only gate (CLAUDE.md rule 2). Every query is scoped by what that token grants.
|
||||
|
||||
type Supabase = ReturnType<typeof createAdminClient>;
|
||||
|
||||
const linkOff = { ok: false, status: 404, error: buzzerMessages.linkOff } as const;
|
||||
|
||||
/** The team behind a buzzer link, with its event's current game (if any). */
|
||||
async function loadTeam(supabase: Supabase, token: string) {
|
||||
const parsed = buzzerTokenSchema.safeParse(token);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { data: team, error } = await supabase
|
||||
.from("teams")
|
||||
.select("id, name, color, events!inner(id, name, current_game_id)")
|
||||
.eq("buzzer_token", parsed.data)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`Could not load the team: ${error.message}`);
|
||||
if (!team) return null;
|
||||
|
||||
const event = team.events;
|
||||
if (!event.current_game_id) return { team, event, game: null };
|
||||
|
||||
const { data: game, error: gameError } = await supabase
|
||||
.from("games")
|
||||
.select("id, title, status, buzzer_enabled")
|
||||
.eq("id", event.current_game_id)
|
||||
.maybeSingle();
|
||||
if (gameError) throw new Error(`Could not load the game: ${gameError.message}`);
|
||||
return { team, event, game };
|
||||
}
|
||||
|
||||
export type BuzzerLookup =
|
||||
| { ok: true; payload: BuzzerPayload }
|
||||
| { ok: false; status: 404 | 409; error: string };
|
||||
|
||||
export async function getBuzzerPayload(token: string): Promise<BuzzerLookup> {
|
||||
const supabase = createAdminClient();
|
||||
const found = await loadTeam(supabase, token);
|
||||
if (!found) return linkOff;
|
||||
const { team, event, game } = found;
|
||||
|
||||
const [teams, buzzes] = await Promise.all([
|
||||
supabase.from("teams").select("id, name, color").eq("event_id", event.id),
|
||||
game?.buzzer_enabled ? loadBuzzes(supabase, game.id) : { data: [], error: null },
|
||||
]);
|
||||
const loadError = teams.error ?? buzzes.error;
|
||||
if (loadError) throw new Error(`Could not load the buzzers: ${loadError.message}`);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
// Only what a team should see: no tokens, no scores.
|
||||
team: { id: team.id, name: team.name, color: team.color },
|
||||
currentGame: game ? { id: game.id, title: game.title } : null,
|
||||
buzzer: buzzerView(game, teams.data ?? [], buzzes.data ?? []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records this team's buzz for the current game and returns what its phone should now show.
|
||||
* A team buzzes once per question; pressing again changes nothing, so a double tap or a
|
||||
* retry after a dropped connection can't move it down the order.
|
||||
*/
|
||||
export async function buzz(token: string): Promise<BuzzerLookup> {
|
||||
const supabase = createAdminClient();
|
||||
const found = await loadTeam(supabase, token);
|
||||
if (!found) return linkOff;
|
||||
const { team, event, game } = found;
|
||||
|
||||
if (!game?.buzzer_enabled) return { ok: false, status: 409, error: buzzerMessages.noGame };
|
||||
if (!buzzerOpen(game)) return { ok: false, status: 409, error: buzzerMessages.closed };
|
||||
|
||||
// Once a team got it, the question is over until the host clears the buzzers.
|
||||
const decided = await supabase
|
||||
.from("buzzes")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("game_id", game.id)
|
||||
.eq("result", "correct");
|
||||
if (decided.error) throw new Error(`Could not check the buzzers: ${decided.error.message}`);
|
||||
if (decided.count) return { ok: false, status: 409, error: buzzerMessages.answered };
|
||||
|
||||
// The database stamps the time and keeps the first row per team (see 0003_buzzer.sql).
|
||||
const { error } = await supabase
|
||||
.from("buzzes")
|
||||
.upsert({ game_id: game.id, team_id: team.id }, { onConflict: "game_id,team_id", ignoreDuplicates: true });
|
||||
if (error) throw new Error(`Could not save the buzz: ${error.message}`);
|
||||
|
||||
const [payload] = await Promise.all([getBuzzerPayload(token), notify({ eventId: event.id, buzzer: true })]);
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
answeringTeamId,
|
||||
bumpScore,
|
||||
buzzerOpen,
|
||||
buzzerView,
|
||||
checkMark,
|
||||
formatBuzzGap,
|
||||
ordinal,
|
||||
type BuzzResult,
|
||||
} from "./buzzer";
|
||||
|
||||
const teams = [
|
||||
{ id: "lions", name: "Lions", color: "#E63946" },
|
||||
{ id: "eagles", name: "Eagles", color: "#3A86FF" },
|
||||
{ id: "sharks", name: "Sharks", color: "#2EC4B6" },
|
||||
];
|
||||
const live = { buzzer_enabled: true, status: "live" };
|
||||
const buzz = (team_id: string, buzzed_at: string, result: BuzzResult = null) => ({ team_id, buzzed_at, result });
|
||||
/** Buzzes in order, a tenth of a second apart. */
|
||||
const order = (...entries: Array<[string, BuzzResult?]>) =>
|
||||
entries.map(([teamId, result], index) => buzz(teamId, `2026-10-05T19:00:00.${index}00Z`, result ?? null));
|
||||
|
||||
describe("buzzerOpen", () => {
|
||||
it("is open only for a buzzer game that isn't done", () => {
|
||||
expect(buzzerOpen(live)).toBe(true);
|
||||
expect(buzzerOpen({ buzzer_enabled: true, status: "done" })).toBe(false);
|
||||
expect(buzzerOpen({ buzzer_enabled: false, status: "live" })).toBe(false);
|
||||
expect(buzzerOpen(null)).toBe(false);
|
||||
expect(buzzerOpen(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("answeringTeamId", () => {
|
||||
it("is the first buzz until the host marks it", () => {
|
||||
expect(answeringTeamId([])).toBeNull();
|
||||
expect(answeringTeamId(order(["lions"], ["eagles"]))).toBe("lions");
|
||||
});
|
||||
|
||||
it("passes down the order on a wrong answer", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"], ["sharks"]))).toBe("eagles");
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "wrong"], ["sharks"]))).toBe("sharks");
|
||||
});
|
||||
|
||||
it("is nobody when everyone so far was wrong, until the next buzz", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"]))).toBeNull();
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"]))).toBe("eagles");
|
||||
});
|
||||
|
||||
it("is nobody once a team got it, even with teams still waiting", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "correct"], ["sharks"]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buzzerView", () => {
|
||||
it("is null when there is no game or it doesn't use buzzers", () => {
|
||||
expect(buzzerView(null, teams, [])).toBeNull();
|
||||
expect(buzzerView({ buzzer_enabled: false, status: "live" }, teams, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("is open and empty before anyone buzzes", () => {
|
||||
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [], answeringTeamId: null });
|
||||
});
|
||||
|
||||
it("keeps the given order and measures each team against the first buzz", () => {
|
||||
const view = buzzerView(live, teams, [
|
||||
buzz("eagles", "2026-10-05T19:00:00.100000+00:00"),
|
||||
buzz("lions", "2026-10-05T19:00:00.520000+00:00"),
|
||||
]);
|
||||
expect(view).toEqual({
|
||||
open: true,
|
||||
answeringTeamId: "eagles",
|
||||
buzzes: [
|
||||
{ teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0, result: null },
|
||||
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420, result: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("stays open after a wrong answer, with the next team answering", () => {
|
||||
const view = buzzerView(live, teams, order(["lions", "wrong"], ["eagles"]));
|
||||
expect(view?.open).toBe(true);
|
||||
expect(view?.answeringTeamId).toBe("eagles");
|
||||
expect(view?.buzzes.map((entry) => entry.result)).toEqual(["wrong", null]);
|
||||
});
|
||||
|
||||
it("closes once a team got it", () => {
|
||||
const view = buzzerView(live, teams, order(["lions", "correct"], ["eagles"]));
|
||||
expect(view?.open).toBe(false);
|
||||
expect(view?.answeringTeamId).toBeNull();
|
||||
});
|
||||
|
||||
it("still shows who buzzed once the game is done, but closed", () => {
|
||||
const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, order(["lions"]));
|
||||
expect(view?.open).toBe(false);
|
||||
expect(view?.buzzes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips a buzz from a team that has since been deleted", () => {
|
||||
const view = buzzerView(live, teams, order(["gone"], ["lions"]));
|
||||
expect(view?.buzzes).toEqual([
|
||||
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 0, result: null },
|
||||
]);
|
||||
expect(view?.answeringTeamId).toBe("lions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMark", () => {
|
||||
const waiting = order(["lions"], ["eagles"]);
|
||||
|
||||
it("lets the host mark the answering team, and a correct answer earns a point", () => {
|
||||
expect(checkMark(waiting, "lions", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
|
||||
expect(checkMark(waiting, "lions", "wrong")).toEqual({ ok: true, changed: true, pointDelta: 0 });
|
||||
});
|
||||
|
||||
it("refuses a team that hasn't buzzed or whose turn it isn't", () => {
|
||||
expect(checkMark(waiting, "sharks", "correct").ok).toBe(false);
|
||||
expect(checkMark(waiting, "eagles", "correct").ok).toBe(false);
|
||||
// Decided already: nobody else can be marked.
|
||||
expect(checkMark(order(["lions", "correct"], ["eagles"]), "eagles", "wrong").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("moves on to the next team after a wrong answer", () => {
|
||||
const afterWrong = order(["lions", "wrong"], ["eagles"]);
|
||||
expect(checkMark(afterWrong, "eagles", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
|
||||
expect(checkMark(afterWrong, "lions", "correct").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when the mark is already what was asked (a double click)", () => {
|
||||
expect(checkMark(order(["lions", "correct"]), "lions", "correct")).toEqual({ ok: true, changed: false, pointDelta: 0 });
|
||||
expect(checkMark(waiting, "lions", null)).toEqual({ ok: true, changed: false, pointDelta: 0 });
|
||||
});
|
||||
|
||||
it("undoes a mark, taking the point back if it was correct", () => {
|
||||
expect(checkMark(order(["lions", "correct"]), "lions", null)).toEqual({ ok: true, changed: true, pointDelta: -1 });
|
||||
expect(checkMark(order(["lions", "wrong"], ["eagles"]), "lions", null)).toEqual({
|
||||
ok: true,
|
||||
changed: true,
|
||||
pointDelta: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bumpScore", () => {
|
||||
it("adds a point, starting from zero when there is no score yet", () => {
|
||||
expect(bumpScore(null, 1, 0)).toBe(1);
|
||||
expect(bumpScore(4, 1, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("takes the point back, clearing the cell rather than going below the minimum", () => {
|
||||
expect(bumpScore(5, -1, 0)).toBe(4);
|
||||
expect(bumpScore(1, -1, 0)).toBe(0);
|
||||
expect(bumpScore(1, -1, 1)).toBeNull();
|
||||
expect(bumpScore(null, -1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting", () => {
|
||||
it("shows the gap behind the first buzz in hundredths of a second", () => {
|
||||
expect(formatBuzzGap(420)).toBe("+0.42s");
|
||||
expect(formatBuzzGap(1500)).toBe("+1.50s");
|
||||
});
|
||||
|
||||
it("writes positions as ordinals", () => {
|
||||
expect([1, 2, 3, 4, 11, 12, 13, 21, 22, 103].map(ordinal)).toEqual([
|
||||
"1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "103rd",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Types and rules for the team buzzers, shared by the buzzer API, the scoreboard and the
|
||||
// control room. Pure: no DB access.
|
||||
|
||||
export const buzzerMessages = {
|
||||
linkOff: "This link has been turned off. Ask the host for a new one.",
|
||||
noGame: "No buzzer for this game. Keep this page open.",
|
||||
closed: "This game is finished, so the buzzers are closed.",
|
||||
answered: "This question has been answered. Wait for the next one.",
|
||||
} as const;
|
||||
|
||||
/** How the host marked a team's answer; null until they do. */
|
||||
export type BuzzResult = "correct" | "wrong" | null;
|
||||
|
||||
export type BuzzEntry = {
|
||||
teamId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** How long after the first buzz this team buzzed; 0 for the first. */
|
||||
afterFirstMs: number;
|
||||
result: BuzzResult;
|
||||
};
|
||||
|
||||
export type BuzzerView = {
|
||||
/** Teams can buzz right now. False once someone got it, or the game is marked done. */
|
||||
open: boolean;
|
||||
/** In the order the teams buzzed. Empty until someone does, and again after a clear. */
|
||||
buzzes: BuzzEntry[];
|
||||
/** Whose turn it is to answer, or null: nobody is waiting, or the question is decided. */
|
||||
answeringTeamId: string | null;
|
||||
};
|
||||
|
||||
/** What a team's phone gets. Nothing here is a score, so it ignores scoreboard visibility. */
|
||||
export type BuzzerPayload = {
|
||||
/** For the realtime channel name only. */
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
team: { id: string; name: string; color: string };
|
||||
currentGame: { id: string; title: string } | null;
|
||||
/** Null when there is no current game or it doesn't use buzzers. */
|
||||
buzzer: BuzzerView | null;
|
||||
};
|
||||
|
||||
type GameFacts = { buzzer_enabled: boolean; status: string };
|
||||
type BuzzFacts = { team_id: string; result: BuzzResult };
|
||||
|
||||
/** Buzzers work only for the current game, only if it uses them, and only until it's done. */
|
||||
export function buzzerOpen(game: GameFacts | null | undefined): boolean {
|
||||
return Boolean(game?.buzzer_enabled) && game?.status !== "done";
|
||||
}
|
||||
|
||||
/**
|
||||
* The team whose turn it is: the earliest buzz the host hasn't marked. A wrong answer passes
|
||||
* the turn down the order; a correct one ends the question. `buzzes` must be in buzz order.
|
||||
*/
|
||||
export function answeringTeamId(buzzes: BuzzFacts[]): string | null {
|
||||
if (buzzes.some((buzz) => buzz.result === "correct")) return null;
|
||||
return buzzes.find((buzz) => buzz.result === null)?.team_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The buzzer state for the current game, or null when it doesn't use buzzers.
|
||||
* `buzzes` must already be in buzz order (the database's clock decides, see 0003_buzzer.sql).
|
||||
*/
|
||||
export function buzzerView(
|
||||
game: GameFacts | null | undefined,
|
||||
teams: Array<{ id: string; name: string; color: string }>,
|
||||
buzzes: Array<BuzzFacts & { buzzed_at: string }>,
|
||||
): BuzzerView | null {
|
||||
if (!game?.buzzer_enabled) return null;
|
||||
|
||||
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||
// A deleted team's buzzes are deleted with it; this only covers the moment in between.
|
||||
const known = buzzes.filter((buzz) => teamsById.has(buzz.team_id));
|
||||
const firstAt = known.length ? Date.parse(known[0].buzzed_at) : 0;
|
||||
|
||||
return {
|
||||
open: buzzerOpen(game) && !known.some((buzz) => buzz.result === "correct"),
|
||||
buzzes: known.map((buzz) => {
|
||||
const team = teamsById.get(buzz.team_id)!;
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt),
|
||||
result: buzz.result,
|
||||
};
|
||||
}),
|
||||
answeringTeamId: answeringTeamId(known),
|
||||
};
|
||||
}
|
||||
|
||||
export type MarkCheck =
|
||||
| { ok: true; changed: boolean; pointDelta: -1 | 0 | 1 }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* May the host mark this team's buzz as `result`, and does the team's score move?
|
||||
* Marking correct or wrong is only for the team answering; `null` undoes an earlier mark.
|
||||
*/
|
||||
export function checkMark(buzzes: BuzzFacts[], teamId: string, result: BuzzResult): MarkCheck {
|
||||
const buzz = buzzes.find((candidate) => candidate.team_id === teamId);
|
||||
if (!buzz) return { ok: false, error: "That team hasn't buzzed for this question. Reload the page." };
|
||||
if (buzz.result === result) return { ok: true, changed: false, pointDelta: 0 };
|
||||
if (result !== null && answeringTeamId(buzzes) !== teamId) {
|
||||
return { ok: false, error: "It isn't that team's turn to answer. Reload the page." };
|
||||
}
|
||||
|
||||
const pointDelta = Number(result === "correct") - Number(buzz.result === "correct");
|
||||
return { ok: true, changed: true, pointDelta: pointDelta as -1 | 0 | 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's score in the buzzer criterion after gaining a point for a correct answer, or
|
||||
* losing it again when the host undoes that. null = no score. Taking the point back never
|
||||
* goes below the criterion's minimum: the cell is cleared instead.
|
||||
*/
|
||||
export function bumpScore(current: number | null, pointDelta: -1 | 1, minValue: number): number | null {
|
||||
if (pointDelta === 1) return (current ?? 0) + 1;
|
||||
if (current === null) return null;
|
||||
return current - 1 < minValue ? null : current - 1;
|
||||
}
|
||||
|
||||
/** "+0.42s" behind the first buzz, for the host's list. */
|
||||
export function formatBuzzGap(afterFirstMs: number): string {
|
||||
return `+${(afterFirstMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
|
||||
export function ordinal(position: number): string {
|
||||
const lastTwo = position % 100;
|
||||
if (lastTwo >= 11 && lastTwo <= 13) return `${position}th`;
|
||||
return `${position}${["th", "st", "nd", "rd"][position % 10] ?? "th"}`;
|
||||
}
|
||||
@@ -58,6 +58,45 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
buzzes: {
|
||||
Row: {
|
||||
buzzed_at: string;
|
||||
game_id: string;
|
||||
id: string;
|
||||
result: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id: string;
|
||||
};
|
||||
Insert: {
|
||||
buzzed_at?: string;
|
||||
game_id: string;
|
||||
id?: string;
|
||||
result?: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id: string;
|
||||
};
|
||||
Update: {
|
||||
buzzed_at?: string;
|
||||
game_id?: string;
|
||||
id?: string;
|
||||
result?: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "buzzes_game_id_fkey";
|
||||
columns: ["game_id"];
|
||||
isOneToOne: false;
|
||||
referencedRelation: "games";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "buzzes_team_id_fkey";
|
||||
columns: ["team_id"];
|
||||
isOneToOne: false;
|
||||
referencedRelation: "teams";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
criteria: {
|
||||
Row: {
|
||||
description: string | null;
|
||||
@@ -105,6 +144,35 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
event_cohosts: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
email: string;
|
||||
event_id: string;
|
||||
id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
email: string;
|
||||
event_id: string;
|
||||
id?: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
email?: string;
|
||||
event_id?: string;
|
||||
id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_cohosts_event_id_fkey";
|
||||
columns: ["event_id"];
|
||||
isOneToOne: false;
|
||||
referencedRelation: "events";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
events: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
@@ -118,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;
|
||||
@@ -136,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;
|
||||
@@ -154,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;
|
||||
@@ -168,11 +248,66 @@ 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: {
|
||||
Row: {
|
||||
admin_notes: string | null;
|
||||
buzzer_enabled: boolean;
|
||||
created_at: string;
|
||||
description: string | null;
|
||||
event_id: string;
|
||||
@@ -193,6 +328,7 @@ export type Database = {
|
||||
};
|
||||
Insert: {
|
||||
admin_notes?: string | null;
|
||||
buzzer_enabled?: boolean;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
event_id: string;
|
||||
@@ -213,6 +349,7 @@ export type Database = {
|
||||
};
|
||||
Update: {
|
||||
admin_notes?: string | null;
|
||||
buzzer_enabled?: boolean;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
event_id?: string;
|
||||
@@ -347,6 +484,7 @@ export type Database = {
|
||||
};
|
||||
teams: {
|
||||
Row: {
|
||||
buzzer_token: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
event_id: string;
|
||||
@@ -355,6 +493,7 @@ export type Database = {
|
||||
sort_order: number;
|
||||
};
|
||||
Insert: {
|
||||
buzzer_token?: string;
|
||||
color?: string;
|
||||
created_at?: string;
|
||||
event_id: string;
|
||||
@@ -363,6 +502,7 @@ export type Database = {
|
||||
sort_order?: number;
|
||||
};
|
||||
Update: {
|
||||
buzzer_token?: string;
|
||||
color?: string;
|
||||
created_at?: string;
|
||||
event_id?: string;
|
||||
@@ -388,7 +528,9 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
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";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buzzerView, type BuzzerView } from "@/lib/buzzer";
|
||||
import type { EventData } from "@/lib/queries/scoring";
|
||||
import { toScoringInput } from "@/lib/queries/scoring";
|
||||
import {
|
||||
@@ -9,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
|
||||
@@ -37,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;
|
||||
@@ -47,6 +59,12 @@ export type LiveGame = {
|
||||
adminNotes: string | null;
|
||||
judgingLocked: boolean;
|
||||
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[];
|
||||
@@ -108,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,
|
||||
@@ -118,6 +137,21 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView {
|
||||
adminNotes: game.admin_notes,
|
||||
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;
|
||||
|
||||
@@ -13,8 +13,22 @@ export type EventData = {
|
||||
judges: Tables<"judges">[];
|
||||
scores: Tables<"scores">[];
|
||||
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. */
|
||||
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
|
||||
@@ -44,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, 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"),
|
||||
@@ -60,9 +74,12 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
|
||||
.eq("games.event_id", event.id)
|
||||
.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;
|
||||
const 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 {
|
||||
@@ -72,6 +89,8 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
|
||||
adjustments: adjustments.data ?? [],
|
||||
criteria: criteria.data ?? [],
|
||||
judges: judges.data ?? [],
|
||||
buzzes: buzzes.data ?? [],
|
||||
items: items.data ?? [],
|
||||
scores,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export const channelNames = {
|
||||
scoreboard: (scoreboardToken: string) => `sb:${scoreboardToken}`,
|
||||
game: (gameId: string) => `game:${gameId}`,
|
||||
admin: (eventId: string) => `admin:${eventId}`,
|
||||
buzzer: (eventId: string) => `buzzer:${eventId}`,
|
||||
};
|
||||
|
||||
+8
-2
@@ -4,7 +4,12 @@ import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
import { channelNames, REFRESH_EVENT } from "./realtime-channels";
|
||||
|
||||
type NotifyArgs = { eventId: string; gameId?: string };
|
||||
type NotifyArgs = {
|
||||
eventId: string;
|
||||
gameId?: string;
|
||||
/** Also ping the teams' buzzer pages: for buzzes, and for changes to what those pages show. */
|
||||
buzzer?: boolean;
|
||||
};
|
||||
|
||||
// A scoreboard token never changes, so remember it and save a DB round trip per ping.
|
||||
const scoreboardTokens = new Map<string, string>();
|
||||
@@ -14,7 +19,7 @@ const scoreboardTokens = new Map<string, string>();
|
||||
* The message carries no data (CLAUDE.md rule 4): hidden/frozen rules live only in the API.
|
||||
* Never throws: the mutation already succeeded, and clients poll every 10s as a fallback.
|
||||
*/
|
||||
export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
|
||||
export async function notify({ eventId, gameId, buzzer }: NotifyArgs): Promise<void> {
|
||||
try {
|
||||
const supabase = createAdminClient();
|
||||
let scoreboardToken = scoreboardTokens.get(eventId);
|
||||
@@ -31,6 +36,7 @@ export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
|
||||
const topics = [channelNames.admin(eventId)];
|
||||
if (scoreboardToken) topics.push(channelNames.scoreboard(scoreboardToken));
|
||||
if (gameId) topics.push(channelNames.game(gameId));
|
||||
if (buzzer) topics.push(channelNames.buzzer(eventId));
|
||||
|
||||
await Promise.all(
|
||||
topics.map(async (topic) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const payload = (overrides: Partial<ScoreboardPayload> = {}): ScoreboardPayload
|
||||
reveal: null,
|
||||
teams: [team("a", 1), team("b", 2), team("c", 3)],
|
||||
timer: null,
|
||||
buzzer: null,
|
||||
onScreen: null,
|
||||
updatedAt: "2026-10-03T12:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
@@ -65,6 +67,49 @@ describe("soundsForUpdate", () => {
|
||||
it("is silent when the reveal finishes and the normal scoreboard returns", () => {
|
||||
expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]);
|
||||
});
|
||||
|
||||
// Team ids in buzz order; "a:wrong" / "a:correct" is a buzz the host has marked.
|
||||
const buzzed = (...marks: string[]): Partial<ScoreboardPayload> => {
|
||||
const buzzes = marks.map((mark, index) => {
|
||||
const [teamId, result = null] = mark.split(":") as [string, ("correct" | "wrong")?];
|
||||
return { teamId, name: teamId, color: "#000000", afterFirstMs: index * 100, result };
|
||||
});
|
||||
const decided = buzzes.some((entry) => entry.result === "correct");
|
||||
return {
|
||||
buzzer: {
|
||||
open: !decided,
|
||||
buzzes,
|
||||
answeringTeamId: decided ? null : (buzzes.find((entry) => entry.result === null)?.teamId ?? null),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("plays the verdict when the host marks an answer", () => {
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:correct")), motion)).toEqual(["correct"]);
|
||||
// Wrong with the next team already waiting: just the "wrong", no ding on top.
|
||||
expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["wrong"]);
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:wrong")), motion)).toEqual(["wrong"]);
|
||||
// Already marked: silent on the next poll.
|
||||
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a:correct")), motion)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dings when a team buzzes in after everyone before it was wrong, or a mark is undone", () => {
|
||||
expect(soundsForUpdate(payload(buzzed("a:wrong")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["buzzIn"]);
|
||||
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
|
||||
});
|
||||
|
||||
it("dings for a new first buzz only", () => {
|
||||
expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
|
||||
// A second team buzzing, or nothing changing, is silent.
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a", "b")), motion)).toEqual([]);
|
||||
// Cleared for the next question: silent, then a ding again even for the same team.
|
||||
expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed()), motion)).toEqual([]);
|
||||
expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
|
||||
// Cleared and re-buzzed by another team between two fetches.
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("b")), motion)).toEqual(["buzzIn"]);
|
||||
// Moving to a game without buzzers.
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(), motion)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("soundForCountdown", () => {
|
||||
|
||||
@@ -32,6 +32,18 @@ export function soundsForUpdate(
|
||||
else sounds.push(isWinner ? "revealWinner" : "reveal");
|
||||
}
|
||||
}
|
||||
|
||||
// Buzzers: the host's verdict if there is a new one, otherwise a ding for a new team
|
||||
// answering. Teams that buzz in behind it are silent: the room only needs to hear who's up.
|
||||
const marksBefore = new Map(before.buzzer?.buzzes.map((entry) => [entry.teamId, entry.result]));
|
||||
const newlyMarked = (result: "correct" | "wrong") =>
|
||||
after.buzzer?.buzzes.some((entry) => entry.result === result && marksBefore.get(entry.teamId) !== result) ?? false;
|
||||
const answering = after.buzzer?.answeringTeamId;
|
||||
if (newlyMarked("correct")) sounds.push("correct");
|
||||
// The next team's turn starts with this same update; one sound, not two on top of each other.
|
||||
else if (newlyMarked("wrong")) sounds.push("wrong");
|
||||
else if (answering && answering !== before.buzzer?.answeringTeamId) sounds.push("buzzIn");
|
||||
|
||||
return sounds;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import "server-only";
|
||||
|
||||
import { buzzerView } from "@/lib/buzzer";
|
||||
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: {
|
||||
@@ -22,6 +48,12 @@ export function toScoreboardInput(data: EventData): ScoreboardInput {
|
||||
teams: data.teams,
|
||||
games: data.games,
|
||||
standings: computeEvent(toScoringInput(data)).standings,
|
||||
buzzer: buzzerView(
|
||||
data.games.find((game) => game.id === event.current_game_id),
|
||||
data.teams,
|
||||
data.buzzes,
|
||||
),
|
||||
onScreen,
|
||||
now: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -43,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)));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ const input = (event: Partial<ScoreboardInput["event"]> = {}): ScoreboardInput =
|
||||
standing("eagles", 2, 20),
|
||||
standing("sharks", 4, 5),
|
||||
],
|
||||
buzzer: null,
|
||||
onScreen: null,
|
||||
now: "2026-10-03T12:00:00.000Z",
|
||||
});
|
||||
|
||||
@@ -180,3 +182,46 @@ describe("timer", () => {
|
||||
expect(hasNoScores(payload.teams)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buzzer", () => {
|
||||
const buzzer = {
|
||||
open: true,
|
||||
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0, result: null }],
|
||||
answeringTeamId: "sharks",
|
||||
};
|
||||
|
||||
it("is null when the current game has no buzzers", () => {
|
||||
expect(buildScoreboardPayload(input()).buzzer).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 }), buzzer });
|
||||
expect(payload.buzzer).toEqual(buzzer);
|
||||
if (visibility !== "visible") expect(hasNoScores(payload.teams)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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).
|
||||
@@ -25,6 +27,10 @@ export type ScoreboardPayload = {
|
||||
teams: ScoreboardTeam[];
|
||||
/** Null while the host has the timer hidden. Not a score, so it ignores frozen/hidden. */
|
||||
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;
|
||||
};
|
||||
@@ -44,6 +50,10 @@ export type ScoreboardInput = {
|
||||
games: Array<{ id: string; title: string }>;
|
||||
/** From the scoring engine, already sorted for display. */
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -128,6 +138,8 @@ export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayloa
|
||||
: null,
|
||||
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;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export type SoundName =
|
||||
| "revealWinner" // drum-roll, then a fanfare for first place
|
||||
| "crash" // a team appears with no drum-roll (reduced motion)
|
||||
| "game" // a new game starts
|
||||
| "buzzIn" // a team is first on the buzzer
|
||||
| "correct" // the answering team got it
|
||||
| "wrong" // the answering team got it wrong
|
||||
| "chime"; // confirmation when sound is switched on
|
||||
|
||||
/** Matches the scoreboard's reveal animation: the cover lifts 70% into 1800ms. */
|
||||
@@ -135,6 +138,24 @@ export function createSoundPlayer(): SoundPlayer {
|
||||
tone(start + index * 0.11, frequency * 2, 0.4, 0.08, "sine");
|
||||
});
|
||||
},
|
||||
buzzIn: (start) => {
|
||||
// A bright two-note "ding-ding", nothing like the low time's-up buzzer.
|
||||
tone(start, 987.77, 0.14, 0.4, "square");
|
||||
tone(start + 0.11, 1318.51, 0.45, 0.4, "square");
|
||||
tone(start + 0.11, 2637.02, 0.3, 0.1, "sine");
|
||||
},
|
||||
correct: (start) => {
|
||||
// A quick rising arpeggio that lands on a bright chord.
|
||||
[659.25, 830.61, 987.77, 1318.51].forEach((frequency, index) => {
|
||||
tone(start + index * 0.07, frequency, index === 3 ? 0.7 : 0.18, 0.3, "triangle");
|
||||
tone(start + index * 0.07, frequency * 2, index === 3 ? 0.5 : 0.12, 0.08, "sine");
|
||||
});
|
||||
},
|
||||
wrong: (start) => {
|
||||
// Two short falling notes: "uh-oh". Shorter and higher than the time's-up buzzer.
|
||||
tone(start, 311.13, 0.22, 0.4, "sawtooth");
|
||||
tone(start + 0.24, 233.08, 0.45, 0.4, "sawtooth");
|
||||
},
|
||||
chime: (start) => {
|
||||
tone(start, 783.99, 0.35, 0.3, "sine");
|
||||
tone(start + 0.12, 1046.5, 0.5, 0.3, "sine");
|
||||
|
||||
@@ -7,5 +7,6 @@ export const tokenSchema = z.string().regex(/^[0-9a-f]{32}$/, "Invalid link");
|
||||
export const scoreboardTokenSchema = tokenSchema;
|
||||
export const judgeTokenSchema = tokenSchema;
|
||||
export const openTokenSchema = tokenSchema;
|
||||
export const buzzerTokenSchema = tokenSchema;
|
||||
|
||||
export const idSchema = z.uuid();
|
||||
|
||||
@@ -2,7 +2,15 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { criteriaPresets } from "@/lib/presets";
|
||||
|
||||
import { 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;
|
||||
|
||||
@@ -13,6 +21,7 @@ const game: GameDraft = {
|
||||
pointsMode: "raw",
|
||||
multiplier: 1,
|
||||
placementPoints: [10, 7, 5, 3],
|
||||
buzzerEnabled: false,
|
||||
adminNotes: "",
|
||||
judgeNotes: "",
|
||||
criteria: [criterion],
|
||||
@@ -64,6 +73,18 @@ describe("gameSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cohostEmailSchema", () => {
|
||||
it("trims and lower-cases, so it matches the email in the login token", () => {
|
||||
expect(cohostEmailSchema.parse(" Sam@Example.COM ")).toBe("sam@example.com");
|
||||
});
|
||||
|
||||
it("rejects things that aren't an email", () => {
|
||||
expect(cohostEmailSchema.safeParse("").success).toBe(false);
|
||||
expect(cohostEmailSchema.safeParse("sam").success).toBe(false);
|
||||
expect(cohostEmailSchema.safeParse("sam@example.com, kim@example.com").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlacementPoints", () => {
|
||||
it("splits on commas and spaces", () => {
|
||||
expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]);
|
||||
@@ -71,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
|
||||
@@ -22,6 +23,13 @@ export const eventSchema = z.object({
|
||||
eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]),
|
||||
});
|
||||
|
||||
/** A co-host's email, normalised the way event_cohosts stores it (trimmed, lower case). */
|
||||
export const cohostEmailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(z.email("Enter a valid email address.").max(254, "That email is too long."));
|
||||
|
||||
export const teamSchema = z.object({
|
||||
name: text(60, "Team name"),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."),
|
||||
@@ -78,11 +86,40 @@ export const gameSchema = z.object({
|
||||
.array(z.number().int("Placement points must be whole numbers.").min(0).max(100000))
|
||||
.min(1, "Add at least one placement value, like 10, 7, 5, 3.")
|
||||
.max(30),
|
||||
buzzerEnabled: z.boolean(),
|
||||
adminNotes: optionalText(4000, "Admin notes"),
|
||||
judgeNotes: optionalText(4000, "Judge notes"),
|
||||
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,44 @@
|
||||
-- Game Night Live — team buzzers.
|
||||
-- Each team gets its own buzzer link; a game opts in with buzzer_enabled.
|
||||
-- Run after 0002_timer.sql.
|
||||
--
|
||||
-- A buzz is one row per (game, team). The database's clock stamps it, so the order never
|
||||
-- depends on a phone's clock or on which server instance handled the request.
|
||||
-- Clearing the buzzers for the next question = deleting the game's rows.
|
||||
|
||||
alter table games
|
||||
add column buzzer_enabled boolean not null default false;
|
||||
|
||||
-- new_token() is volatile, so every existing team gets its own token here.
|
||||
alter table teams
|
||||
add column buzzer_token text not null unique default new_token();
|
||||
|
||||
create table buzzes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
game_id uuid not null references games(id) on delete cascade,
|
||||
team_id uuid not null references teams(id) on delete cascade,
|
||||
-- clock_timestamp(), not now(): the real moment of the insert, not the transaction's start.
|
||||
buzzed_at timestamptz not null default clock_timestamp(),
|
||||
constraint buzzes_one_per_team unique (game_id, team_id)
|
||||
);
|
||||
|
||||
-- the team must be in the same event as the game
|
||||
create or replace function validate_buzz() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
if (select event_id from teams where id = new.team_id)
|
||||
is distinct from (select event_id from games where id = new.game_id) then
|
||||
raise exception 'team does not belong to the game''s event';
|
||||
end if;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
create trigger buzzes_validate
|
||||
before insert or update on buzzes
|
||||
for each row execute function validate_buzz();
|
||||
|
||||
-- Same rule as every other table: owner only, anon has no policies.
|
||||
alter table buzzes enable row level security;
|
||||
|
||||
create policy owner_all on buzzes for all to authenticated
|
||||
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Game Night Live — marking buzzer answers.
|
||||
-- The host marks the answering team's buzz correct or wrong. Run after 0003_buzzer.sql.
|
||||
--
|
||||
-- result null : not judged yet. The first such buzz (in buzz order) is the team answering,
|
||||
-- as long as nobody has been marked correct.
|
||||
-- result wrong : that team is out for this question; the turn passes to the next buzz.
|
||||
-- result correct : the question is decided; no more buzzes until the host clears.
|
||||
|
||||
create type buzz_result as enum ('correct', 'wrong');
|
||||
|
||||
alter table buzzes
|
||||
add column result buzz_result;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Game Night Live — sharing an event with other admins (co-hosts).
|
||||
-- Run after 0004_buzz_result.sql.
|
||||
--
|
||||
-- The owner shares an event by email. A co-host can do everything in the event (setup, control
|
||||
-- room, scores) except delete it or change who it is shared with.
|
||||
--
|
||||
-- Shares are keyed by email, not user id, so an admin can be added before their first login.
|
||||
-- The email comes from the signed JWT, which is only as trustworthy as the project's auth
|
||||
-- settings: keep "Confirm email" on (magic links confirm it anyway). ADMIN_EMAILS relies on
|
||||
-- the same claim.
|
||||
|
||||
create table event_cohosts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
event_id uuid not null references events(id) on delete cascade,
|
||||
email text not null check (email = lower(email) and length(email) between 3 and 254),
|
||||
created_at timestamptz not null default now(),
|
||||
constraint event_cohosts_one_per_email unique (event_id, email)
|
||||
);
|
||||
create index on event_cohosts(email);
|
||||
|
||||
-- ---------- helpers ----------
|
||||
-- security definer: they read events / event_cohosts without going back through RLS.
|
||||
|
||||
create or replace function is_event_cohost(eid uuid) returns boolean
|
||||
language sql stable security definer set search_path = public as $$
|
||||
select exists (
|
||||
select 1 from event_cohosts
|
||||
where event_id = eid and email = lower(auth.jwt() ->> 'email')
|
||||
)
|
||||
$$;
|
||||
|
||||
create or replace function is_event_admin(eid uuid) returns boolean
|
||||
language sql stable security definer set search_path = public as $$
|
||||
select is_event_owner(eid) or is_event_cohost(eid)
|
||||
$$;
|
||||
|
||||
-- A co-host may update the event row, so the policy can't be what protects the owner column.
|
||||
create or replace function keep_event_owner() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
if new.owner_id is distinct from old.owner_id then
|
||||
raise exception 'the owner of an event cannot be changed';
|
||||
end if;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
create trigger events_keep_owner
|
||||
before update on events
|
||||
for each row execute function keep_event_owner();
|
||||
|
||||
-- ---------- RLS ----------
|
||||
-- Still no anon policies anywhere.
|
||||
|
||||
alter table event_cohosts enable row level security;
|
||||
|
||||
-- Co-hosts can see who else is on the event; only the owner changes the list.
|
||||
create policy admin_read on event_cohosts for select to authenticated
|
||||
using (is_event_admin(event_id));
|
||||
create policy owner_insert on event_cohosts for insert to authenticated
|
||||
with check (is_event_owner(event_id));
|
||||
create policy owner_delete on event_cohosts for delete to authenticated
|
||||
using (is_event_owner(event_id));
|
||||
|
||||
-- events: the owner check stays on the column (not is_event_admin) so that
|
||||
-- INSERT ... RETURNING can see the row it has just inserted.
|
||||
drop policy owner_all on events;
|
||||
create policy admin_read on events for select to authenticated
|
||||
using (owner_id = auth.uid() or is_event_cohost(id));
|
||||
create policy owner_insert on events for insert to authenticated
|
||||
with check (owner_id = auth.uid());
|
||||
create policy admin_update on events for update to authenticated
|
||||
using (owner_id = auth.uid() or is_event_cohost(id))
|
||||
with check (owner_id = auth.uid() or is_event_cohost(id));
|
||||
create policy owner_delete on events for delete to authenticated
|
||||
using (owner_id = auth.uid());
|
||||
|
||||
-- everything inside an event: owner or co-host
|
||||
drop policy owner_all on teams;
|
||||
drop policy owner_all on games;
|
||||
drop policy owner_all on adjustments;
|
||||
drop policy owner_all on criteria;
|
||||
drop policy owner_all on judges;
|
||||
drop policy owner_all on scores;
|
||||
drop policy owner_all on buzzes;
|
||||
|
||||
create policy admin_all on teams for all to authenticated
|
||||
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
|
||||
create policy admin_all on games for all to authenticated
|
||||
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
|
||||
create policy admin_all on adjustments for all to authenticated
|
||||
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
|
||||
create policy admin_all on criteria for all to authenticated
|
||||
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
|
||||
create policy admin_all on judges for all to authenticated
|
||||
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
|
||||
create policy admin_all on scores for all to authenticated
|
||||
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
|
||||
create policy admin_all on buzzes for all to authenticated
|
||||
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
|
||||
@@ -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