Files
BaraBingo/components/ItemEditor.tsx
SlavaVlad 3f41d7699c
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m44s
feat: accept any audio/video upload, server converts to 6s compressed MP3
2026-06-15 03:25:44 +03:00

442 lines
18 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 } 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>
);
}