- Share links section: scoreboard link, every judged game's judge links and open link with QR codes, and 'Copy all judge links' as plain text for WhatsApp. Warns when links point at localhost. - Error and not-found pages with plain-language copy; a real start page. - Accessibility: darker 'alert strong' red for text and white-on-red (Alert was 4.0:1 on white), no text below 70% ink, 44px block buttons and link targets, scoreboard status stays readable when idle. - vercel.json pins functions to Dublin next to the West EU database; README documents deployment and pre-game-night checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { cancelReveal, finishReveal, revealNext, setVisibility, startReveal } from "@/app/admin/live-actions";
|
|
import { Button, buttonClass } from "@/components/ui/button";
|
|
import { formatScore } from "@/lib/criteria";
|
|
import type { LiveView } from "@/lib/live-view";
|
|
import type { Visibility } from "@/lib/scoreboard";
|
|
|
|
import { ErrorText } from "./error-text";
|
|
import { useAction } from "./use-action";
|
|
|
|
export const VISIBILITY_ORDER: Visibility[] = ["visible", "frozen", "hidden"];
|
|
|
|
const options: Record<Visibility, { action: string; state: string; explain: string }> = {
|
|
visible: { action: "Show scores", state: "Visible", explain: "The room sees live scores." },
|
|
frozen: {
|
|
action: "Freeze scores",
|
|
state: "Frozen",
|
|
explain: "The room sees the scores from the moment you froze them.",
|
|
},
|
|
hidden: { action: "Hide scores", state: "Hidden", explain: "The room sees team names only, no scores." },
|
|
};
|
|
|
|
const ordinal = (rank: number) => {
|
|
const suffix = rank % 100 >= 11 && rank % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][rank % 10] ?? "th");
|
|
return `${rank}${suffix}`;
|
|
};
|
|
|
|
export function ScoreboardPanel({ view }: { view: LiveView }) {
|
|
const { event, reveal, teams } = view;
|
|
const { pending, error, run } = useAction();
|
|
const nextTeam = reveal.next ? teams.find((team) => team.id === reveal.next?.teamId) : undefined;
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
{reveal.active ? (
|
|
<div className="flex flex-wrap items-center gap-2 rounded-md border-2 border-floodlight bg-floodlight/15 p-3">
|
|
<p className="mr-2">
|
|
<strong>
|
|
Reveal mode · {reveal.revealedCount} of {teams.length} revealed.
|
|
</strong>{" "}
|
|
{reveal.next ? (
|
|
// Only the host sees who is next, so they can build it up out loud.
|
|
<>
|
|
Next: <strong>{ordinal(reveal.next.rank)} place</strong> — {nextTeam?.name} (
|
|
{formatScore(reveal.next.total)})
|
|
</>
|
|
) : (
|
|
"Every team is revealed."
|
|
)}
|
|
</p>
|
|
{reveal.next && (
|
|
<Button disabled={pending} onClick={() => run(() => revealNext(event.id))}>
|
|
Reveal next
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant={reveal.next ? "secondary" : "primary"}
|
|
disabled={pending}
|
|
onClick={() => run(() => finishReveal(event.id))}
|
|
>
|
|
Finish and show scores
|
|
</Button>
|
|
<Button variant="secondary" disabled={pending} onClick={() => run(() => cancelReveal(event.id))}>
|
|
Cancel reveal
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
role="group"
|
|
aria-label="What the room sees"
|
|
className="grid w-full max-w-md grid-cols-3 gap-1"
|
|
>
|
|
{VISIBILITY_ORDER.map((visibility) => {
|
|
const selected = event.visibility === visibility;
|
|
return (
|
|
<button
|
|
key={visibility}
|
|
type="button"
|
|
aria-pressed={selected}
|
|
disabled={pending}
|
|
onClick={() => run(() => setVisibility(event.id, visibility))}
|
|
className={`min-h-14 rounded-md border px-1 text-sm font-bold disabled:opacity-60 ${
|
|
selected
|
|
? visibility === "hidden"
|
|
? "border-alert-strong bg-alert-strong text-white"
|
|
: "border-ink bg-ink text-white"
|
|
: "border-ink/25 bg-white hover:bg-ink/5"
|
|
}`}
|
|
>
|
|
{selected ? options[visibility].state : options[visibility].action}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="min-w-48 flex-1">{options[event.visibility].explain}</p>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<a
|
|
href={`/s/${event.scoreboardToken}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={buttonClass("secondary")}
|
|
>
|
|
Open scoreboard
|
|
</a>
|
|
{!reveal.active && (
|
|
<Button
|
|
variant="secondary"
|
|
disabled={pending || teams.length === 0}
|
|
onClick={() => run(() => startReveal(event.id))}
|
|
>
|
|
Start reveal
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<div className="basis-full">
|
|
<ErrorText>{error}</ErrorText>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|