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>
307 lines
10 KiB
TypeScript
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'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>
|
|
);
|
|
}
|