diff --git a/README.pdf b/README.pdf new file mode 100644 index 0000000..c8a02ac Binary files /dev/null and b/README.pdf differ diff --git a/app/api/sounds/route.ts b/app/api/sounds/route.ts new file mode 100644 index 0000000..3417653 --- /dev/null +++ b/app/api/sounds/route.ts @@ -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 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 745c57b..6e9c860 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -18,7 +18,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) -
+
{children}
{/* Floating bubbles */} diff --git a/components/BingoCard.tsx b/components/BingoCard.tsx index ffd78e1..2b7c394 100644 --- a/components/BingoCard.tsx +++ b/components/BingoCard.tsx @@ -151,125 +151,92 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin "from-red-600 to-red-500"; return ( -
+
{/* Header */}
-

+

{campaign.name}

-

+

{markedCount}/{totalItems} cells marked

- {/* Chaos Meter */} -
-
- {stageLabel} - {Math.round(chaosLevel * 100)}% chaos + {/* Chaos + Subs compact row */} +
+
+
+ {stageLabel} + {Math.round(chaosLevel * 100)}% chaos +
+
+
= 0.6 && "animate-pulse" + )} + style={{ width: `${chaosLevel * 100}%` }} + /> +
-
-
= 0.6 && "animate-pulse" - )} - style={{ width: `${chaosLevel * 100}%` }} - /> +
+
+ Hull {Math.round(hullHealth)}% +
+
120 ? "border-orange-800/50 text-orange-400" : "border-slate-700/30 text-slate-500")}> + R{Math.round(reactorTemp)}° +
+
+ O₂ {Math.round(o2Level)}% +
- {/* Sub Status + Grid Row */} -
- {/* Sub Status Panel */} -
-
-

Sub Status

-
-
-
- Hull - {Math.round(hullHealth)}% -
-
-
-
-
-
-
- Reactor - 120 ? "text-orange-400" : "text-slate-400")}>{Math.round(reactorTemp)}°C -
-
-
120 ? "bg-orange-500 animate-pulse" : "bg-cyan-500")} style={{ width: `${Math.min(reactorTemp / 2, 100)}%` }} /> -
-
-
-
- O₂ - {Math.round(o2Level)}% -
-
-
-
-
-
-
- {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"} -
-
- - {/* Activity Log */} -
-

Crew Log

-
- {activityLog.length === 0 && ( -

Awaiting chaos...

- )} - {activityLog.map((entry, i) => ( -

{entry}

- ))} -
-
-
- - {/* Bingo Grid */} -
-
- {grid.map((cell) => ( - - ))} -
+ {/* Bingo Grid */} +
+
+ {grid.map((cell) => ( + + ))}
{/* Bingo Lines */} {bingoLines.length > 0 && ( -
- - 🏆 BINGO! {bingoLines.length} line(s) - -
+ + 🏆 BINGO! {bingoLines.length} line(s) + )} + {/* Activity Log */} +
+

Crew Log

+ {activityLog.length === 0 ? ( +

Awaiting chaos...

+ ) : ( +
+ {activityLog.map((entry, i) => ( +

{entry}

+ ))} +
+ )} +
+ {/* Bingo Celebration Modal */} {showBingo && (
setShowBingo(false)}> @@ -284,30 +251,6 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
)} - - {/* Mobile Activity Log */} -
-

Crew Log

-
- {activityLog.length === 0 && ( -

Awaiting chaos...

- )} - {activityLog.map((entry, i) => ( -

{entry}

- ))} -
-
- - {/* Legend */} -
- 🎺 horn - 🚨 alarm - 🌊 flood - 💥 boom - 👹 monster - 💀 death - 🔥 chaos -
); } diff --git a/components/BingoCell.tsx b/components/BingoCell.tsx index f50f6ef..a2651ff 100644 --- a/components/BingoCell.tsx +++ b/components/BingoCell.tsx @@ -56,8 +56,8 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, return (
5 ? "p-1" : "p-2" + "text-slate-600", + gridSize > 5 ? "text-[10px] p-1" : "text-sm p-2" )}> ✖
@@ -76,7 +76,8 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, className={cn( "relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden", "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 ? "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", @@ -87,12 +88,12 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, )} title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text} > - 5 ? "text-lg" : "text-2xl", "leading-none")}> + 5 ? "text-2xl" : "text-3xl")}> {item.emoji || "💀"} 5 ? "text-[10px]" : "text-xs", + gridSize > 5 ? "text-xs" : "text-sm", "line-clamp-2" )}> {item.text} @@ -106,13 +107,13 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, )} {marked && ( - + )} {!marked && !isFree && ( - {getSoundEmoji(item.soundCategory)} + {getSoundEmoji(item.soundCategory)} )} {isFree && !marked && ( - 5 ? "hidden" : "")}> + 🎯 mark me )} diff --git a/components/ItemEditor.tsx b/components/ItemEditor.tsx index d4e56d2..01f61ca 100644 --- a/components/ItemEditor.tsx +++ b/components/ItemEditor.tsx @@ -23,6 +23,11 @@ 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(null); @@ -73,6 +78,8 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { const [editSound, setEditSound] = useState("horn"); const [editSoundUrl, setEditSoundUrl] = useState(null); const [uploading, setUploading] = useState(false); + const [librarySounds, setLibrarySounds] = useState([]); + const [showLibrary, setShowLibrary] = useState(false); const fileInputRef = useRef(null); const formRef = useRef(null); @@ -86,6 +93,16 @@ 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 || []); + }; + + useEffect(() => { + if (editSound === "custom") fetchLibrary(); + }, [editSound]); + const updateItem = async (item: Item) => { await fetch(`/api/campaigns/${campaign.id}/items`, { method: "PUT", @@ -176,6 +193,15 @@ 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) => { @@ -233,42 +259,108 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
- {editSound === "custom" && ( -
- -
- { const f = e.target.files?.[0]; if (f) uploadSound(f); }} - /> - - {editSoundUrl && ( - - )} -
-
- )}
+ + {editSound === "custom" && ( +
+
+ { const f = e.target.files?.[0]; if (f) uploadSound(f); }} + /> + + {editSoundUrl && ( +
+ ✓ sound loaded + + +
+ )} +
+ +
+ +
+ + {showLibrary && librarySounds.length > 0 && ( +
+ {librarySounds.map(s => { + const isSelected = editSoundUrl === s.url; + return ( +
+ {s.filename} + + + +
+ ); + })} +
+ )} + + {showLibrary && librarySounds.length === 0 && ( +

No uploaded sounds yet. Upload an OGG file above.

+ )} +
+ )}
@@ -369,5 +461,3 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
); } - -