Files
game-scoreboard/src/lib/validation/admin.ts
T
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:08 +02:00

137 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// the admin sees a friendly message instead of a database error.
const text = (max: number, what: string) =>
z
.string()
.trim()
.min(1, `${what} can't be empty.`)
.max(max, `${what} can be at most ${max} characters.`);
const optionalText = (max: number, what: string) =>
z.string().trim().max(max, `${what} can be at most ${max} characters.`);
const number = (what: string) => z.number(`${what} must be a number.`);
export const eventSchema = z.object({
name: text(120, "Event name"),
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."),
});
/** Is (max − min) a whole number of steps? Tolerates float error for steps like 0.5. */
const onStep = (min: number, max: number, step: number) => {
const steps = (max - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-9;
};
export const criterionSchema = z
.object({
id: idSchema.optional(),
label: text(60, "Criterion label"),
description: optionalText(300, "Criterion description"),
inputType: z.enum(["blocks", "number"]),
min: number("Min").min(-999999).max(999999),
max: number("Max").min(-999999).max(999999),
step: number("Step").positive("Step must be more than 0.").max(999999),
weight: number("Weight").min(0, "Weight can't be negative.").max(9999),
})
.superRefine((criterion, context) => {
const name = criterion.label || "A criterion";
if (criterion.max <= criterion.min) {
context.addIssue({ code: "custom", message: `${name}: max must be more than min.` });
return;
}
if (!onStep(criterion.min, criterion.max, criterion.step)) {
context.addIssue({
code: "custom",
message: `${name}: the step must divide evenly into the range from min to max.`,
});
return;
}
if (
criterion.inputType === "blocks" &&
(criterion.max - criterion.min) / criterion.step > 10
) {
context.addIssue({
code: "custom",
message: `${name}: blocks can show at most 11 buttons. Use a number input or a bigger step.`,
});
}
});
export const gameSchema = z.object({
title: text(120, "Game title"),
description: optionalText(2000, "Description"),
scoringMode: z.enum(["judged", "direct"]),
pointsMode: z.enum(["raw", "placement"]),
multiplier: number("Multiplier").min(0, "Multiplier can't be negative.").max(9999),
placementPoints: z
.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>;
/** Parses "10, 7, 5, 3" into numbers; invalid parts become NaN so validation reports them. */
export function parsePlacementPoints(value: string): number[] {
return value
.split(/[,\s]+/)
.filter(Boolean)
.map((part) => Number(part));
}
export function firstError(error: z.ZodError): string {
return error.issues[0]?.message ?? "Something in the form isn't valid.";
}