Files
game-scoreboard/src/components/admin/scoreboard-panel.tsx
T
joshuaandClaude Opus 5.5 f1b3a862f8 Phase 8: share panel, polish, accessibility and deploy config
- 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>
2026-10-03 16:02:29 +02:00

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>
);
}