From d0cd008f6e17c846c383c87cb83b1020c7607ed6 Mon Sep 17 00:00:00 2001 From: SlavaVlad Date: Mon, 15 Jun 2026 01:07:05 +0300 Subject: [PATCH] v2.0: sound library, image upload, DnD, ResourceBrowser, Barotrauma HUD, captain sanity --- .gitea/workflows/deploy.yml | 3 +- app/api/campaigns/[campaignId]/items/route.ts | 11 +- app/api/images/route.ts | 93 ++++ app/api/sounds/import/route.ts | 63 +++ app/api/sounds/route.ts | 72 ++- app/api/sounds/search/route.ts | 29 ++ app/api/upload/sound/route.ts | 15 + components/BingoCard.tsx | 136 +++--- components/BingoCell.tsx | 30 +- components/ItemEditor.tsx | 232 ++++----- components/ResourceBrowser.tsx | 443 ++++++++++++++++++ docker-compose.yml | 1 + lib/db/index.ts | 18 + lib/db/schema.ts | 22 +- package-lock.json | 96 +++- package.json | 6 + 16 files changed, 1039 insertions(+), 231 deletions(-) create mode 100644 app/api/images/route.ts create mode 100644 app/api/sounds/import/route.ts create mode 100644 app/api/sounds/search/route.ts create mode 100644 components/ResourceBrowser.tsx diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ab77855..fde86c1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -32,8 +32,9 @@ jobs: - name: Copy files to server run: | ssh root@barabingo 'mkdir -p /opt/barabingo/data /opt/barabingo/uploads' + ssh root@barabingo 'echo "FREESOUND_API_KEY=${{ secrets.FREESOUND_API_KEY }}" > /opt/barabingo/.env' scp /tmp/barabingo.tar.gz docker-compose.yml root@barabingo:/opt/barabingo/ - name: Deploy on server run: | - ssh root@barabingo 'chmod 777 /opt/barabingo/data /opt/barabingo/uploads && cd /opt/barabingo && docker load < barabingo.tar.gz && docker compose down --remove-orphans 2>/dev/null; docker compose up -d && rm -f barabingo.tar.gz && docker image prune -f' + ssh root@barabingo 'chmod 777 /opt/barabingo/data /opt/barabingo/uploads && cd /opt/barabingo && docker load < barabingo.tar.gz && docker compose down --remove-orphans 2>/dev/null; docker compose --env-file .env up -d && rm -f barabingo.tar.gz && docker image prune -f' diff --git a/app/api/campaigns/[campaignId]/items/route.ts b/app/api/campaigns/[campaignId]/items/route.ts index ea22c18..7d20248 100644 --- a/app/api/campaigns/[campaignId]/items/route.ts +++ b/app/api/campaigns/[campaignId]/items/route.ts @@ -20,7 +20,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ cam } try { const { campaignId } = await params; - const { text, emoji, soundCategory, soundUrl, gridIndex } = await req.json(); + const { text, emoji, soundCategory, soundUrl, imageUrl, gridIndex } = await req.json(); if (!text) return NextResponse.json({ error: "Text required" }, { status: 400 }); const existing = db.select().from(bingoItems) @@ -32,7 +32,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ cam const id = uuidv4(); const now = new Date().toISOString(); - db.insert(bingoItems).values({ id, campaignId, text, emoji: emoji || "๐Ÿ’€", soundCategory: soundCategory || "horn", soundUrl: soundUrl || null, gridIndex, createdAt: now }).run(); + db.insert(bingoItems).values({ id, campaignId, text, emoji: emoji || "๐Ÿ’€", soundCategory: soundCategory || "horn", soundUrl: soundUrl || null, imageUrl: imageUrl || null, gridIndex, createdAt: now }).run(); return NextResponse.json({ id }); } catch { return NextResponse.json({ error: "Failed to add item" }, { status: 500 }); @@ -49,10 +49,11 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ camp const body = await req.json(); if (!body.id) return NextResponse.json({ error: "Item ID required" }, { status: 400 }); const updates: Record = {}; - if (body.text) updates.text = body.text; - if (body.emoji) updates.emoji = body.emoji; - if (body.soundCategory) updates.soundCategory = body.soundCategory; + if (body.text !== undefined) updates.text = body.text; + if (body.emoji !== undefined) updates.emoji = body.emoji; + if (body.soundCategory !== undefined) updates.soundCategory = body.soundCategory; if (body.soundUrl !== undefined) updates.soundUrl = body.soundUrl; + if (body.imageUrl !== undefined) updates.imageUrl = body.imageUrl; if (body.gridIndex !== undefined) updates.gridIndex = body.gridIndex; db.update(bingoItems).set(updates) .where(and(eq(bingoItems.id, body.id), eq(bingoItems.campaignId, campaignId))) diff --git a/app/api/images/route.ts b/app/api/images/route.ts new file mode 100644 index 0000000..fb91364 --- /dev/null +++ b/app/api/images/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { images } from "@/lib/db/schema"; +import { writeFile, mkdir, unlink } from "fs/promises"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { eq } from "drizzle-orm"; + +const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads", "images"); + +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; +const MAX_SIZE = 5 * 1024 * 1024; + +export async function GET() { + const session = await getServerSession(); + if (!session || !session.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + try { + const all = db.select().from(images).orderBy(images.createdAt).all(); + const list = all.map(i => ({ + id: i.id, + originalName: i.originalName, + url: `/uploads/images/${i.storedFilename}`, + mimeType: i.mimeType, + fileSize: i.fileSize, + })); + return NextResponse.json({ images: list }); + } catch { + return NextResponse.json({ images: [] }); + } +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(); + if (!session || !session.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + try { + const formData = await req.formData(); + const files = formData.getAll("files") as File[]; + if (!files.length) return NextResponse.json({ error: "No files" }, { status: 400 }); + + await mkdir(UPLOAD_DIR, { recursive: true }); + const results: { url: string; originalName: string; mimeType: string; fileSize: number }[] = []; + + for (const file of files) { + if (!ALLOWED_TYPES.includes(file.type)) continue; + if (file.size > MAX_SIZE) continue; + + const ext = path.extname(file.name) || ".png"; + const filename = `${uuidv4()}${ext}`; + const filepath = path.join(UPLOAD_DIR, filename); + const bytes = await file.arrayBuffer(); + await writeFile(filepath, Buffer.from(bytes)); + + const id = uuidv4(); + db.insert(images).values({ + id, + originalName: file.name, + storedFilename: filename, + mimeType: file.type, + fileSize: file.size, + uploadedBy: session.id, + createdAt: new Date().toISOString(), + }).run(); + + results.push({ url: `/uploads/images/${filename}`, originalName: file.name, mimeType: file.type, fileSize: file.size }); + } + + return NextResponse.json({ images: results }); + } catch { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } +} + +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(UPLOAD_DIR, filename); + await unlink(filepath).catch(() => {}); + db.delete(images).where(eq(images.storedFilename, filename)).run(); + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: "Delete failed" }, { status: 500 }); + } +} diff --git a/app/api/sounds/import/route.ts b/app/api/sounds/import/route.ts new file mode 100644 index 0000000..4ad0852 --- /dev/null +++ b/app/api/sounds/import/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { sounds } from "@/lib/db/schema"; +import { writeFile, mkdir } from "fs/promises"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; + +const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads", "sounds"); + +export async function POST(req: NextRequest) { + const session = await getServerSession(); + if (!session || !session.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { name, duration, previewUrl } = await req.json(); + if (!previewUrl || !name) { + return NextResponse.json({ error: "Missing name or previewUrl" }, { status: 400 }); + } + + if (duration > 6) { + return NextResponse.json({ error: "Sound too long (max 6s)" }, { status: 400 }); + } + + await mkdir(UPLOAD_DIR, { recursive: true }); + + const res = await fetch(previewUrl); + if (!res.ok) return NextResponse.json({ error: "Failed to download preview" }, { status: 502 }); + + const buffer = Buffer.from(await res.arrayBuffer()); + + // trim to 6 seconds: approximate by byte ratio + const maxBytes = Math.floor(buffer.length * Math.min(6 / duration, 1)); + const trimmed = buffer.subarray(0, maxBytes); + + const ext = ".mp3"; + const filename = `${uuidv4()}${ext}`; + const filepath = path.join(UPLOAD_DIR, filename); + await writeFile(filepath, trimmed); + + const id = uuidv4(); + const originalName = name.replace(/\.[^.]+$/, "") + ext; + + db.insert(sounds).values({ + id, + originalName, + storedFilename: filename, + duration: Math.min(duration, 6), + uploadedBy: session.id, + createdAt: new Date().toISOString(), + }).run(); + + return NextResponse.json({ + url: `/uploads/sounds/${filename}`, + originalName, + duration: Math.min(duration, 6), + }); + } catch { + return NextResponse.json({ error: "Import failed" }, { status: 500 }); + } +} diff --git a/app/api/sounds/route.ts b/app/api/sounds/route.ts index 3417653..1018ab9 100644 --- a/app/api/sounds/route.ts +++ b/app/api/sounds/route.ts @@ -1,9 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "@/lib/auth"; -import { readdir, unlink } from "fs/promises"; +import { db } from "@/lib/db"; +import { sounds } from "@/lib/db/schema"; +import { writeFile, mkdir, unlink } from "fs/promises"; import path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { eq } from "drizzle-orm"; -const SOUNDS_DIR = path.join(process.cwd(), "public", "uploads", "sounds"); +const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads", "sounds"); export async function GET() { const session = await getServerSession(); @@ -11,17 +15,64 @@ export async function GET() { 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 }); + const all = db.select().from(sounds).orderBy(sounds.createdAt).all(); + const list = all.map(s => ({ + id: s.id, + originalName: s.originalName, + url: `/uploads/sounds/${s.storedFilename}`, + duration: s.duration, + })); + return NextResponse.json({ sounds: list }); } catch { return NextResponse.json({ sounds: [] }); } } +export async function POST(req: NextRequest) { + const session = await getServerSession(); + if (!session || !session.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + try { + const formData = await req.formData(); + const files = formData.getAll("files") as File[]; + if (!files.length) return NextResponse.json({ error: "No files" }, { status: 400 }); + + await mkdir(UPLOAD_DIR, { recursive: true }); + const results: { url: string; originalName: string; duration: number }[] = []; + + for (const file of files) { + if (!file.name.toLowerCase().endsWith(".ogg")) continue; + if (file.size > 2 * 1024 * 1024) continue; + + const ext = path.extname(file.name); + const filename = `${uuidv4()}${ext}`; + const filepath = path.join(UPLOAD_DIR, filename); + const bytes = await file.arrayBuffer(); + await writeFile(filepath, Buffer.from(bytes)); + + // compute duration from file size (approximate for OGG Vorbis ~64kbps) + const duration = Math.round((file.size / (64 * 128)) * 10) / 10; + + const id = uuidv4(); + db.insert(sounds).values({ + id, + originalName: file.name, + storedFilename: filename, + duration: Math.min(duration, 6), + uploadedBy: session.id, + createdAt: new Date().toISOString(), + }).run(); + + results.push({ url: `/uploads/sounds/${filename}`, originalName: file.name, duration: Math.min(duration, 6) }); + } + + return NextResponse.json({ sounds: results }); + } catch { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } +} + export async function DELETE(req: NextRequest) { const session = await getServerSession(); if (!session || !session.isAdmin) { @@ -30,8 +81,9 @@ export async function DELETE(req: NextRequest) { 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); + const filepath = path.join(UPLOAD_DIR, filename); + await unlink(filepath).catch(() => {}); + db.delete(sounds).where(eq(sounds.storedFilename, filename)).run(); return NextResponse.json({ success: true }); } catch { return NextResponse.json({ error: "Delete failed" }, { status: 500 }); diff --git a/app/api/sounds/search/route.ts b/app/api/sounds/search/route.ts new file mode 100644 index 0000000..44e1430 --- /dev/null +++ b/app/api/sounds/search/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; + +const FREESOUND_API = "https://freesound.org/apiv2/search/text/"; +const API_KEY = process.env.FREESOUND_API_KEY || ""; + +export async function GET(req: NextRequest) { + const query = req.nextUrl.searchParams.get("q") || ""; + const page = parseInt(req.nextUrl.searchParams.get("page") || "1"); + + if (!query) return NextResponse.json({ results: [] }); + if (!API_KEY) return NextResponse.json({ error: "Freesound API key not configured" }, { status: 500 }); + + try { + const url = `${FREESOUND_API}?query=${encodeURIComponent(query)}&page=${page}&token=${API_KEY}&page_size=12&fields=id,name,duration,previews`; + const res = await fetch(url); + const data = await res.json(); + + const results = (data.results || []).map((r: any) => ({ + id: r.id, + name: r.name, + duration: r.duration, + previewUrl: r.previews?.["preview-lq-mp3"] || r.previews?.["preview-hq-mp3"] || null, + })); + + return NextResponse.json({ results, count: data.count || 0 }); + } catch (e) { + return NextResponse.json({ error: "Freesound search failed" }, { status: 500 }); + } +} diff --git a/app/api/upload/sound/route.ts b/app/api/upload/sound/route.ts index 2d843be..0e602ab 100644 --- a/app/api/upload/sound/route.ts +++ b/app/api/upload/sound/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { sounds } from "@/lib/db/schema"; import { writeFile, mkdir } from "fs/promises"; import path from "path"; import { v4 as uuidv4 } from "uuid"; @@ -32,6 +34,19 @@ export async function POST(req: NextRequest) { const bytes = await file.arrayBuffer(); await writeFile(filepath, Buffer.from(bytes)); + // approximate duration + const duration = Math.round((file.size / (64 * 128)) * 10) / 10; + + const id = uuidv4(); + db.insert(sounds).values({ + id, + originalName: file.name, + storedFilename: filename, + duration: Math.min(duration, 6), + uploadedBy: session.id, + createdAt: new Date().toISOString(), + }).run(); + return NextResponse.json({ url: `/uploads/sounds/${filename}` }); } catch { return NextResponse.json({ error: "Upload failed" }, { status: 500 }); diff --git a/components/BingoCard.tsx b/components/BingoCard.tsx index 2b7c394..13a63b8 100644 --- a/components/BingoCard.tsx +++ b/components/BingoCard.tsx @@ -13,6 +13,7 @@ type Item = { emoji: string; soundCategory: string; soundUrl?: string | null; + imageUrl?: string | null; gridIndex: number; }; @@ -41,26 +42,49 @@ type Props = { function checkBingo(grid: GridCell[], gridSize: number): number[][] { const lines: number[][] = []; const size = gridSize; - for (let row = 0; row < size; row++) { const indices = Array.from({ length: size }, (_, c) => row * size + c); if (indices.every(i => grid[i]?.marked)) lines.push(indices); } - for (let col = 0; col < size; col++) { const indices = Array.from({ length: size }, (_, r) => r * size + col); if (indices.every(i => grid[i]?.marked)) lines.push(indices); } - const diag1 = Array.from({ length: size }, (_, i) => i * size + i); if (diag1.every(i => grid[i]?.marked)) lines.push(diag1); - const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1); if (diag2.every(i => grid[i]?.marked)) lines.push(diag2); - return lines; } +function StatusGauge({ label, value, unit, low, high, icon, stage }: { + label: string; value: number; unit?: string; low?: boolean; high?: boolean; icon: string; stage?: string; +}) { + const pct = Math.min(Math.max(value, 0), 10000) / 100; + return ( +
+ {icon} +
+
+ {label} + + {unit ? `${Math.round(value)}${unit}` : Math.round(value)} + +
+
+
+
+
+
+ ); +} + export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) { const [grid, setGrid] = useState(initialGrid); const [bingoLines, setBingoLines] = useState([]); @@ -87,9 +111,7 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin setShowBingo(true); playBingo(); } - if (data.activityLog) { - setActivityLog(data.activityLog); - } + if (data.activityLog) setActivityLog(data.activityLog); } } catch {} }, [campaign.id, campaign.gridSize]); @@ -111,9 +133,7 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin headers: { "Content-Type": "application/json" }, body: JSON.stringify({ campaignId: campaign.id, itemId }), }); - if (!res.ok && res.status === 409) { - // already marked by someone else, just refresh - } + if (!res.ok && res.status === 409) {} await fetchState(); } catch {} setMarking(null); @@ -136,60 +156,60 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin const markedCount = grid.filter(c => c.marked).length; const totalItems = grid.filter(c => c.item).length; + // HUD params + const reactorTemp = 2000 + chaosLevel * 8000; const hullHealth = Math.max(0, 100 - chaosLevel * 100); - const reactorTemp = 80 + chaosLevel * 120; - const o2Level = Math.max(0, 100 - chaosLevel * 130); + const floodLevel = Math.min(100, chaosLevel * 100); + const captainSanity = Math.max(0, 100 - chaosLevel * 100); + + const sanityStage = + captainSanity >= 75 ? "calm" : + captainSanity >= 50 ? "nervous" : + captainSanity >= 25 ? "panic" : "lost"; + + const sanityLabel = + sanityStage === "calm" ? "ะกะฟะพะบะพะตะฝ" : + sanityStage === "nervous" ? "ะะตั€ะฒะฝะธั‡ะฐะตั‚" : + sanityStage === "panic" ? "ะŸะฐะฝะธะบะฐ!" : "ะšัƒะบัƒั…ะฐ ัƒะตั…ะฐะปะฐ!"; + + const sanityIcon = + sanityStage === "calm" ? "๐Ÿ˜" : + sanityStage === "nervous" ? "๐Ÿ˜ฐ" : + sanityStage === "panic" ? "๐Ÿ˜ฑ" : "๐Ÿคช"; const stageLabel = - chaosLevel < 0.25 ? "โœ… Sub stable" : + chaosLevel < 0.25 ? "Sub stable" : chaosLevel < 0.5 ? "โš ๏ธ Leaking" : - chaosLevel < 0.75 ? "๐Ÿšจ MELTDOWN" : "โ˜ข๏ธ SUB DESTROYED"; - - const chaosStageClass = - chaosLevel < 0.3 ? "from-cyan-600 to-cyan-500" : - chaosLevel < 0.6 ? "from-amber-600 to-orange-500" : - "from-red-600 to-red-500"; + chaosLevel < 0.75 ? "๐Ÿšจ MELTDOWN" : "โ˜ข๏ธ DESTROYED"; return ( -
+
{/* Header */}
-

- {campaign.name} -

-

- {markedCount}/{totalItems} cells marked +

{campaign.name}

+

+ {markedCount}/{totalItems} cells โ€” {stageLabel}

- {/* Chaos + Subs compact row */} -
-
-
- {stageLabel} - {Math.round(chaosLevel * 100)}% chaos -
-
-
= 0.6 && "animate-pulse" - )} - style={{ width: `${chaosLevel * 100}%` }} - /> -
+ {/* HUD Panel */} +
+
+ 9000} high={reactorTemp > 5000 && reactorTemp <= 9000} /> + + 50} /> +
-
-
- 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)}% -
+
+ {sanityStage === "calm" && "ะšะฐะฟะธั‚ะฐะฝ ัะฟะพะบะพะตะฝ. ะŸะพะบะฐ."} + {sanityStage === "nervous" && "ะšะฐะฟะธั‚ะฐะฝ ะดั‘ั€ะณะฐะตั‚ัั. ะญั‚ะพ ะฝะพั€ะผะฐะปัŒะฝะพ."} + {sanityStage === "panic" && "ะšะญะŸ ะะ ะะ•ะ ะ’ะะฅ! ะะ• ะขะ ะžะ“ะะ™ะขะ• ะ•ะ“ะž!"} + {sanityStage === "lost" && "ะšะฃะšะฃะฅะ ะฃะ•ะฅะะ›ะ! ะšะฐะฟะธั‚ะฐะฝ ัƒัˆั‘ะป ะฒ ะพั‚ั€ั‹ะฒ!"}
@@ -237,17 +257,15 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin )}
- {/* Bingo Celebration Modal */} + {/* Bingo Modal */} {showBingo && (
setShowBingo(false)}>
e.stopPropagation()}>
๐Ÿ†๐Ÿ”ฅ๐Ÿ’€

