fix: custom sound playback, unmark, editor click-to-edit, bigger fonts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m41s

This commit is contained in:
2026-06-14 22:29:50 +03:00
parent b34a009eb0
commit 610cbbec0f
4 changed files with 181 additions and 88 deletions

View File

@@ -100,8 +100,10 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [fetchState]); }, [fetchState]);
const handleMark = async (itemId: string) => { const markItem = async (itemId: string) => {
if (marking) return; if (marking) return;
const item = grid.find(c => c.item?.id === itemId);
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
setMarking(itemId); setMarking(itemId);
try { try {
const res = await fetch("/api/game/mark", { const res = await fetch("/api/game/mark", {
@@ -109,13 +111,24 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }), body: JSON.stringify({ campaignId: campaign.id, itemId }),
}); });
if (res.ok) { if (!res.ok && res.status === 409) {
const item = grid.find(c => c.item?.id === itemId); // already marked by someone else, just refresh
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
await fetchState();
} else if (res.status === 409) {
await fetchState();
} }
await fetchState();
} catch {}
setMarking(null);
};
const unmarkItem = async (itemId: string) => {
if (marking) return;
setMarking(itemId);
try {
await fetch("/api/game/mark", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }),
});
await fetchState();
} catch {} } catch {}
setMarking(null); setMarking(null);
}; };
@@ -239,7 +252,8 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
markCount={cell.markCount} markCount={cell.markCount}
markedBy={cell.markedBy} markedBy={cell.markedBy}
gridSize={campaign.gridSize} gridSize={campaign.gridSize}
onMark={handleMark} onMark={markItem}
onUnmark={unmarkItem}
disabled={marking !== null} disabled={marking !== null}
/> />
))} ))}

View File

