Files
BaraBingo/components/ItemEditor.tsx
SlavaVlad 05c4f19109
Some checks failed
Deploy / build-and-deploy (push) Has been cancelled
fix: replace new Audio() with howler.js for reliable sound playback + error logging
2026-06-15 01:35:08 +03:00

435 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, EMOJIS } from "@/lib/bingo-data";
import { Badge } from "./ui/badge";
import { ResourceBrowser, DragData } from "./ResourceBrowser";
import { playSoundOnce } from "@/lib/sounds";
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;
};
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 [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]);
// Listen for emoji-select from ResourceBrowser EmojisTab
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.emoji) setEditEmoji(detail.emoji);
};
window.addEventListener("emoji-select", handler);
return () => window.removeEventListener("emoji-select", handler);
}, []);
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);
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 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"></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!); }}
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)} />
{/* 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>
);
}