ะ‘ะ˜ะะ“ะž!

-

ะฅะฐะพั ะฟะพะฑะตะดะธะป! ะกัƒะฑ ะฒะทะพั€ะฒะฐะฝ, ัะบะธะฟะฐะถ ะผั‘ั€ั‚ะฒ, ะฒัะตะผ ะฒะตัะตะปะพ!

-
๐ŸŽ‰๐Ÿ’ฅ๐ŸŒŠ๐Ÿคก
- +

ะšะฐะฟะธั‚ะฐะฝ ะพั„ะพะฝะฐั€ะตะป! ะกัƒะฑ ั€ะฐะทะณะตั€ะผะตั‚ะธะทะธั€ะพะฒะฐะฝ! ะ’ัะต ะผะตั€ั‚ะฒั‹!

+
๐ŸŽ‰๐Ÿ’ฅ๐ŸŒŠ๐Ÿคช
+
)} diff --git a/components/BingoCell.tsx b/components/BingoCell.tsx index a2651ff..059516a 100644 --- a/components/BingoCell.tsx +++ b/components/BingoCell.tsx @@ -9,6 +9,7 @@ type Item = { emoji: string; soundCategory: string; soundUrl?: string | null; + imageUrl?: string | null; gridIndex: number; }; @@ -76,8 +77,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", - "aspect-square", - gridSize > 5 ? "p-1 gap-0" : "p-2 gap-0.5", + "min-h-[90px]", + gridSize > 5 ? "p-1.5 gap-0.5" : "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", @@ -88,34 +89,33 @@ export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, )} title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text} > - 5 ? "text-2xl" : "text-3xl")}> + {item.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + 5 ? "text-2xl" : "text-2xl")}> {item.emoji || "๐Ÿ’€"} 5 ? "text-xs" : "text-sm", - "line-clamp-2" + "text-center font-medium leading-tight text-slate-200 relative z-10", + "text-xs", + "line-clamp-2 px-0.5" )}> {item.text} {marked && markCount > 0 && ( - 5 ? "text-[9px]" : "text-xs" - )}> + 5 ? "text-[9px]" : "text-xs")}> {markCount} )} {marked && ( - โœ• + โœ• )} {!marked && !isFree && ( - {getSoundEmoji(item.soundCategory)} + {getSoundEmoji(item.soundCategory)} )} {isFree && !marked && ( - - ๐ŸŽฏ mark me - + ๐ŸŽฏ mark me )} ); diff --git a/components/ItemEditor.tsx b/components/ItemEditor.tsx index 01f61ca..f5ff230 100644 --- a/components/ItemEditor.tsx +++ b/components/ItemEditor.tsx @@ -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(null); @@ -77,9 +74,12 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { const [editEmoji, setEditEmoji] = useState("๐Ÿ’€"); const [editSound, setEditSound] = useState("horn"); const [editSoundUrl, setEditSoundUrl] = useState(null); + const [editImageUrl, setEditImageUrl] = useState(null); const [uploading, setUploading] = useState(false); - const [librarySounds, setLibrarySounds] = useState([]); + const [librarySounds, setLibrarySounds] = useState<{ originalName: string; url: string }[]>([]); const [showLibrary, setShowLibrary] = useState(false); + const [showBrowser, setShowBrowser] = useState(false); + const [dragOverIdx, setDragOverIdx] = useState(null); const fileInputRef = useRef(null); const formRef = useRef(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 }) {