@@ -8,6 +8,7 @@ type Item = {
text: string; text: string;
emoji: string; emoji: string;
soundCategory: string; soundCategory: string;
soundUrl?: string | null;
gridIndex: number; gridIndex: number;
}; };
@@ -19,6 +20,7 @@ type Props = {
markedBy: string[]; markedBy: string[];
gridSize: number; gridSize: number;
onMark: (itemId: string) => void; onMark: (itemId: string) => void;
onUnmark?: (itemId: string) => void;
disabled?: boolean; disabled?: boolean;
isFreeSpace?: boolean; isFreeSpace?: boolean;
}; };
@@ -36,7 +38,7 @@ function getSoundEmoji(cat: string) {
} }
} }
export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, disabled, isFreeSpace }: Props) { export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, onUnmark, disabled, isFreeSpace }: Props) {
const [shake, setShake] = useState(false); const [shake, setShake] = useState(false);
const [glow, setGlow] = useState(false); const [glow, setGlow] = useState(false);
@@ -66,14 +68,17 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
return ( return (
<button <button
onClick={() => !disabled && !marked && onMark(item.id)} onClick={() => {
disabled={disabled || marked} if (disabled) return;
if (marked) { onUnmark?.(item.id); return; }
onMark(item.id);
}}
className={cn( className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden", "relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
"select-none", "select-none",
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1", gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
marked marked
? "border-cyan-500/60 bg-cyan-900/40 cursor-default" ? "border-cyan-500/60 bg-cyan-900/40 hover:bg-cyan-800/40 cursor-pointer"
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95", : "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
shake && "animate-shake", shake && "animate-shake",
glow && "animate-glow-pulse", glow && "animate-glow-pulse",
@@ -94,12 +99,15 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
</span> </span>
{marked && markCount > 0 && ( {marked && markCount > 0 && (
<span className={cn( <span className={cn(
"absolute top-0.5 right-1 font-bold text-cyan-400", "absolute top-0.5 right-1 font-bold text-amber-400",
gridSize > 5 ? "text-[9px]" : "text-xs" gridSize > 5 ? "text-[9px]" : "text-xs"
)}> )}>
{markCount} {markCount}
</span> </span>
)} )}
{marked && (
<span className="absolute top-0.5 left-1 text-[8px] text-cyan-500/60"></span>
)}
{!marked && !isFree && ( {!marked && !isFree && (
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span> <span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
)} )}

View File

@@ -67,12 +67,14 @@ function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string)
export function ItemEditor({ campaign }: { campaign: Campaign }) { export function ItemEditor({ campaign }: { campaign: Campaign }) {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editItemId, setEditItemId] = useState<string | null>(null);
const [editText, setEditText] = useState(""); const [editText, setEditText] = useState("");
const [editEmoji, setEditEmoji] = useState("💀"); const [editEmoji, setEditEmoji] = useState("💀");
const [editSound, setEditSound] = useState("horn"); const [editSound, setEditSound] = useState("horn");
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null); const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLDivElement>(null);
const fetchItems = async () => { const fetchItems = async () => {
const res = await fetch(`/api/campaigns/${campaign.id}/items`); const res = await fetch(`/api/campaigns/${campaign.id}/items`);
@@ -101,6 +103,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
}; };
const deleteItem = async (itemId: string) => { const deleteItem = async (itemId: string) => {
if (editItemId === itemId) resetForm();
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" }); await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
await fetchItems(); await fetchItems();
}; };
@@ -124,11 +127,45 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
gridIndex: maxIdx + 1, gridIndex: maxIdx + 1,
}), }),
}); });
resetForm();
await fetchItems();
};
const submitItem = async () => {
if (!editText) return;
if (editItemId) {
const existing = items.find(it => it.id === editItemId);
if (existing) {
await updateItem({
...existing,
text: editText,
emoji: editEmoji,
soundCategory: editSound,
soundUrl: editSoundUrl,
});
}
resetForm();
} else {
await addItem();
}
};
const editItem = (item: Item) => {
setEditItemId(item.id);
setEditText(item.text);
setEditEmoji(item.emoji);
setEditSound(item.soundCategory);
setEditSoundUrl(item.soundUrl || null);
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const resetForm = () => {
setEditItemId(null);
setEditText(""); setEditText("");
setEditEmoji("💀"); setEditEmoji("💀");
setEditSound("horn"); setEditSound("horn");
setEditSoundUrl(null); setEditSoundUrl(null);
await fetchItems(); if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const uploadSound = async (file: File) => { const uploadSound = async (file: File) => {
@@ -141,6 +178,10 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
setUploading(false); setUploading(false);
}; };
const playSoundOnce = (url: string) => {
try { const a = new Audio(url); a.preload = "auto"; a.volume = 0.4; a.play().catch(() => {}); } catch {}
};
const totalCells = campaign.gridSize * campaign.gridSize; const totalCells = campaign.gridSize * campaign.gridSize;
if (loading) { if (loading) {
@@ -149,79 +190,88 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="border-cyan-800/30"> <div ref={formRef}>
<CardContent className="p-4 space-y-3"> <Card className={editItemId ? "border-amber-500/40" : "border-cyan-800/30"}>
<h3 className="text-xs font-mono text-cyan-400 uppercase">+ Add New Cell</h3> <CardContent className="p-4 space-y-3">
<div className="flex flex-wrap gap-2 items-end"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-[150px]"> <h3 className="text-xs font-mono text-cyan-400 uppercase">{editItemId ? "✏ Edit Cell" : "+ Add New Cell"}</h3>
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label> {editItemId && (
<Input <button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
placeholder="Reactor explodes" Cancel
value={editText} </button>
onChange={e => setEditText(e.target.value)} )}
className="font-mono text-sm"
/>
</div> </div>
<div> <div className="flex flex-wrap gap-2 items-end">
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label> <div className="flex-1 min-w-[150px]">
<EmojiPicker value={editEmoji} onChange={setEditEmoji} /> <label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
</div> <Input
<div className="w-32"> placeholder="Reactor explodes"
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label> value={editText}
<Select onChange={e => setEditText(e.target.value)}
value={editSound} className="font-mono text-sm"
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }} />
>
<SelectTrigger className="font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editSound === "custom" && (
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
<div className="flex gap-1 items-center">
<input
ref={fileInputRef}
type="file"
accept=".ogg"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
/>
<Button
variant="outline"
size="sm"
className="font-mono text-[9px] h-10"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
</Button>
{editSoundUrl && (
<button
type="button"
onClick={() => { const a = new Audio(editSoundUrl); a.volume = 0.4; a.play(); }}
className="text-sm cursor-pointer hover:text-cyan-300"
>
</button>
)}
</div>
</div> </div>
)} <div>
<Button size="sm" onClick={addItem} disabled={!editText} className="font-mono text-xs"> <label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
ADD <EmojiPicker value={editEmoji} onChange={setEditEmoji} />
</Button> </div>
</div> <div className="w-32">
</CardContent> <label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
</Card> <Select
value={editSound}
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }}
>
<SelectTrigger className="font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editSound === "custom" && (
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
<div className="flex gap-1 items-center">
<input
ref={fileInputRef}
type="file"
accept=".ogg"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
/>
<Button
variant="outline"
size="sm"
className="font-mono text-[9px] h-10"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
</Button>
{editSoundUrl && (
<button
type="button"
onClick={() => playSoundOnce(editSoundUrl)}
className="text-sm cursor-pointer hover:text-cyan-300"
>
</button>
)}
</div>
</div>
)}
<Button size="sm" onClick={submitItem} disabled={!editText} className="font-mono text-xs">
{editItemId ? "SAVE" : "ADD"}
</Button>
</div>
</CardContent>
</Card>
</div>
<div <div
className="grid gap-1" className="grid gap-1"
@@ -236,17 +286,26 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
</div> </div>
); );
} }
const isEditing = editItemId === item.id;
return ( return (
<Card key={item.id} className="border-slate-700/30 aspect-square group"> <Card key={item.id} className={"border-slate-700/30 aspect-square group" + (isEditing ? " ring-2 ring-amber-500/50" : "")}>
<CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative"> <CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative">
<span className="text-lg">{item.emoji}</span> <span className="text-lg">{item.emoji}</span>
<span className="text-[8px] text-center font-mono text-slate-300 leading-tight line-clamp-2"> <span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5">
{item.text} {item.text}
</span> </span>
<Badge variant="outline" className="text-[6px] px-1 py-0 absolute top-0.5 right-0.5"> <Badge variant="outline" className="text-[8px] px-1 py-0 absolute top-0.5 right-0.5">
{item.soundUrl ? "🔊" : item.soundCategory} {item.soundUrl ? "🔊" : item.soundCategory}
</Badge> </Badge>
<div className="absolute bottom-0.5 left-0.5 right-0.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute bottom-0.5 left-0.5 right-0.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="secondary"
size="sm"
className="h-4 text-[8px] px-1 py-0 flex-1"
onClick={() => editItem(item)}
>
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -256,6 +315,15 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
</Button> </Button>
</div> </div>
{item.soundUrl && (
<button
type="button"
onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!); }}
className="absolute bottom-0.5 left-0.5 text-[10px] text-cyan-500/60 hover:text-cyan-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
🔊
</button>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -301,3 +369,5 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
</div> </div>
); );
} }

View File

@@ -110,6 +110,7 @@ export function playChaosRiser() {
export function playSoundUrl(url: string) { export function playSoundUrl(url: string) {
try { try {
const audio = new Audio(url); const audio = new Audio(url);
audio.preload = "auto";
audio.volume = 0.4; audio.volume = 0.4;
audio.play().catch(() => {}); audio.play().catch(() => {});
} catch {} } catch {}