Переработан дизайн, добавлена фича SoundLibrary
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m14s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m14s
This commit is contained in:
BIN
README.pdf
Normal file
BIN
README.pdf
Normal file
Binary file not shown.
39
app/api/sounds/route.ts
Normal file
39
app/api/sounds/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "@/lib/auth";
|
||||||
|
import { readdir, unlink } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const SOUNDS_DIR = path.join(process.cwd(), "public", "uploads", "sounds");
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session || !session.isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const files = await readdir(SOUNDS_DIR);
|
||||||
|
const sounds = files
|
||||||
|
.filter(f => f.endsWith(".ogg"))
|
||||||
|
.map(f => ({ filename: f, url: `/uploads/sounds/${f}` }))
|
||||||
|
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||||
|
return NextResponse.json({ sounds });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ sounds: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session || !session.isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const filename = req.nextUrl.searchParams.get("filename");
|
||||||
|
if (!filename) return NextResponse.json({ error: "No filename" }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const filepath = path.join(SOUNDS_DIR, filename);
|
||||||
|
await unlink(filepath);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="container mx-auto px-4 py-6 max-w-5xl">
|
<main className="container mx-auto px-2 py-4 max-w-3xl">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{/* Floating bubbles */}
|
{/* Floating bubbles */}
|
||||||
|
|||||||
@@ -151,125 +151,92 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
"from-red-600 to-red-500";
|
"from-red-600 to-red-500";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 w-full max-w-5xl mx-auto">
|
<div className="flex flex-col items-center gap-2 w-full max-w-lg mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center w-full">
|
<div className="text-center w-full">
|
||||||
<h2 className="text-2xl font-bold text-cyan-300 font-mono tracking-wide uppercase">
|
<h2 className="text-xl font-bold text-cyan-300 font-mono tracking-wide uppercase">
|
||||||
{campaign.name}
|
{campaign.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-500 font-mono mt-1">
|
<p className="text-xs text-slate-500 font-mono">
|
||||||
{markedCount}/{totalItems} cells marked
|
{markedCount}/{totalItems} cells marked
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chaos Meter */}
|
{/* Chaos + Subs compact row */}
|
||||||
<div className="w-full space-y-1">
|
<div className="w-full flex gap-2 items-stretch">
|
||||||
<div className="flex justify-between text-xs font-mono text-slate-500">
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
<span>{stageLabel}</span>
|
<div className="flex justify-between text-[9px] font-mono text-slate-500">
|
||||||
<span>{Math.round(chaosLevel * 100)}% chaos</span>
|
<span>{stageLabel}</span>
|
||||||
|
<span>{Math.round(chaosLevel * 100)}% chaos</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-900/60 rounded-full h-2 overflow-hidden border border-slate-700/30">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
|
||||||
|
chaosStageClass,
|
||||||
|
chaosLevel >= 0.6 && "animate-pulse"
|
||||||
|
)}
|
||||||
|
style={{ width: `${chaosLevel * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
|
<div className="hidden sm:flex gap-2 text-[9px] font-mono">
|
||||||
<div
|
<div className={cn("px-2 py-1 rounded border", hullHealth < 30 ? "border-red-800/50 text-red-400" : "border-slate-700/30 text-slate-500")}>
|
||||||
className={cn(
|
Hull {Math.round(hullHealth)}%
|
||||||
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
|
</div>
|
||||||
chaosStageClass,
|
<div className={cn("px-2 py-1 rounded border", reactorTemp > 120 ? "border-orange-800/50 text-orange-400" : "border-slate-700/30 text-slate-500")}>
|
||||||
chaosLevel >= 0.6 && "animate-pulse"
|
R{Math.round(reactorTemp)}°
|
||||||
)}
|
</div>
|
||||||
style={{ width: `${chaosLevel * 100}%` }}
|
<div className={cn("px-2 py-1 rounded border", o2Level < 30 ? "border-red-800/50 text-red-400" : "border-slate-700/30 text-slate-500")}>
|
||||||
/>
|
O₂ {Math.round(o2Level)}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub Status + Grid Row */}
|
{/* Bingo Grid */}
|
||||||
<div className="flex gap-4 w-full">
|
<div className="w-full">
|
||||||
{/* Sub Status Panel */}
|
<div
|
||||||
<div className="hidden md:flex flex-col gap-2 w-48 shrink-0">
|
className="grid gap-1"
|
||||||
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 space-y-2">
|
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
|
||||||
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider">Sub Status</h3>
|
>
|
||||||
<div className="space-y-2">
|
{grid.map((cell) => (
|
||||||
<div>
|
<BingoCell
|
||||||
<div className="flex justify-between text-[9px] font-mono">
|
key={cell.index}
|
||||||
<span className="text-slate-500">Hull</span>
|
item={cell.item}
|
||||||
<span className={cn(hullHealth < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(hullHealth)}%</span>
|
index={cell.index}
|
||||||
</div>
|
marked={cell.marked}
|
||||||
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
markCount={cell.markCount}
|
||||||
<div className={cn("h-full rounded-full transition-all duration-700", hullHealth < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${hullHealth}%` }} />
|
markedBy={cell.markedBy}
|
||||||
</div>
|
gridSize={campaign.gridSize}
|
||||||
</div>
|
onMark={markItem}
|
||||||
<div>
|
onUnmark={unmarkItem}
|
||||||
<div className="flex justify-between text-[9px] font-mono">
|
disabled={marking !== null}
|
||||||
<span className="text-slate-500">Reactor</span>
|
/>
|
||||||
<span className={cn(reactorTemp > 120 ? "text-orange-400" : "text-slate-400")}>{Math.round(reactorTemp)}°C</span>
|
))}
|
||||||
</div>
|
|
||||||
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
||||||
<div className={cn("h-full rounded-full transition-all duration-700", reactorTemp > 120 ? "bg-orange-500 animate-pulse" : "bg-cyan-500")} style={{ width: `${Math.min(reactorTemp / 2, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-[9px] font-mono">
|
|
||||||
<span className="text-slate-500">O₂</span>
|
|
||||||
<span className={cn(o2Level < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(o2Level)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
||||||
<div className={cn("h-full rounded-full transition-all duration-700", o2Level < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${o2Level}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] font-mono text-slate-700 pt-1 border-t border-slate-700/30">
|
|
||||||
{chaosLevel < 0.25 && "All systems nominal"}
|
|
||||||
{chaosLevel >= 0.25 && chaosLevel < 0.5 && "⚠️ Minor breaches detected"}
|
|
||||||
{chaosLevel >= 0.5 && chaosLevel < 0.75 && "🚨 EVACUATE! SUB COMPROMISED"}
|
|
||||||
{chaosLevel >= 0.75 && "☢️ MELTDOWN IN PROGRESS"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Activity Log */}
|
|
||||||
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 flex-1 min-h-0 max-h-60 overflow-y-auto">
|
|
||||||
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2 sticky top-0 bg-slate-900/80 pb-1">Crew Log</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{activityLog.length === 0 && (
|
|
||||||
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
|
|
||||||
)}
|
|
||||||
{activityLog.map((entry, i) => (
|
|
||||||
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bingo Grid */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="grid gap-1.5 w-full"
|
|
||||||
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
|
|
||||||
>
|
|
||||||
{grid.map((cell) => (
|
|
||||||
<BingoCell
|
|
||||||
key={cell.index}
|
|
||||||
item={cell.item}
|
|
||||||
index={cell.index}
|
|
||||||
marked={cell.marked}
|
|
||||||
markCount={cell.markCount}
|
|
||||||
markedBy={cell.markedBy}
|
|
||||||
gridSize={campaign.gridSize}
|
|
||||||
onMark={markItem}
|
|
||||||
onUnmark={unmarkItem}
|
|
||||||
disabled={marking !== null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bingo Lines */}
|
{/* Bingo Lines */}
|
||||||
{bingoLines.length > 0 && (
|
{bingoLines.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
<Badge variant="destructive" className="text-xs px-3 py-1 animate-pulse shadow-lg shadow-red-600/30">
|
||||||
<Badge variant="destructive" className="text-sm px-4 py-1 animate-pulse shadow-lg shadow-red-600/30">
|
🏆 BINGO! {bingoLines.length} line(s)
|
||||||
🏆 BINGO! {bingoLines.length} line(s)
|
</Badge>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<div className="w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-2 max-h-28 overflow-y-auto">
|
||||||
|
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-1 sticky top-0 bg-slate-900/80 pb-0.5">Crew Log</h3>
|
||||||
|
{activityLog.length === 0 ? (
|
||||||
|
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{activityLog.map((entry, i) => (
|
||||||
|
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bingo Celebration Modal */}
|
{/* Bingo Celebration Modal */}
|
||||||
{showBingo && (
|
{showBingo && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setShowBingo(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setShowBingo(false)}>
|
||||||
@@ -284,30 +251,6 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Activity Log */}
|
|
||||||
<div className="md:hidden w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 max-h-40 overflow-y-auto">
|
|
||||||
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2">Crew Log</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{activityLog.length === 0 && (
|
|
||||||
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
|
|
||||||
)}
|
|
||||||
{activityLog.map((entry, i) => (
|
|
||||||
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="flex flex-wrap gap-2 justify-center text-[10px] text-slate-600 font-mono">
|
|
||||||
<span>🎺 horn</span>
|
|
||||||
<span>🚨 alarm</span>
|
|
||||||
<span>🌊 flood</span>
|
|
||||||
<span>💥 boom</span>
|
|
||||||
<span>👹 monster</span>
|
|
||||||
<span>💀 death</span>
|
|
||||||
<span>🔥 chaos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center justify-center rounded-lg border border-dashed border-slate-700/30 bg-slate-900/20",
|
"flex items-center justify-center rounded-lg border border-dashed border-slate-700/30 bg-slate-900/20",
|
||||||
"text-slate-600 text-sm",
|
"text-slate-600",
|
||||||
gridSize > 5 ? "p-1" : "p-2"
|
gridSize > 5 ? "text-[10px] p-1" : "text-sm p-2"
|
||||||
)}>
|
)}>
|
||||||
✖
|
✖
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +76,8 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
|
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
|
||||||
"select-none",
|
"select-none",
|
||||||
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
|
"aspect-square",
|
||||||
|
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-0.5",
|
||||||
marked
|
marked
|
||||||
? "border-cyan-500/60 bg-cyan-900/40 hover:bg-cyan-800/40 cursor-pointer"
|
? "border-cyan-500/60 bg-cyan-900/40 hover:bg-cyan-800/40 cursor-pointer"
|
||||||
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
|
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
|
||||||
@@ -87,12 +88,12 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
)}
|
)}
|
||||||
title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text}
|
title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text}
|
||||||
>
|
>
|
||||||
<span className={cn(gridSize > 5 ? "text-lg" : "text-2xl", "leading-none")}>
|
<span className={cn("leading-none", gridSize > 5 ? "text-2xl" : "text-3xl")}>
|
||||||
{item.emoji || "💀"}
|
{item.emoji || "💀"}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-center font-medium leading-tight text-slate-200",
|
"text-center font-medium leading-tight text-slate-200",
|
||||||
gridSize > 5 ? "text-[10px]" : "text-xs",
|
gridSize > 5 ? "text-xs" : "text-sm",
|
||||||
"line-clamp-2"
|
"line-clamp-2"
|
||||||
)}>
|
)}>
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -106,13 +107,13 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize,
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{marked && (
|
{marked && (
|
||||||
<span className="absolute top-0.5 left-1 text-[8px] text-cyan-500/60">✕</span>
|
<span className="absolute top-0.5 left-1 text-[9px] text-cyan-500/60">✕</span>
|
||||||
)}
|
)}
|
||||||
{!marked && !isFree && (
|
{!marked && !isFree && (
|
||||||
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
|
<span className="text-[10px] text-slate-600">{getSoundEmoji(item.soundCategory)}</span>
|
||||||
)}
|
)}
|
||||||
{isFree && !marked && (
|
{isFree && !marked && (
|
||||||
<span className={cn("text-[9px] text-amber-500/60 mt-0.5", gridSize > 5 ? "hidden" : "")}>
|
<span className="text-[10px] text-amber-500/60">
|
||||||
🎯 mark me
|
🎯 mark me
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ type Campaign = {
|
|||||||
gridSize: number;
|
gridSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LibrarySound = {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,6 +78,8 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
const [editSound, setEditSound] = useState("horn");
|
const [editSound, setEditSound] = useState("horn");
|
||||||
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
|
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [librarySounds, setLibrarySounds] = useState<LibrarySound[]>([]);
|
||||||
|
const [showLibrary, setShowLibrary] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const formRef = useRef<HTMLDivElement>(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -86,6 +93,16 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
useEffect(() => { fetchItems(); }, [campaign.id]);
|
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) => {
|
const updateItem = async (item: Item) => {
|
||||||
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -176,6 +193,15 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.url) setEditSoundUrl(data.url);
|
if (data.url) setEditSoundUrl(data.url);
|
||||||
setUploading(false);
|
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) => {
|
const playSoundOnce = (url: string) => {
|
||||||
@@ -233,42 +259,108 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{editSound === "custom" && (
|
|
||||||
<div>
|
|
||||||
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<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-[9px] h-10"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
|
|
||||||
</Button>
|
|
||||||
{editSoundUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => playSoundOnce(editSoundUrl)}
|
|
||||||
className="text-sm cursor-pointer hover:text-cyan-300"
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={submitItem} disabled={!editText} className="font-mono text-xs">
|
<Button size="sm" onClick={submitItem} disabled={!editText} className="font-mono text-xs">
|
||||||
{editItemId ? "SAVE" : "ADD"}
|
{editItemId ? "SAVE" : "ADD"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,5 +461,3 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user