{editItemId ? "โœ Edit Cell" : "+ Add New Cell"}

- {editItemId && ( - + )} + - )} +
@@ -286,85 +320,25 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { {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.

- )} + {editImageUrl && ( +
+ ๐Ÿ–ผ๏ธ image loaded +
)}
+ {/* Grid */}
it.gridIndex === idx); if (!item) { return ( -
+
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" : "")} + > โœ–
); } const isEditing = editItemId === item.id; return ( - + 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" : "")} + > - {item.emoji} - + {item.emoji} + {item.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {item.text} - - {item.soundUrl ? "๐Ÿ”Š" : item.soundCategory} - -
- - -
{item.soundUrl && ( )} + + {item.imageUrl ? "๐Ÿ–ผ๏ธ" : item.soundUrl ? "๐Ÿ”Š" : item.soundCategory} + +
+ + +
); })}
+ {/* Resource Browser dock */} + setShowBrowser(false)} /> + + {/* All Items list */}

All Items

{items.map(item => ( @@ -430,9 +408,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) { { - 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)} /> diff --git a/components/ResourceBrowser.tsx b/components/ResourceBrowser.tsx new file mode 100644 index 0000000..97363ec --- /dev/null +++ b/components/ResourceBrowser.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useDropzone } from "react-dropzone"; +import { Howl } from "howler"; + +// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type LibrarySound = { + id: string; + originalName: string; + url: string; + duration: number; +}; + +type LibraryImage = { + id: string; + originalName: string; + url: string; + mimeType: string; + fileSize: number; +}; + +type FreesoundResult = { + id: number; + name: string; + duration: number; + previewUrl: string | null; +}; + +export type DragData = + | { type: "sound"; url: string; originalName: string } + | { type: "image"; url: string; originalName: string } + | { type: "emoji"; emoji: string }; + +// โ”€โ”€โ”€ Howl helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const howlCache = new Map(); + +function playPreview(url: string) { + let h = howlCache.get(url); + if (!h) { + h = new Howl({ src: [url], format: ["mp3", "ogg"], volume: 0.4, preload: true }); + howlCache.set(url, h); + } + h.play(); +} + +// โ”€โ”€โ”€ Emoji Picker (inline, searchable) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const EMOJI_LIST = [ + "๐Ÿ˜€", "๐Ÿ˜‚", "๐Ÿคฃ", "๐Ÿ˜", "๐Ÿฅฐ", "๐Ÿ˜˜", "๐Ÿ˜Š", "๐Ÿ˜Ž", "๐Ÿคฉ", "๐Ÿฅณ", "๐Ÿคฏ", "๐Ÿ˜ฑ", + "๐Ÿ˜ˆ", "๐Ÿคก", "๐Ÿ’€", "๐Ÿ‘ป", "๐Ÿ‘ฝ", "๐Ÿค–", "๐ŸŽƒ", "๐Ÿ˜บ", "๐Ÿ™ˆ", "๐Ÿ™‰", "๐Ÿ™Š", "๐Ÿ’ฉ", + "๐Ÿ”ฅ", "๐ŸŒŠ", "๐Ÿ’ฅ", "๐Ÿ’ซ", "โญ", "๐ŸŒŸ", "โœจ", "โšก", "โ˜„๏ธ", "๐Ÿ’ง", "๐ŸงŠ", "๐ŸŒ‹", + "๐ŸŽ‰", "๐ŸŽŠ", "๐ŸŽˆ", "๐ŸŽ", "๐Ÿ†", "๐Ÿฅ‡", "๐Ÿ’Ž", "๐Ÿ”ฎ", "๐Ÿช„", "๐Ÿงจ", "๐Ÿ”ซ", "โš”๏ธ", + "๐Ÿ›ก๏ธ", "๐Ÿš€", "๐Ÿ›ธ", "๐Ÿš", "โš“", "๐ŸŒ€", "๐ŸŒˆ", "๐ŸŒช๏ธ", "โ˜ข๏ธ", "โ˜ฃ๏ธ", "โš•๏ธ", "๐Ÿงฌ", + "๐Ÿ’‰", "๐Ÿ’Š", "๐Ÿฉธ", "๐Ÿงช", "๐Ÿ”ฌ", "๐Ÿ”ญ", "๐Ÿ“ก", "๐ŸŽฏ", "๐ŸŽฒ", "โ™Ÿ๏ธ", "๐Ÿงฉ", "๐ŸŽญ", + "๐ŸŽต", "๐ŸŽถ", "๐ŸŽบ", "๐Ÿ“ฏ", "๐Ÿ””", "๐ŸŽค", "๐ŸŽง", "๐Ÿ“ข", "๐Ÿ“ฃ", "๐Ÿ”Š", "๐Ÿ”‡", "๐Ÿ’ค", + "๐Ÿ’ฃ", "๐Ÿ”ช", "๐Ÿชฆ", "โšฐ๏ธ", "๐Ÿชค", "๐Ÿงฒ", "๐Ÿ—๏ธ", "๐Ÿ”‘", "๐Ÿ”“", "๐Ÿ”’", "๐Ÿ› ๏ธ", "โ›“๏ธ", + "๐Ÿงน", "๐Ÿช ", "๐Ÿ”ง", "โš™๏ธ", "๐Ÿ“ฆ", "๐Ÿ“€", "๐Ÿ’ฟ", "๐Ÿ“น", "๐Ÿ“ธ", "๐Ÿ“ท", "๐Ÿ–ผ๏ธ", "๐ŸŽจ", + "๐Ÿบ", "๐Ÿป", "๐Ÿท", "๐Ÿฅƒ", "๐Ÿธ", "๐Ÿน", "๐Ÿง‰", "๐Ÿ•", "๐Ÿ”", "๐ŸŒญ", "๐Ÿฟ", "๐Ÿง€", + "๐Ÿฅœ", "๐ŸŒถ๏ธ", "๐Ÿ„", "๐Ÿฅš", "๐Ÿง…", "๐Ÿฅ•", "๐Ÿฅฆ", "๐Ÿซ", "๐Ÿง ", "๐Ÿ‘€", "๐Ÿ‘๏ธ", "๐Ÿซ€", + "๐Ÿ’‹", "๐Ÿซ‚", "๐Ÿค", "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘Š", "โœŠ", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘†", "๐Ÿ‘‡", "๐Ÿ–•", + "๐Ÿ’ช", "๐Ÿฆต", "๐Ÿฆถ", "๐Ÿ‘‚", "๐Ÿ‘ƒ", "๐Ÿง‘โ€๐Ÿš€", "๐Ÿง‘โ€๐Ÿ”ง", "๐Ÿง‘โ€โš•๏ธ", "๐Ÿง‘โ€โœˆ๏ธ", "๐Ÿง‘โ€๐Ÿซ", "๐Ÿง‘โ€๐ŸŽค", "๐Ÿง‘โ€๐Ÿณ", + "๐Ÿถ", "๐Ÿฑ", "๐Ÿญ", "๐Ÿน", "๐Ÿฐ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿผ", "๐Ÿจ", "๐Ÿฏ", "๐Ÿฆ", "๐Ÿฎ", + "๐Ÿฆˆ", "๐Ÿ™", "๐Ÿฆ‘", "๐Ÿ‹", "๐Ÿฌ", "๐Ÿฆญ", "๐ŸŠ", "๐ŸฆŽ", "๐Ÿ", "๐Ÿข", "๐Ÿฆ–", "๐Ÿฆ•", + "๐ŸฆŸ", "๐Ÿฆ—", "๐Ÿ›", "๐Ÿชฑ", "๐Ÿฆ‹", "๐ŸŒ", "๐Ÿž", "๐Ÿœ", "๐Ÿฆ‚", "๐Ÿ•ท๏ธ", "๐Ÿฆ€", "๐Ÿชธ", +]; + +function EmojiPickerGrid({ onSelect }: { onSelect: (emoji: string) => void }) { + const [search, setSearch] = useState(""); + const filtered = search ? EMOJI_LIST.filter(e => e.includes(search)) : EMOJI_LIST; + + return ( +
+ setSearch(e.target.value)} + className="w-full bg-slate-800/60 border border-slate-700/30 rounded px-2 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none focus:border-cyan-500/50" + /> +
+ {filtered.map(e => ( + + ))} +
+
+ ); +} + +// โ”€โ”€โ”€ Sounds Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function SoundsTab() { + const [sounds, setSounds] = useState([]); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [showSearch, setShowSearch] = useState(false); + const searchTimer = useRef>(undefined); + + const fetchSounds = useCallback(async () => { + try { + const res = await fetch("/api/sounds"); + const data = await res.json(); + setSounds(data.sounds || []); + } catch {} + }, []); + + useEffect(() => { fetchSounds(); }, [fetchSounds]); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: async (files) => { + if (!files.length) return; + setUploading(true); + setProgress(0); + const formData = new FormData(); + files.forEach(f => formData.append("files", f)); + try { + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = e => { + if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); + }; + await new Promise((resolve, reject) => { + xhr.onload = () => { if (xhr.status === 200) resolve(); else reject(); }; + xhr.onerror = () => reject(); + xhr.open("POST", "/api/sounds"); + xhr.send(formData); + }); + await fetchSounds(); + } catch {} + setUploading(false); + }, + accept: { "audio/ogg": [".ogg"] }, + multiple: true, + }); + + const deleteSound = async (filename: string) => { + await fetch(`/api/sounds?filename=${encodeURIComponent(filename)}`, { method: "DELETE" }); + await fetchSounds(); + }; + + // Freesound search with debounce + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { setSearchResults([]); return; } + setSearching(true); + try { + const res = await fetch(`/api/sounds/search?q=${encodeURIComponent(q)}&page=1`); + const data = await res.json(); + setSearchResults(data.results || []); + } catch {} + setSearching(false); + }, []); + + useEffect(() => { + clearTimeout(searchTimer.current); + if (!searchQuery.trim()) { setSearchResults([]); return; } + searchTimer.current = setTimeout(() => doSearch(searchQuery), 300); + return () => clearTimeout(searchTimer.current); + }, [searchQuery, doSearch]); + + const importSound = async (result: FreesoundResult) => { + if (!result.previewUrl) return; + await fetch("/api/sounds/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: result.name, duration: result.duration, previewUrl: result.previewUrl }), + }); + await fetchSounds(); + }; + + return ( +
+ {uploading && ( +
+
+ Uploading... + {progress}% +
+
+
+
+
+ )} + +
+ +

