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>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
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.";
|
||
}
|