All checks were successful
Deploy / build-and-deploy (push) Successful in 1m44s
442 lines
18 KiB
TypeScript
442 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useCallback } 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 } from "@/lib/bingo-data";
|
||
import { Badge } from "./ui/badge";
|
||
import { ResourceBrowser, DragData } from "./ResourceBrowser";
|
||
import { playSoundOnce } from "@/lib/sounds";
|
||
import { SoundPlayerBar } from "./SoundPlayerBar";
|
||
|
||
type Item = {
|
||
id: string;
|
||
text: string;
|
||
emoji: string;
|
||
soundCategory: string;
|
||
soundUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
gridIndex: number;
|
||
};
|
||
|
||
type Campaign = {
|
||
id: string;
|
||
name: string;
|
||
gridSize: number;
|
||
};
|
||
|
||
const EMOJI_LIST = [
|
||
"😀", "😂", "🤣", "😍", "🥰", "😘", "😊", "😎", "🤩", "🥳", "🤯", "😱",
|
||
"😈", "🤡", "💀", "👻", "👽", "🤖", "🎃", "😺", "🙈", "🙉", "🙊", "💩",
|
||
"🔥", "🌊", "💥", "💫", "⭐", "🌟", "✨", "⚡", "☄️", "💧", "🧊", "🌋",
|
||
"🎉", "🎊", "🎈", "🎁", "🏆", "🥇", "💎", "🔮", "🪄", "🧨", "🔫", "⚔️",
|
||
"🛡️", "🚀", "🛸", "🚁", "⚓", "🌀", "🌈", "🌪️", "☢️", "☣️", "⚕️", "🧬",
|
||
"💉", "💊", "🩸", "🧪", "🔬", "🔭", "📡", "🎯", "🎲", "♟️", "🧩", "🎭",
|
||
"🎵", "🎶", "🎺", "📯", "🔔", "🎤", "🎧", "📢", "📣", "🔊", "🔇", "💤",
|
||
"💣", "🔪", "🪦", "⚰️", "🪤", "🧲", "🗝️", "🔑", "🔓", "🔒", "🛠️", "⛓️",
|
||
"🧹", "🪠", "🔧", "⚙️", "📦", "📀", "💿", "📹", "📸", "📷", "🖼️", "🎨",
|
||
"🍺", "🍻", "🍷", "🥃", "🍸", "🍹", "🧉", "🍕", "🍔", "🌭", "🍿", "🧀",
|
||
"🥜", "🌶️", "🍄", "🥚", "🧅", "🥕", "🥦", "🫁", "🧠", "👀", "👁️", "🫀",
|
||
"💋", "🫂", "🤝", "👍", "👎", "👊", "✊", "🤛", "🤜", "👆", "👇", "🖕",
|
||
"💪", "🦵", "🦶", "👂", "👃", "🧑🚀", "🧑🔧", "🧑⚕️", "🧑✈️", "🧑🏫", "🧑🎤", "🧑🍳",
|
||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮",
|
||
"🦈", "🐙", "🦑", "🐋", "🐬", "🦭", "🐊", "🦎", "🐍", "🐢", "🦖", "🦕",
|
||
"🦟", "🦗", "🐛", "🪱", "🦋", "🐌", "🐞", "🐜", "🦂", "🕷️", "🦀", "🪸",
|
||
];
|
||
|
||
function EmojiPickerGrid({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||
const [search, setSearch] = useState("");
|
||
const filtered = search ? EMOJI_LIST.filter(e => e.includes(search)) : EMOJI_LIST;
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Search emoji..."
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="w-full bg-slate-800/60 border border-slate-700/30 rounded px-2 py-1 text-[10px] font-mono text-slate-200 placeholder:text-slate-600 outline-none focus:border-cyan-500/50"
|
||
/>
|
||
<div className="flex flex-wrap gap-1 max-h-36 overflow-y-auto">
|
||
{filtered.map(e => (
|
||
<button
|
||
key={e}
|
||
type="button"
|
||
onClick={() => onChange(e)}
|
||
className={`w-7 h-7 flex items-center justify-center text-sm rounded hover:bg-slate-700/60 cursor-pointer active:scale-90 transition-transform ${
|
||
e === value ? "bg-cyan-700/50 ring-1 ring-cyan-400" : ""
|
||
}`}
|
||
>
|
||
{e}
|
||
</button>
|
||
))}
|
||
</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 [editImageUrl, setEditImageUrl] = useState<string | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [librarySounds, setLibrarySounds] = useState<{ originalName: string; url: string }[]>([]);
|
||
const [showLibrary, setShowLibrary] = useState(false);
|
||
const [showBrowser, setShowBrowser] = useState(false);
|
||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||
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 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,
|
||
imageUrl: item.imageUrl,
|
||
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,
|
||
imageUrl: editImageUrl,
|
||
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,
|
||
imageUrl: editImageUrl,
|
||
});
|
||
}
|
||
resetForm();
|
||
} else {
|
||
await addItem();
|
||
}
|
||
};
|
||
|
||
const editItem = (item: Item) => {
|
||
setEditItemId(item.id);
|
||
setEditText(item.text);
|
||
setEditEmoji(item.emoji);
|
||
setEditSound(item.soundCategory);
|
||
setEditSoundUrl(item.soundUrl || null);
|
||
setEditImageUrl(item.imageUrl || null);
|
||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setEditItemId(null);
|
||
setEditText("");
|
||
setEditEmoji("💀");
|
||
setEditSound("horn");
|
||
setEditSoundUrl(null);
|
||
setEditImageUrl(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);
|
||
};
|
||
|
||
// ─── Drag & Drop handlers ───────────────────────────────────────
|
||
|
||
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||
e.preventDefault();
|
||
setDragOverIdx(idx);
|
||
}, []);
|
||
|
||
const handleDragLeave = useCallback(() => {
|
||
setDragOverIdx(null);
|
||
}, []);
|
||
|
||
const handleDrop = useCallback(async (e: React.DragEvent, idx: number) => {
|
||
e.preventDefault();
|
||
setDragOverIdx(null);
|
||
const raw = e.dataTransfer.getData("application/json");
|
||
if (!raw) return;
|
||
const data: DragData = JSON.parse(raw);
|
||
const item = items.find(it => it.gridIndex === idx);
|
||
if (!item) return;
|
||
|
||
if (data.type === "sound") {
|
||
playSoundOnce(data.url, data.originalName);
|
||
await updateItem({ ...item, soundCategory: "custom", soundUrl: data.url });
|
||
} else if (data.type === "image") {
|
||
await updateItem({ ...item, imageUrl: data.url });
|
||
} else if (data.type === "emoji") {
|
||
await updateItem({ ...item, emoji: data.emoji });
|
||
}
|
||
}, [items, updateItem]);
|
||
|
||
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>
|
||
<div className="flex items-center gap-2">
|
||
{editItemId && (
|
||
<button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
|
||
Cancel
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setShowBrowser(!showBrowser)}
|
||
className="text-[10px] font-mono text-cyan-500 hover:text-cyan-300 cursor-pointer"
|
||
>
|
||
{showBrowser ? "▾ Hide" : "▸ Resource Browser"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<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 — click to select</label>
|
||
<EmojiPickerGrid value={editEmoji} onChange={setEditEmoji} />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2 items-end">
|
||
<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="audio/*,video/*"
|
||
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!, "Preview")} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-slate-600/50 text-xs cursor-pointer">▶</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">✕</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{editImageUrl && (
|
||
<div className="flex items-center gap-2 text-[10px] font-mono text-cyan-400">
|
||
<span>🖼️ image loaded</span>
|
||
<button type="button" onClick={() => setEditImageUrl(null)} className="px-1.5 py-0.5 rounded bg-slate-700/50 hover:bg-red-800/50 text-xs cursor-pointer">✕</button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Grid */}
|
||
<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}
|
||
onDragOver={e => handleDragOver(e, idx)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={e => handleDrop(e, 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 transition-colors " + (dragOverIdx === idx ? "border-cyan-500/50 bg-cyan-900/20" : "")}
|
||
>
|
||
✖
|
||
</div>
|
||
);
|
||
}
|
||
const isEditing = editItemId === item.id;
|
||
return (
|
||
<Card key={item.id}
|
||
onDragOver={e => handleDragOver(e, idx)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={e => handleDrop(e, idx)}
|
||
className={"border-slate-700/30 aspect-square group" + (isEditing ? " ring-2 ring-amber-500/50" : "") + (dragOverIdx === idx ? " ring-2 ring-cyan-400/70" : "")}
|
||
>
|
||
<CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative">
|
||
<span className="text-lg leading-none">{item.emoji}</span>
|
||
{item.imageUrl && (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={item.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover rounded-lg opacity-30 pointer-events-none" />
|
||
)}
|
||
<span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5 relative z-10">
|
||
{item.text}
|
||
</span>
|
||
{item.soundUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!, item.text); }}
|
||
className="text-[10px] text-cyan-500/60 hover:text-cyan-300 cursor-pointer relative z-10"
|
||
>
|
||
🔊
|
||
</button>
|
||
)}
|
||
<Badge variant="outline" className="text-[8px] px-1 py-0 absolute top-0.5 right-0.5 z-10">
|
||
{item.imageUrl ? "🖼️" : 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 z-10">
|
||
<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>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Resource Browser dock */}
|
||
<ResourceBrowser visible={showBrowser} onClose={() => setShowBrowser(false)} />
|
||
|
||
{/* Sound playback bar */}
|
||
<SoundPlayerBar />
|
||
|
||
{/* All Items list */}
|
||
<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>
|
||
);
|
||
}
|