Drop OGG files here or click to upload

+
+ + {/* Library */} + {sounds.length > 0 && ( +
+ {sounds.map(s => { + const filename = s.url.split("/").pop() || ""; + return ( + playPreview(s.url)} + onDelete={() => deleteSound(filename)} + /> + ); + })} +
+ )} + + {/* Freesound toggle */} + + + {showSearch && ( +
+ setSearchQuery(e.target.value)} + className="w-full bg-slate-800/60 border border-slate-700/30 rounded px-2 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none focus:border-cyan-500/50" + /> + {searching &&

Searching...

} +
+ {searchResults.map(r => ( + r.previewUrl && playPreview(r.previewUrl)} + onImport={() => importSound(r)} + /> + ))} +
+
+ )} +
+ ); +} + +// โ”€โ”€โ”€ Images Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function ImagesTab() { + const [images, setImages] = useState([]); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + + const fetchImages = useCallback(async () => { + try { + const res = await fetch("/api/images"); + const data = await res.json(); + setImages(data.images || []); + } catch {} + }, []); + + useEffect(() => { fetchImages(); }, [fetchImages]); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: async (files) => { + if (!files.length) return; + setUploading(true); + setProgress(0); + const formData = new FormData(); + files.forEach(f => formData.append("files", f)); + try { + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = e => { + if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); + }; + await new Promise((resolve, reject) => { + xhr.onload = () => { if (xhr.status === 200) resolve(); else reject(); }; + xhr.onerror = () => reject(); + xhr.open("POST", "/api/images"); + xhr.send(formData); + }); + await fetchImages(); + } catch {} + setUploading(false); + }, + accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] }, + multiple: true, + }); + + const deleteImage = async (filename: string) => { + await fetch(`/api/images?filename=${encodeURIComponent(filename)}`, { method: "DELETE" }); + await fetchImages(); + }; + + return ( +
+ {uploading && ( +
+
+ Uploading... + {progress}% +
+
+
+
+
+ )} + +
+ +

