v2.0: sound library, image upload, DnD, ResourceBrowser, Barotrauma HUD, captain sanity
Deploy / build-and-deploy (push) Failing after 52s
Deploy / build-and-deploy (push) Failing after 52s
This commit is contained in:
+103
-129
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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";
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ type Item = {
|
||||
emoji: string;
|
||||
soundCategory: string;
|
||||
soundUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
gridIndex: number;
|
||||
};
|
||||
|
||||
@@ -23,11 +25,6 @@ type Campaign = {
|
||||
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);
|
||||
@@ -77,9 +74,12 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
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<LibrarySound[]>([]);
|
||||
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);
|
||||
|
||||
@@ -93,15 +93,15 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
|
||||
useEffect(() => { fetchItems(); }, [campaign.id]);
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
const res = await fetch("/api/sounds");
|
||||
const data = await res.json();
|
||||
setLibrarySounds(data.sounds || []);
|
||||
};
|
||||
|
||||
// Listen for emoji-select from ResourceBrowser EmojisTab
|
||||
useEffect(() => {
|
||||
if (editSound === "custom") fetchLibrary();
|
||||
}, [editSound]);
|
||||
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`, {
|
||||
@@ -113,6 +113,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
emoji: item.emoji,
|
||||
soundCategory: item.soundCategory,
|
||||
soundUrl: item.soundUrl,
|
||||
imageUrl: item.imageUrl,
|
||||
gridIndex: item.gridIndex,
|
||||
}),
|
||||
});
|
||||
@@ -141,6 +142,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
emoji: editEmoji,
|
||||
soundCategory: editSound,
|
||||
soundUrl: editSoundUrl,
|
||||
imageUrl: editImageUrl,
|
||||
gridIndex: maxIdx + 1,
|
||||
}),
|
||||
});
|
||||
@@ -159,6 +161,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
emoji: editEmoji,
|
||||
soundCategory: editSound,
|
||||
soundUrl: editSoundUrl,
|
||||
imageUrl: editImageUrl,
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
@@ -173,6 +176,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
setEditEmoji(item.emoji);
|
||||
setEditSound(item.soundCategory);
|
||||
setEditSoundUrl(item.soundUrl || null);
|
||||
setEditImageUrl(item.imageUrl || null);
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
@@ -182,6 +186,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
setEditEmoji("💀");
|
||||
setEditSound("horn");
|
||||
setEditSoundUrl(null);
|
||||
setEditImageUrl(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
@@ -193,21 +198,42 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
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 {}
|
||||
};
|
||||
|
||||
// ─── 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) {
|
||||
@@ -221,11 +247,19 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
<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
|
||||
<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]">
|
||||
@@ -286,85 +320,25 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
{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)` }}
|
||||
@@ -373,55 +347,59 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
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 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} className={"border-slate-700/30 aspect-square group" + (isEditing ? " ring-2 ring-amber-500/50" : "")}>
|
||||
<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">{item.emoji}</span>
|
||||
<span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5">
|
||||
<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>
|
||||
<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"
|
||||
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 => (
|
||||
@@ -430,9 +408,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
<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));
|
||||
}}
|
||||
onChange={e => setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it))}
|
||||
onBlur={() => updateItem(item)}
|
||||
/>
|
||||
<Select
|
||||
@@ -448,9 +424,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOUND_CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">{cat.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
Reference in New Issue
Block a user