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>
162 lines
6.1 KiB
TypeScript
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'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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|