All checks were successful
Deploy / build-and-deploy (push) Successful in 1m14s
464 lines
18 KiB
TypeScript
464 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Card, CardContent } from "./ui/card";
|
|
import { Button } from "./ui/button";
|
|
import { Input } from "./ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
|
|
import { Badge } from "./ui/badge";
|
|
|
|
type Item = {
|
|
id: string;
|
|
text: string;
|
|
emoji: string;
|
|
soundCategory: string;
|
|
soundUrl?: string | null;
|
|
gridIndex: number;
|
|
};
|
|
|
|
type Campaign = {
|
|
id: string;
|
|
name: string;
|
|
gridSize: number;
|
|
};
|
|
|
|
type LibrarySound = {
|
|
filename: string;
|
|
url: string;
|
|
};
|
|
|
|
function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className="w-10 h-10 flex items-center justify-center text-xl rounded border border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 cursor-pointer"
|
|
>
|
|
{value || "?"}
|
|
</button>
|
|
{open && (
|
|
<div className="absolute top-full left-0 mt-1 z-50 bg-slate-900 border border-slate-700/50 rounded-lg p-2 shadow-xl w-[280px]">
|
|
<div className="grid grid-cols-8 gap-1">
|
|
{EMOJIS.map(e => (
|
|
<button
|
|
key={e}
|
|
type="button"
|
|
onClick={() => { onChange(e); setOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center text-base rounded hover:bg-slate-700 cursor-pointer ${e === value ? "bg-cyan-700/50 ring-1 ring-cyan-400" : ""}`}
|
|
>
|
|
{e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editItemId, setEditItemId] = useState<string | null>(null);
|
|
const [editText, setEditText] = useState("");
|
|
const [editEmoji, setEditEmoji] = useState("💀");
|
|
const [editSound, setEditSound] = useState("horn");
|
|
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [librarySounds, setLibrarySounds] = useState<LibrarySound[]>([]);
|
|
const [showLibrary, setShowLibrary] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const formRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchItems = async () => {
|
|
const res = await fetch(`/api/campaigns/${campaign.id}/items`);
|
|
const data = await res.json();
|
|
const sorted = [...data].sort((a: Item, b: Item) => a.gridIndex - b.gridIndex);
|
|
setItems(sorted);
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { fetchItems(); }, [campaign.id]);
|
|
|
|
const fetchLibrary = async () => {
|
|
const res = await fetch("/api/sounds");
|
|
const data = await res.json();
|
|
setLibrarySounds(data.sounds || []);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (editSound === "custom") fetchLibrary();
|
|
}, [editSound]);
|
|
|
|
const updateItem = async (item: Item) => {
|
|
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: item.id,
|
|
text: item.text,
|
|
emoji: item.emoji,
|
|
soundCategory: item.soundCategory,
|
|
soundUrl: item.soundUrl,
|
|
gridIndex: item.gridIndex,
|
|
}),
|
|
});
|
|
await fetchItems();
|
|
};
|
|
|
|
const deleteItem = async (itemId: string) => {
|
|
if (editItemId === itemId) resetForm();
|
|
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
|
|
await fetchItems();
|
|
};
|
|
|
|
const addItem = async () => {
|
|
if (!editText) return;
|
|
const maxIdx = items.reduce((max, it) => Math.max(max, it.gridIndex), -1);
|
|
const totalCells = campaign.gridSize * campaign.gridSize;
|
|
if (maxIdx + 1 >= totalCells) {
|
|
alert("Grid is full! Delete some items first.");
|
|
return;
|
|
}
|
|
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
text: editText,
|
|
emoji: editEmoji,
|
|
soundCategory: editSound,
|
|
soundUrl: editSoundUrl,
|
|
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("");
|
|
setEditEmoji("💀");
|
|
setEditSound("horn");
|
|
setEditSoundUrl(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
};
|
|
|
|
const uploadSound = async (file: File) => {
|
|
setUploading(true);
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await fetch("/api/upload/sound", { method: "POST", body: formData });
|
|
const data = await res.json();
|
|
if (data.url) setEditSoundUrl(data.url);
|
|
setUploading(false);
|
|
fetchLibrary();
|
|
};
|
|
|
|
const deleteSound = async (filename: string) => {
|
|
await fetch(`/api/sounds?filename=${encodeURIComponent(filename)}`, { method: "DELETE" });
|
|
if (librarySounds.find(s => s.filename === filename)?.url === editSoundUrl) {
|
|
setEditSoundUrl(null);
|
|
}
|
|
fetchLibrary();
|
|
};
|
|
|
|
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;
|
|
|
|
if (loading) {
|
|
return <div className="text-center text-slate-500 font-mono text-sm py-8">Loading items...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div ref={formRef}>
|
|
<Card className={editItemId ? "border-amber-500/40" : "border-cyan-800/30"}>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-mono text-cyan-400 uppercase">{editItemId ? "✏ Edit Cell" : "+ Add New Cell"}</h3>
|
|
{editItemId && (
|
|
<button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|
<div className="flex-1 min-w-[150px]">
|
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
|
|
<Input
|
|
placeholder="Reactor explodes"
|
|
value={editText}
|
|
onChange={e => setEditText(e.target.value)}
|
|
className="font-mono text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
|
|
<EmojiPicker value={editEmoji} onChange={setEditEmoji} />
|
|
</div>
|
|
<div className="w-32">
|
|
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
|
|
<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>
|
|
<Button size="sm" onClick={submitItem} disabled={!editText} className="font-mono text-xs">
|
|
{editItemId ? "SAVE" : "ADD"}
|
|
</Button>
|
|
</div>
|
|
|
|
{editSound === "custom" && (
|
|
<div className="border-t border-slate-700/20 pt-3 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<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-[10px]"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? "Uploading..." : "+ Upload OGG"}
|
|
</Button>
|
|
{editSoundUrl && (
|
|
<div className="flex items-center gap-1 text-[10px] font-mono text-cyan-400">
|
|
<span>✓ sound loaded</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => playSoundOnce(editSoundUrl)}
|
|
className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-slate-600/50 text-xs cursor-pointer"
|
|
title="Preview"
|
|
>
|
|
▶
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditSoundUrl(null)}
|
|
className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-red-800/50 text-xs cursor-pointer"
|
|
title="Clear"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowLibrary(!showLibrary)}
|
|
className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer"
|
|
>
|
|
{showLibrary ? "▾ Hide Library" : "▸ Sound Library (" + librarySounds.length + ")"}
|
|
</button>
|
|
</div>
|
|
|
|
{showLibrary && librarySounds.length > 0 && (
|
|
<div className="max-h-40 overflow-y-auto space-y-1 border border-slate-700/20 rounded p-2 bg-slate-900/40">
|
|
{librarySounds.map(s => {
|
|
const isSelected = editSoundUrl === s.url;
|
|
return (
|
|
<div
|
|
key={s.filename}
|
|
className={"flex items-center gap-1 px-2 py-1 rounded text-[10px] font-mono " + (isSelected ? "bg-cyan-900/40 text-cyan-300" : "text-slate-400 hover:bg-slate-800/40")}
|
|
>
|
|
<span className="flex-1 truncate">{s.filename}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => playSoundOnce(s.url)}
|
|
className="px-1.5 py-0.5 rounded hover:bg-slate-700/50 cursor-pointer"
|
|
title="Play"
|
|
>
|
|
▶
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditSoundUrl(s.url)}
|
|
className="px-1.5 py-0.5 rounded hover:bg-slate-700/50 cursor-pointer"
|
|
title="Use this sound"
|
|
>
|
|
{isSelected ? "✓" : "Use"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => deleteSound(s.filename)}
|
|
className="px-1.5 py-0.5 rounded hover:bg-red-800/50 cursor-pointer text-red-400"
|
|
title="Delete"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{showLibrary && librarySounds.length === 0 && (
|
|
<p className="text-[9px] font-mono text-slate-600 italic">No uploaded sounds yet. Upload an OGG file above.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div
|
|
className="grid gap-1"
|
|
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
|
|
>
|
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
|
const item = items.find(it => it.gridIndex === idx);
|
|
if (!item) {
|
|
return (
|
|
<div key={idx} className="aspect-square rounded border border-dashed border-slate-700/20 bg-slate-900/20 flex items-center justify-center text-slate-700 text-[10px] font-mono">
|
|
✖
|
|
</div>
|
|
);
|
|
}
|
|
const isEditing = editItemId === item.id;
|
|
return (
|
|
<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">
|
|
<span className="text-lg">{item.emoji}</span>
|
|
<span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5">
|
|
{item.text}
|
|
</span>
|
|
<Badge variant="outline" className="text-[8px] px-1 py-0 absolute top-0.5 right-0.5">
|
|
{item.soundUrl ? "🔊" : item.soundCategory}
|
|
</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">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-4 text-[8px] px-1 py-0 flex-1"
|
|
onClick={() => editItem(item)}
|
|
>
|
|
✏
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="h-4 text-[8px] px-1 py-0 flex-1"
|
|
onClick={() => deleteItem(item.id)}
|
|
>
|
|
✕
|
|
</Button>
|
|
</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>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>
|
|
{items.map(item => (
|
|
<div key={item.id} className="flex items-center gap-2 bg-slate-800/30 rounded p-2 border border-slate-700/20">
|
|
<span className="text-sm w-6">{item.emoji}</span>
|
|
<input
|
|
className="flex-1 bg-transparent text-xs font-mono text-slate-200 border-b border-slate-700/30 focus:border-cyan-500/50 outline-none px-1"
|
|
value={item.text}
|
|
onChange={e => {
|
|
setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it));
|
|
}}
|
|
onBlur={() => updateItem(item)}
|
|
/>
|
|
<Select
|
|
value={item.soundCategory}
|
|
onValueChange={val => {
|
|
const updated = { ...item, soundCategory: val, soundUrl: val !== "custom" ? null : item.soundUrl };
|
|
setItems(prev => prev.map(it => it.id === item.id ? updated : it));
|
|
updateItem(updated);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 text-[9px] w-20 font-mono">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SOUND_CATEGORIES.map(cat => (
|
|
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">
|
|
{cat.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-[9px] text-slate-600 font-mono w-6 text-right">#{item.gridIndex}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|