Files
game-scoreboard/src/components/admin/share-panel.tsx
T
joshuaandClaude Opus 5.5 bbcf84bbc3 Phase 10: team buzzers
Each team gets a buzzer link (/b/[token]); a game opts in with a
"Teams buzz in" checkbox. The database's clock orders the buzzes, so
the result doesn't depend on phone clocks. The control room lists the
order and clears it per question (B); the scoreboard shows who buzzed
first with a ding.

Buzzes follow the existing rules: token-gated route handlers with the
service-role client, RLS on the new table, and data-free realtime pings
on a new buzzer:{eventId} channel.

Needs supabase/migrations/0003_buzzer.sql applied before deploying.

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

162 lines
6.1 KiB
TypeScript

"use client";
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";
import { ShareLink } from "./share-link";
const site = publicEnv.NEXT_PUBLIC_SITE_URL;
/** Plain text for pasting into WhatsApp: one line per judge. */
export function judgeLinksText(game: Pick<GameWithCriteria, "title" | "judges">): string {
const lines = game.judges
.filter((judge) => !judge.revoked)
.map((judge) => `${judge.name}: ${site}/j/${judge.token}`);
return [`${game.title} — judge links`, ...lines].join("\n");
}
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 (
<div className="mt-3 flex flex-col gap-4">
{localOnly && (
<p className="rounded-md border-2 border-ink bg-chalk p-3">
These links point at <strong>{site}</strong>, which only works on this computer. Phones can open
them once the site is deployed and <code>NEXT_PUBLIC_SITE_URL</code> is set to its address.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-40 flex-1 font-bold">Scoreboard (projector)</p>
<ShareLink
url={`${site}/s/${scoreboardToken}`}
copyLabel="Copy scoreboard link"
qrLabel="QR code for the scoreboard"
/>
</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>
) : (
judgedGames.map((game) => {
const activeJudges = game.judges.filter((judge) => !judge.revoked);
return (
<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)} 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>
)}
<ul className="flex flex-col gap-2">
{activeJudges.map((judge) => (
<li key={judge.id} className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">{judge.name}</span>
<ShareLink
url={`${site}/j/${judge.token}`}
copyLabel="Copy judge link"
qrLabel={`QR code for ${judge.name}'s judge link`}
/>
</li>
))}
{game.open_link_enabled && (
<li className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">Open link (anyone, enters their name)</span>
<ShareLink
url={`${site}/jo/${game.open_token}`}
copyLabel="Copy open link"
qrLabel={`QR code for the open judge link of ${game.title}`}
/>
</li>
)}
</ul>
</div>
);
})
)}
</div>
);
}
function CopyAllButton({ text, label }: { text: string; label: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setState("copied");
setTimeout(() => setState("idle"), 2500);
} catch {
setState("failed");
}
}}
>
{state === "copied" ? "Copied ✓" : label}
</Button>
{state === "failed" && (
<div className="basis-full">
<p className="text-sm">Couldn&apos;t copy automatically. Select the text below.</p>
<pre className="mt-1 rounded bg-chalk p-2 text-sm whitespace-pre-wrap select-all">{text}</pre>
</div>
)}
</>
);
}