Files
game-scoreboard/src/components/admin/items-editor.tsx
T
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
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>
2026-10-05 10:54:08 +02:00

307 lines
10 KiB
TypeScript

"use client";
import { useState } from "react";
import { addItem, deleteItem, moveItem, updateItem } from "@/app/admin/item-actions";
import type { ActionResult } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { Field, inputClass } from "@/components/ui/field";
import type { Tables } from "@/lib/database.types";
import type { GameWithCriteria } from "@/lib/queries/event";
import {
MEDIA_BUCKET,
parseYouTube,
storagePathFor,
uploadAccept,
uploadProblem,
type ItemKind,
} from "@/lib/screen";
import { createClient } from "@/lib/supabase/browser";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
type Item = Tables<"game_items">;
export const kindLabel: Record<ItemKind, string> = { question: "Question", sound: "Sound", video: "Video" };
const titleLabel: Record<ItemKind, string> = {
question: "Question",
sound: "Name (only you see it)",
video: "Name (only you see it)",
};
const linkLabel: Record<ItemKind, string> = {
question: "Picture link (optional)",
sound: "Link to the sound",
video: "Link to the video",
};
const linkHint: Record<ItemKind, string> = {
question: "A link to an image, starting with https://",
sound: "A YouTube link (only the sound plays) or a link to an audio file.",
video: "A YouTube link, or a link to a video file.",
};
function sourceLabel(item: Item): string | null {
if (item.storage_path) return "Uploaded file";
if (!item.media_url) return null;
return parseYouTube(item.media_url) ? "YouTube" : "Link";
}
/** A game's questions, sounds and videos: what the host can put on the scoreboard. */
export function ItemsEditor({ eventId, game }: { eventId: string; game: GameWithCriteria }) {
const items = game.game_items;
return (
<section className="flex flex-col gap-3 rounded-md border border-ink/15 bg-chalk p-3">
<div>
<h3 className="text-lg font-bold">On the scoreboard: questions, sounds and videos</h3>
<p className="text-sm text-ink/70">
In the control room you click one to show it. A question appears above the scores, a sound
plays through the scoreboard&apos;s speakers, and a video (like a karaoke track) takes over
the whole screen.
</p>
</div>
{items.length > 0 && (
<ol className="flex flex-col gap-2">
{items.map((item, index) => (
<ItemRow
key={item.id}
eventId={eventId}
gameId={game.id}
item={item}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</ol>
)}
<AddItemForm eventId={eventId} gameId={game.id} />
</section>
);
}
function ItemRow({
eventId,
gameId,
item,
isFirst,
isLast,
}: {
eventId: string;
gameId: string;
item: Item;
isFirst: boolean;
isLast: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ title: item.title, answer: item.answer ?? "", mediaUrl: item.media_url ?? "" });
const { pending, error, run } = useAction();
const source = sourceLabel(item);
return (
<li className="rounded-md bg-white p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-ink/10 px-2 py-0.5 text-sm font-bold">{kindLabel[item.kind]}</span>
<span className="min-w-40 flex-1">
<span className="font-bold">{item.title}</span>
{item.answer && <span className="block text-sm text-ink/70">Answer: {item.answer}</span>}
</span>
{source && <span className="text-sm text-ink/70">{source}</span>}
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} up`}
disabled={pending || isFirst}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "up"))}
>
↑
</Button>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} down`}
disabled={pending || isLast}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "down"))}
>
↓
</Button>
<Button variant="secondary" size="sm" aria-expanded={editing} onClick={() => setEditing(!editing)}>
{editing ? "Close" : "Edit"}
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${item.title}"? This can't be undone.`)) {
run(() => deleteItem(eventId, gameId, item.id));
}
}}
>
Delete
</Button>
</div>
{editing && (
<form
className="mt-2 grid gap-3 border-t border-ink/15 pt-2 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(() => updateItem(eventId, gameId, item.id, draft), () => setEditing(false));
}}
>
<Field label={titleLabel[item.kind]} className="sm:col-span-2">
<input
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
required
maxLength={500}
className={inputClass}
/>
</Field>
{item.kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”.">
<input
value={draft.answer}
onChange={(event) => setDraft({ ...draft, answer: event.target.value })}
maxLength={500}
className={inputClass}
/>
</Field>
)}
{item.storage_path ? (
<p className="self-end text-sm text-ink/70">
Uses an uploaded file. To change the file, delete this and add it again.
</p>
) : (
<Field label={linkLabel[item.kind]} hint={linkHint[item.kind]}>
<input
type="url"
value={draft.mediaUrl}
onChange={(event) => setDraft({ ...draft, mediaUrl: event.target.value })}
maxLength={2000}
className={inputClass}
/>
</Field>
)}
<div className="sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? "Saving…" : "Save"}
</Button>
</div>
</form>
)}
<ErrorText>{error}</ErrorText>
</li>
);
}
function AddItemForm({ eventId, gameId }: { eventId: string; gameId: string }) {
const [kind, setKind] = useState<ItemKind>("question");
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
const [mediaUrl, setMediaUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
// Bumped to empty the file input, which React can't control directly.
const [fileKey, setFileKey] = useState(0);
const { pending, error, run } = useAction();
const clearFile = () => {
setFile(null);
setFileKey((key) => key + 1);
};
/** Uploads the chosen file (if any) straight to storage, then records the item. */
const save = async (): Promise<ActionResult> => {
const draft = { kind, title, answer, mediaUrl: file ? "" : mediaUrl };
if (!file) return addItem(eventId, gameId, { ...draft, storagePath: null });
const problem = uploadProblem(kind, file);
const storagePath = storagePathFor(eventId, file.name, crypto.randomUUID());
if (problem || !storagePath) return { ok: false, error: problem ?? "That file can't be uploaded." };
const storage = createClient().storage.from(MEDIA_BUCKET);
const upload = await storage.upload(storagePath, file, { contentType: file.type });
if (upload.error) return { ok: false, error: `The upload failed: ${upload.error.message}. Try again.` };
const result = await addItem(eventId, gameId, { ...draft, storagePath });
// Don't leave a file behind that no item points to.
if (!result.ok) await storage.remove([storagePath]);
return result;
};
return (
<form
className="grid gap-3 border-t border-ink/15 pt-3 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(save, () => {
setTitle("");
setAnswer("");
setMediaUrl("");
clearFile();
});
}}
>
<Field label="Add a">
<select
value={kind}
onChange={(event) => {
setKind(event.target.value as ItemKind);
// A file chosen for another kind would be the wrong type.
clearFile();
}}
className={inputClass}
>
<option value="question">Question</option>
<option value="sound">Sound</option>
<option value="video">Video (karaoke, clip)</option>
</select>
</Field>
<Field label={titleLabel[kind]}>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={500}
className={inputClass}
placeholder={kind === "question" ? "Who built the ark?" : kind === "sound" ? "Mystery song 1" : "Oceans — karaoke"}
/>
</Field>
{kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”." className="sm:col-span-2">
<input value={answer} onChange={(event) => setAnswer(event.target.value)} maxLength={500} className={inputClass} />
</Field>
)}
<Field label={linkLabel[kind]} hint={linkHint[kind]}>
<input
type="url"
value={mediaUrl}
onChange={(event) => setMediaUrl(event.target.value)}
maxLength={2000}
disabled={file !== null}
className={`${inputClass} disabled:bg-ink/5`}
placeholder="https://"
/>
</Field>
<Field label="…or upload a file" hint="Up to 50 MB. Used instead of the link.">
<input
key={fileKey}
type="file"
accept={uploadAccept(kind)}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className={`${inputClass} py-2`}
/>
</Field>
<div className="flex flex-wrap items-center gap-3 sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? (file ? "Uploading…" : "Adding…") : `Add ${kindLabel[kind].toLowerCase()}`}
</Button>
<ErrorText>{error}</ErrorText>
</div>
</form>
);
}