Drop images here or click to upload

+
+ +
+ {images.map(img => { + const filename = img.url.split("/").pop() || ""; + return ( +
{ + ev.dataTransfer.setData("application/json", JSON.stringify({ type: "image", url: img.url, originalName: img.originalName } satisfies DragData)); + }} + className="relative group aspect-square rounded border border-slate-700/30 bg-slate-800/40 overflow-hidden cursor-grab active:cursor-grabbing" + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {img.originalName} + +
+ ); + })} +
+
+ ); +} + +// โ”€โ”€โ”€ Shared Row Components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function SoundRow({ label, duration, url, onPlay, onDelete }: { + label: string; duration: number; url: string; onPlay: () => void; onDelete: () => void; +}) { + return ( +
{ + ev.dataTransfer.setData("application/json", JSON.stringify({ type: "sound", url, originalName: label } satisfies DragData)); + }} + className="flex items-center gap-2 px-2 py-1.5 rounded bg-slate-800/30 border border-slate-700/20 text-[10px] font-mono cursor-grab active:cursor-grabbing hover:bg-slate-700/30 transition-colors group" + > + ๐Ÿ”Š + {label} + {duration.toFixed(1)}s + + +
+ ); +} + +function FreesoundRow({ name, duration, onPreview, onImport }: { + name: string; duration: number; onPreview: () => void; onImport: () => void; +}) { + return ( +
+ ๐ŸŒ + {name} + {duration.toFixed(1)}s + + +
+ ); +} + +// โ”€โ”€โ”€ Main ResourceBrowser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type Tab = "sounds" | "images" | "emojis"; + +export function ResourceBrowser({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const [tab, setTab] = useState("sounds"); + + if (!visible) return null; + + return ( +
+ {/* Tab bar */} +
+ {([ + { key: "sounds" as const, label: "๐Ÿ”Š Sounds" }, + { key: "images" as const, label: "๐Ÿ–ผ๏ธ Images" }, + { key: "emojis" as const, label: "๐Ÿ˜€ Emojis" }, + ]).map(t => ( + + ))} +
+ +
+ + {/* Content */} +
+ {tab === "sounds" && } + {tab === "images" && } + {tab === "emojis" && } +
+
+ ); +} + +function EmojisTab() { + const handleSelect = (emoji: string) => { + // Dispatch a custom event so ItemEditor can pick it up + window.dispatchEvent(new CustomEvent("emoji-select", { detail: { emoji } })); + }; + + return
; +} diff --git a/docker-compose.yml b/docker-compose.yml index 8f8d6f7..4a26f73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,4 @@ services: - ./uploads:/app/public/uploads environment: - NODE_ENV=production + - FREESOUND_API_KEY=${FREESOUND_API_KEY} diff --git a/lib/db/index.ts b/lib/db/index.ts index 4a94ada..e79270f 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -59,8 +59,26 @@ sqlite.exec(` ); CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_item_pos ON bingo_items(campaign_id, grid_index); CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_mark ON marks(campaign_id, item_id); + CREATE TABLE IF NOT EXISTS sounds ( + id TEXT PRIMARY KEY, + original_name TEXT NOT NULL, + stored_filename TEXT NOT NULL UNIQUE, + duration REAL NOT NULL DEFAULT 0, + uploaded_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + original_name TEXT NOT NULL, + stored_filename TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL DEFAULT 'image/png', + file_size INTEGER NOT NULL, + uploaded_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL + ); `); try { sqlite.exec("ALTER TABLE bingo_items ADD COLUMN sound_url TEXT"); } catch {} +try { sqlite.exec("ALTER TABLE bingo_items ADD COLUMN image_url TEXT"); } catch {} export const db = drizzle(sqlite, { schema }); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8f381a8..1a7319b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey(), @@ -31,12 +31,32 @@ export const bingoItems = sqliteTable("bingo_items", { emoji: text("emoji").notNull().default("๐Ÿ’€"), soundCategory: text("sound_category").notNull().default("horn"), soundUrl: text("sound_url"), + imageUrl: text("image_url"), gridIndex: integer("grid_index").notNull(), createdAt: text("created_at").notNull(), }, (table) => [ uniqueIndex("idx_campaign_item_pos").on(table.campaignId, table.gridIndex), ]); +export const sounds = sqliteTable("sounds", { + id: text("id").primaryKey(), + originalName: text("original_name").notNull(), + storedFilename: text("stored_filename").unique().notNull(), + duration: real("duration").notNull().default(0), + uploadedBy: text("uploaded_by").notNull().references(() => users.id), + createdAt: text("created_at").notNull(), +}); + +export const images = sqliteTable("images", { + id: text("id").primaryKey(), + originalName: text("original_name").notNull(), + storedFilename: text("stored_filename").unique().notNull(), + mimeType: text("mime_type").notNull().default("image/png"), + fileSize: integer("file_size").notNull(), + uploadedBy: text("uploaded_by").notNull().references(() => users.id), + createdAt: text("created_at").notNull(), +}); + export const marks = sqliteTable("marks", { id: text("id").primaryKey(), campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }), diff --git a/package-lock.json b/package-lock.json index 2313c21..81ff1c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "barotraumabingo", "version": "0.1.0", "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@radix-ui/react-avatar": "^1.1.12", "@radix-ui/react-checkbox": "^1.3.4", "@radix-ui/react-dialog": "^1.1.16", @@ -17,15 +19,19 @@ "@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-toast": "^1.2.16", + "@types/howler": "^2.2.13", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", + "emoji-mart": "^5.6.0", + "howler": "^2.2.4", "lucide-react": "^1.18.0", "next": "^16.2.5", "react": "19.2.4", "react-dom": "19.2.4", + "react-dropzone": "^14.4.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "uuid": "^14.0.0" @@ -294,6 +300,22 @@ "tslib": "^2.4.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", + "license": "MIT" + }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "license": "MIT", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -2383,7 +2405,7 @@ "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2394,6 +2416,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/howler": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.13.tgz", + "integrity": "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -2406,7 +2434,7 @@ }, "node_modules/@types/node": { "version": "20.19.43", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2414,7 +2442,7 @@ }, "node_modules/@types/react": { "version": "19.2.17", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2422,7 +2450,7 @@ }, "node_modules/@types/react-dom": { "version": "19.2.3", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3273,6 +3301,15 @@ "node": ">= 0.4" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "dev": true, @@ -3614,7 +3651,7 @@ }, "node_modules/csstype": { "version": "3.2.3", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3914,6 +3951,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "9.2.2", "dev": true, @@ -4555,6 +4598,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4901,6 +4956,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5388,7 +5449,6 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5789,7 +5849,6 @@ }, "node_modules/loose-envify": { "version": "1.4.0", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -6063,7 +6122,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6356,7 +6414,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6442,9 +6499,25 @@ "react": "^19.2.4" } }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", - "dev": true, "license": "MIT" }, "node_modules/react-remove-scroll": { @@ -7158,6 +7231,7 @@ }, "node_modules/tailwindcss": { "version": "4.3.1", + "dev": true, "license": "MIT" }, "node_modules/tailwindcss-animate": { @@ -7445,7 +7519,7 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 7baabdc..e83a616 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@radix-ui/react-avatar": "^1.1.12", "@radix-ui/react-checkbox": "^1.3.4", "@radix-ui/react-dialog": "^1.1.16", @@ -18,15 +20,19 @@ "@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-toast": "^1.2.16", + "@types/howler": "^2.2.13", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", + "emoji-mart": "^5.6.0", + "howler": "^2.2.4", "lucide-react": "^1.18.0", "next": "^16.2.5", "react": "19.2.4", "react-dom": "19.2.4", + "react-dropzone": "^14.4.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "uuid": "^14.0.0"