v2.0: sound library, image upload, DnD, ResourceBrowser, Barotrauma HUD, captain sanity
Some checks failed
Deploy / build-and-deploy (push) Failing after 52s
Some checks failed
Deploy / build-and-deploy (push) Failing after 52s
This commit is contained in:
@@ -32,8 +32,9 @@ jobs:
|
|||||||
- name: Copy files to server
|
- name: Copy files to server
|
||||||
run: |
|
run: |
|
||||||
ssh root@barabingo 'mkdir -p /opt/barabingo/data /opt/barabingo/uploads'
|
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/
|
scp /tmp/barabingo.tar.gz docker-compose.yml root@barabingo:/opt/barabingo/
|
||||||
|
|
||||||
- name: Deploy on server
|
- name: Deploy on server
|
||||||
run: |
|
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'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ cam
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { campaignId } = await params;
|
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 });
|
if (!text) return NextResponse.json({ error: "Text required" }, { status: 400 });
|
||||||
|
|
||||||
const existing = db.select().from(bingoItems)
|
const existing = db.select().from(bingoItems)
|
||||||
@@ -32,7 +32,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ cam
|
|||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const now = new Date().toISOString();
|
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 });
|
return NextResponse.json({ id });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Failed to add item" }, { status: 500 });
|
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();
|
const body = await req.json();
|
||||||
if (!body.id) return NextResponse.json({ error: "Item ID required" }, { status: 400 });
|
if (!body.id) return NextResponse.json({ error: "Item ID required" }, { status: 400 });
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (body.text) updates.text = body.text;
|
if (body.text !== undefined) updates.text = body.text;
|
||||||
if (body.emoji) updates.emoji = body.emoji;
|
if (body.emoji !== undefined) updates.emoji = body.emoji;
|
||||||
if (body.soundCategory) updates.soundCategory = body.soundCategory;
|
if (body.soundCategory !== undefined) updates.soundCategory = body.soundCategory;
|
||||||
if (body.soundUrl !== undefined) updates.soundUrl = body.soundUrl;
|
if (body.soundUrl !== undefined) updates.soundUrl = body.soundUrl;
|
||||||
|
if (body.imageUrl !== undefined) updates.imageUrl = body.imageUrl;
|
||||||
if (body.gridIndex !== undefined) updates.gridIndex = body.gridIndex;
|
if (body.gridIndex !== undefined) updates.gridIndex = body.gridIndex;
|
||||||
db.update(bingoItems).set(updates)
|
db.update(bingoItems).set(updates)
|
||||||
.where(and(eq(bingoItems.id, body.id), eq(bingoItems.campaignId, campaignId)))
|
.where(and(eq(bingoItems.id, body.id), eq(bingoItems.campaignId, campaignId)))
|
||||||
|
|||||||
93
app/api/images/route.ts
Normal file
93
app/api/images/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/sounds/import/route.ts
Normal file
63
app/api/sounds/import/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "@/lib/auth";
|
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 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() {
|
export async function GET() {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
@@ -11,17 +15,64 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const files = await readdir(SOUNDS_DIR);
|
const all = db.select().from(sounds).orderBy(sounds.createdAt).all();
|
||||||
const sounds = files
|
const list = all.map(s => ({
|
||||||
.filter(f => f.endsWith(".ogg"))
|
id: s.id,
|
||||||
.map(f => ({ filename: f, url: `/uploads/sounds/${f}` }))
|
originalName: s.originalName,
|
||||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
url: `/uploads/sounds/${s.storedFilename}`,
|
||||||
return NextResponse.json({ sounds });
|
duration: s.duration,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ sounds: list });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ sounds: [] });
|
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) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
if (!session || !session.isAdmin) {
|
if (!session || !session.isAdmin) {
|
||||||
@@ -30,8 +81,9 @@ export async function DELETE(req: NextRequest) {
|
|||||||
const filename = req.nextUrl.searchParams.get("filename");
|
const filename = req.nextUrl.searchParams.get("filename");
|
||||||
if (!filename) return NextResponse.json({ error: "No filename" }, { status: 400 });
|
if (!filename) return NextResponse.json({ error: "No filename" }, { status: 400 });
|
||||||
try {
|
try {
|
||||||
const filepath = path.join(SOUNDS_DIR, filename);
|
const filepath = path.join(UPLOAD_DIR, filename);
|
||||||
await unlink(filepath);
|
await unlink(filepath).catch(() => {});
|
||||||
|
db.delete(sounds).where(eq(sounds.storedFilename, filename)).run();
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||||
|
|||||||
29
app/api/sounds/search/route.ts
Normal file
29
app/api/sounds/search/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "@/lib/auth";
|
import { getServerSession } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { sounds } from "@/lib/db/schema";
|
||||||
import { writeFile, mkdir } from "fs/promises";
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
@@ -32,6 +34,19 @@ export async function POST(req: NextRequest) {
|
|||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
await writeFile(filepath, Buffer.from(bytes));
|
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}` });
|
return NextResponse.json({ url: `/uploads/sounds/${filename}` });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Item = {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
soundCategory: string;
|
soundCategory: string;
|
||||||
soundUrl?: string | null;
|
soundUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
gridIndex: number;
|
gridIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,26 +42,49 @@ type Props = {
|
|||||||
function checkBingo(grid: GridCell[], gridSize: number): number[][] {
|
function checkBingo(grid: GridCell[], gridSize: number): number[][] {
|
||||||
const lines: number[][] = [];
|
const lines: number[][] = [];
|
||||||
const size = gridSize;
|
const size = gridSize;
|
||||||
|
|
||||||
for (let row = 0; row < size; row++) {
|
for (let row = 0; row < size; row++) {
|
||||||
const indices = Array.from({ length: size }, (_, c) => row * size + c);
|
const indices = Array.from({ length: size }, (_, c) => row * size + c);
|
||||||
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
|
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let col = 0; col < size; col++) {
|
for (let col = 0; col < size; col++) {
|
||||||
const indices = Array.from({ length: size }, (_, r) => r * size + col);
|
const indices = Array.from({ length: size }, (_, r) => r * size + col);
|
||||||
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
|
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
const diag1 = Array.from({ length: size }, (_, i) => i * size + i);
|
const diag1 = Array.from({ length: size }, (_, i) => i * size + i);
|
||||||
if (diag1.every(i => grid[i]?.marked)) lines.push(diag1);
|
if (diag1.every(i => grid[i]?.marked)) lines.push(diag1);
|
||||||
|
|
||||||
const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1);
|
const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1);
|
||||||
if (diag2.every(i => grid[i]?.marked)) lines.push(diag2);
|
if (diag2.every(i => grid[i]?.marked)) lines.push(diag2);
|
||||||
|
|
||||||
return lines;
|
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 (
|
||||||
|
<div className="flex items-center gap-1.5 text-[9px] font-mono">
|
||||||
|
<span className="w-4 text-center shrink-0">{icon}</span>
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">{label}</span>
|
||||||
|
<span className={cn(low && "text-red-400", high && "text-orange-400", !low && !high && "text-slate-400")}>
|
||||||
|
{unit ? `${Math.round(value)}${unit}` : Math.round(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className={cn(
|
||||||
|
"h-full rounded-full transition-all duration-700",
|
||||||
|
low && "bg-red-500", high && "bg-orange-500",
|
||||||
|
!low && !high && "bg-cyan-500",
|
||||||
|
stage === "panic" && "animate-pulse",
|
||||||
|
stage === "lost" && "animate-pulse",
|
||||||
|
)} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) {
|
export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) {
|
||||||
const [grid, setGrid] = useState<GridCell[]>(initialGrid);
|
const [grid, setGrid] = useState<GridCell[]>(initialGrid);
|
||||||
const [bingoLines, setBingoLines] = useState<number[][]>([]);
|
const [bingoLines, setBingoLines] = useState<number[][]>([]);
|
||||||
@@ -87,9 +111,7 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
setShowBingo(true);
|
setShowBingo(true);
|
||||||
playBingo();
|
playBingo();
|
||||||
}
|
}
|
||||||
if (data.activityLog) {
|
if (data.activityLog) setActivityLog(data.activityLog);
|
||||||
setActivityLog(data.activityLog);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [campaign.id, campaign.gridSize]);
|
}, [campaign.id, campaign.gridSize]);
|
||||||
@@ -111,9 +133,7 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ campaignId: campaign.id, itemId }),
|
body: JSON.stringify({ campaignId: campaign.id, itemId }),
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status === 409) {
|
if (!res.ok && res.status === 409) {}
|
||||||
// already marked by someone else, just refresh
|
|
||||||
}
|
|
||||||
await fetchState();
|
await fetchState();
|
||||||
} catch {}
|
} catch {}
|
||||||
setMarking(null);
|
setMarking(null);
|
||||||
@@ -136,60 +156,60 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
const markedCount = grid.filter(c => c.marked).length;
|
const markedCount = grid.filter(c => c.marked).length;
|
||||||
const totalItems = grid.filter(c => c.item).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 hullHealth = Math.max(0, 100 - chaosLevel * 100);
|
||||||
const reactorTemp = 80 + chaosLevel * 120;
|
const floodLevel = Math.min(100, chaosLevel * 100);
|
||||||
const o2Level = Math.max(0, 100 - chaosLevel * 130);
|
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 =
|
const stageLabel =
|
||||||
chaosLevel < 0.25 ? "✅ Sub stable" :
|
chaosLevel < 0.25 ? "Sub stable" :
|
||||||
chaosLevel < 0.5 ? "⚠️ Leaking" :
|
chaosLevel < 0.5 ? "⚠️ Leaking" :
|
||||||
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ SUB DESTROYED";
|
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ 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";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 w-full max-w-lg mx-auto">
|
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center w-full">
|
<div className="text-center w-full">
|
||||||
<h2 className="text-xl 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}</h2>
|
||||||
{campaign.name}
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
</h2>
|
{markedCount}/{totalItems} cells — {stageLabel}
|
||||||
<p className="text-xs text-slate-500 font-mono">
|
|
||||||
{markedCount}/{totalItems} cells marked
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chaos + Subs compact row */}
|
{/* HUD Panel */}
|
||||||
<div className="w-full flex gap-2 items-stretch">
|
<div className={cn(
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
"w-full border rounded-lg p-2 space-y-1.5 bg-slate-900/50 transition-all",
|
||||||
<div className="flex justify-between text-[9px] font-mono text-slate-500">
|
sanityStage === "panic" && "border-orange-700/40 animate-shake",
|
||||||
<span>{stageLabel}</span>
|
sanityStage === "lost" && "border-purple-700/50 animate-flicker",
|
||||||
<span>{Math.round(chaosLevel * 100)}% chaos</span>
|
sanityStage !== "panic" && sanityStage !== "lost" && "border-slate-700/30",
|
||||||
</div>
|
)}>
|
||||||
<div className="w-full bg-slate-900/60 rounded-full h-2 overflow-hidden border border-slate-700/30">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||||
<div
|
<StatusGauge icon="⚛️" label="Reactor" value={reactorTemp} unit="°C" low={reactorTemp > 9000} high={reactorTemp > 5000 && reactorTemp <= 9000} />
|
||||||
className={cn(
|
<StatusGauge icon="🛡️" label="Hull" value={hullHealth} unit="%" low={hullHealth < 30} />
|
||||||
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
|
<StatusGauge icon="🌊" label="Flood" value={floodLevel} unit="%" high={floodLevel > 50} />
|
||||||
chaosStageClass,
|
<StatusGauge icon={sanityIcon} label="Кэп" value={captainSanity} unit="%" low={captainSanity < 30} stage={sanityStage === "panic" || sanityStage === "lost" ? sanityStage : undefined} />
|
||||||
chaosLevel >= 0.6 && "animate-pulse"
|
|
||||||
)}
|
|
||||||
style={{ width: `${chaosLevel * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex gap-2 text-[9px] font-mono">
|
|
||||||
<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")}>
|
|
||||||
Hull {Math.round(hullHealth)}%
|
|
||||||
</div>
|
|
||||||
<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")}>
|
|
||||||
R{Math.round(reactorTemp)}°
|
|
||||||
</div>
|
|
||||||
<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 className="text-[8px] font-mono text-slate-600 text-center pt-1 border-t border-slate-700/20">
|
||||||
|
{sanityStage === "calm" && "Капитан спокоен. Пока."}
|
||||||
|
{sanityStage === "nervous" && "Капитан дёргается. Это нормально."}
|
||||||
|
{sanityStage === "panic" && "КЭП НА НЕРВАХ! НЕ ТРОГАЙТЕ ЕГО!"}
|
||||||
|
{sanityStage === "lost" && "КУКУХА УЕХАЛА! Капитан ушёл в отрыв!"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -237,17 +257,15 @@ export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bingo Celebration Modal */}
|
{/* Bingo 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)}>
|
||||||
<div className="bg-slate-900 border-2 border-amber-500/50 rounded-2xl p-8 text-center shadow-2xl shadow-amber-900/30 animate-[bounce_1s_ease-in-out_infinite] max-w-md mx-4" onClick={e => e.stopPropagation()}>
|
<div className="bg-slate-900 border-2 border-amber-500/50 rounded-2xl p-8 text-center shadow-2xl shadow-amber-900/30 animate-[bounce_1s_ease-in-out_infinite] max-w-md mx-4" onClick={e => e.stopPropagation()}>
|
||||||
<div className="text-6xl mb-4">🏆🔥💀</div>
|
<div className="text-6xl mb-4">🏆🔥💀</div>
|
||||||
<h2 className="text-3xl font-bold text-amber-400 font-mono mb-2">БИНГО!</h2>
|
<h2 className="text-3xl font-bold text-amber-400 font-mono mb-2">БИНГО!</h2>
|
||||||
<p className="text-slate-300 mb-4">Хаос победил! Суб взорван, экипаж мёртв, всем весело!</p>
|
<p className="text-slate-300 mb-4">Капитан офонарел! Суб разгерметизирован! Все мертвы!</p>
|
||||||
<div className="text-4xl mb-4 animate-pulse">🎉💥🌊🤡</div>
|
<div className="text-4xl mb-4 animate-pulse">🎉💥🌊🤪</div>
|
||||||
<Button variant="outline" onClick={() => setShowBingo(false)}>
|
<Button variant="outline" onClick={() => setShowBingo(false)}>Продолжить хаос</Button>
|
||||||
Продолжить хаос
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Item = {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
soundCategory: string;
|
soundCategory: string;
|
||||||
soundUrl?: string | null;
|
soundUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
gridIndex: number;
|
gridIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,8 +77,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",
|
||||||
"aspect-square",
|
"min-h-[90px]",
|
||||||
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-0.5",
|
gridSize > 5 ? "p-1.5 gap-0.5" : "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",
|
||||||
@@ -88,34 +89,33 @@ 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("leading-none", gridSize > 5 ? "text-2xl" : "text-3xl")}>
|
{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-20 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
<span className={cn("leading-none relative z-10", gridSize > 5 ? "text-2xl" : "text-2xl")}>
|
||||||
{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 relative z-10",
|
||||||
gridSize > 5 ? "text-xs" : "text-sm",
|
"text-xs",
|
||||||
"line-clamp-2"
|
"line-clamp-2 px-0.5"
|
||||||
)}>
|
)}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
{marked && markCount > 0 && (
|
{marked && markCount > 0 && (
|
||||||
<span className={cn(
|
<span className={cn("absolute top-0.5 right-1 font-bold text-amber-400 z-10", gridSize > 5 ? "text-[9px]" : "text-xs")}>
|
||||||
"absolute top-0.5 right-1 font-bold text-amber-400",
|
|
||||||
gridSize > 5 ? "text-[9px]" : "text-xs"
|
|
||||||
)}>
|
|
||||||
{markCount}
|
{markCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{marked && (
|
{marked && (
|
||||||
<span className="absolute top-0.5 left-1 text-[9px] text-cyan-500/60">✕</span>
|
<span className="absolute top-0.5 left-1 text-[9px] text-cyan-500/60 z-10">✕</span>
|
||||||
)}
|
)}
|
||||||
{!marked && !isFree && (
|
{!marked && !isFree && (
|
||||||
<span className="text-[10px] text-slate-600">{getSoundEmoji(item.soundCategory)}</span>
|
<span className="text-[10px] text-slate-600 relative z-10">{getSoundEmoji(item.soundCategory)}</span>
|
||||||
)}
|
)}
|
||||||
{isFree && !marked && (
|
{isFree && !marked && (
|
||||||
<span className="text-[10px] text-amber-500/60">
|
<span className="text-[10px] text-amber-500/60 relative z-10">🎯 mark me</span>
|
||||||
🎯 mark me
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Card, CardContent } from "./ui/card";
|
import { Card, CardContent } from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
|
import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
import { ResourceBrowser, DragData } from "./ResourceBrowser";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +15,7 @@ type Item = {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
soundCategory: string;
|
soundCategory: string;
|
||||||
soundUrl?: string | null;
|
soundUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
gridIndex: number;
|
gridIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,11 +25,6 @@ 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);
|
||||||
@@ -77,9 +74,12 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
const [editEmoji, setEditEmoji] = useState("💀");
|
const [editEmoji, setEditEmoji] = useState("💀");
|
||||||
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 [editImageUrl, setEditImageUrl] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [librarySounds, setLibrarySounds] = useState<LibrarySound[]>([]);
|
const [librarySounds, setLibrarySounds] = useState<{ originalName: string; url: string }[]>([]);
|
||||||
const [showLibrary, setShowLibrary] = useState(false);
|
const [showLibrary, setShowLibrary] = useState(false);
|
||||||
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const formRef = useRef<HTMLDivElement>(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -93,15 +93,15 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
useEffect(() => { fetchItems(); }, [campaign.id]);
|
useEffect(() => { fetchItems(); }, [campaign.id]);
|
||||||
|
|
||||||
const fetchLibrary = async () => {
|
// Listen for emoji-select from ResourceBrowser EmojisTab
|
||||||
const res = await fetch("/api/sounds");
|
|
||||||
const data = await res.json();
|
|
||||||
setLibrarySounds(data.sounds || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editSound === "custom") fetchLibrary();
|
const handler = (e: Event) => {
|
||||||
}, [editSound]);
|
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) => {
|
const updateItem = async (item: Item) => {
|
||||||
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
await fetch(`/api/campaigns/${campaign.id}/items`, {
|
||||||
@@ -113,6 +113,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
emoji: item.emoji,
|
emoji: item.emoji,
|
||||||
soundCategory: item.soundCategory,
|
soundCategory: item.soundCategory,
|
||||||
soundUrl: item.soundUrl,
|
soundUrl: item.soundUrl,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
gridIndex: item.gridIndex,
|
gridIndex: item.gridIndex,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -141,6 +142,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
emoji: editEmoji,
|
emoji: editEmoji,
|
||||||
soundCategory: editSound,
|
soundCategory: editSound,
|
||||||
soundUrl: editSoundUrl,
|
soundUrl: editSoundUrl,
|
||||||
|
imageUrl: editImageUrl,
|
||||||
gridIndex: maxIdx + 1,
|
gridIndex: maxIdx + 1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -159,6 +161,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
emoji: editEmoji,
|
emoji: editEmoji,
|
||||||
soundCategory: editSound,
|
soundCategory: editSound,
|
||||||
soundUrl: editSoundUrl,
|
soundUrl: editSoundUrl,
|
||||||
|
imageUrl: editImageUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -173,6 +176,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
setEditEmoji(item.emoji);
|
setEditEmoji(item.emoji);
|
||||||
setEditSound(item.soundCategory);
|
setEditSound(item.soundCategory);
|
||||||
setEditSoundUrl(item.soundUrl || null);
|
setEditSoundUrl(item.soundUrl || null);
|
||||||
|
setEditImageUrl(item.imageUrl || null);
|
||||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,6 +186,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
setEditEmoji("💀");
|
setEditEmoji("💀");
|
||||||
setEditSound("horn");
|
setEditSound("horn");
|
||||||
setEditSoundUrl(null);
|
setEditSoundUrl(null);
|
||||||
|
setEditImageUrl(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,21 +198,42 @@ 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) => {
|
||||||
try { const a = new Audio(url); a.preload = "auto"; a.volume = 0.4; a.play().catch(() => {}); } catch {}
|
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;
|
const totalCells = campaign.gridSize * campaign.gridSize;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -221,11 +247,19 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-mono text-cyan-400 uppercase">{editItemId ? "✏ Edit Cell" : "+ Add New Cell"}</h3>
|
<h3 className="text-xs font-mono text-cyan-400 uppercase">{editItemId ? "✏ Edit Cell" : "+ Add New Cell"}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{editItemId && (
|
{editItemId && (
|
||||||
<button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
|
<button onClick={resetForm} className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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>
|
||||||
<div className="flex flex-wrap gap-2 items-end">
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
<div className="flex-1 min-w-[150px]">
|
<div className="flex-1 min-w-[150px]">
|
||||||
@@ -286,85 +320,25 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
{editSoundUrl && (
|
{editSoundUrl && (
|
||||||
<div className="flex items-center gap-1 text-[10px] font-mono text-cyan-400">
|
<div className="flex items-center gap-1 text-[10px] font-mono text-cyan-400">
|
||||||
<span>✓ sound loaded</span>
|
<span>✓ sound loaded</span>
|
||||||
<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>
|
||||||
type="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>
|
||||||
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showLibrary && librarySounds.length === 0 && (
|
{editImageUrl && (
|
||||||
<p className="text-[9px] font-mono text-slate-600 italic">No uploaded sounds yet. Upload an OGG file above.</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
<div
|
<div
|
||||||
className="grid gap-1"
|
className="grid gap-1"
|
||||||
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
|
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
|
||||||
@@ -373,55 +347,59 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
const item = items.find(it => it.gridIndex === idx);
|
const item = items.find(it => it.gridIndex === idx);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return (
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isEditing = editItemId === item.id;
|
const isEditing = editItemId === item.id;
|
||||||
return (
|
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">
|
<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-lg leading-none">{item.emoji}</span>
|
||||||
<span className="text-[10px] text-center font-mono text-slate-300 leading-tight line-clamp-3 px-0.5">
|
{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}
|
{item.text}
|
||||||
</span>
|
</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 && (
|
{item.soundUrl && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => { e.stopPropagation(); playSoundOnce(item.soundUrl!); }}
|
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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Browser dock */}
|
||||||
|
<ResourceBrowser visible={showBrowser} onClose={() => setShowBrowser(false)} />
|
||||||
|
|
||||||
|
{/* All Items list */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>
|
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
@@ -430,9 +408,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
<input
|
<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"
|
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}
|
value={item.text}
|
||||||
onChange={e => {
|
onChange={e => setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it))}
|
||||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it));
|
|
||||||
}}
|
|
||||||
onBlur={() => updateItem(item)}
|
onBlur={() => updateItem(item)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -448,9 +424,7 @@ export function ItemEditor({ campaign }: { campaign: Campaign }) {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SOUND_CATEGORIES.map(cat => (
|
{SOUND_CATEGORIES.map(cat => (
|
||||||
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">
|
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">{cat.label}</SelectItem>
|
||||||
{cat.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
443
components/ResourceBrowser.tsx
Normal file
443
components/ResourceBrowser.tsx
Normal file
@@ -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<string, Howl>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search emoji..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-slate-800/60 border border-slate-700/30 rounded px-2 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none focus:border-cyan-500/50"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-10 gap-1 max-h-48 overflow-y-auto">
|
||||||
|
{filtered.map(e => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
draggable
|
||||||
|
onDragStart={ev => {
|
||||||
|
ev.dataTransfer.setData("application/json", JSON.stringify({ type: "emoji", emoji: e } satisfies DragData));
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(e)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-base rounded hover:bg-slate-700/60 cursor-pointer active:scale-90 transition-transform"
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sounds Tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SoundsTab() {
|
||||||
|
const [sounds, setSounds] = useState<LibrarySound[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<FreesoundResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setInterval>>(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<void>((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 (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{uploading && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[9px] font-mono text-slate-500">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-cyan-500 transition-all duration-200" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div {...getRootProps()} className="border-2 border-dashed border-slate-700/40 rounded-lg p-3 text-center cursor-pointer hover:border-cyan-600/40 transition-colors">
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<p className="text-[10px] font-mono text-slate-500">Drop OGG files here or click to upload</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Library */}
|
||||||
|
{sounds.length > 0 && (
|
||||||
|
<div className="max-h-36 overflow-y-auto space-y-1">
|
||||||
|
{sounds.map(s => {
|
||||||
|
const filename = s.url.split("/").pop() || "";
|
||||||
|
return (
|
||||||
|
<SoundRow
|
||||||
|
key={s.id}
|
||||||
|
label={s.originalName}
|
||||||
|
duration={s.duration}
|
||||||
|
url={s.url}
|
||||||
|
onPlay={() => playPreview(s.url)}
|
||||||
|
onDelete={() => deleteSound(filename)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Freesound toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSearch(!showSearch)}
|
||||||
|
className="text-[10px] font-mono text-slate-500 hover:text-slate-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showSearch ? "▾ Hide Online Search" : "▸ Search Freesound"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search online sounds..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => 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 && <p className="text-[9px] font-mono text-slate-600 italic">Searching...</p>}
|
||||||
|
<div className="max-h-36 overflow-y-auto space-y-1">
|
||||||
|
{searchResults.map(r => (
|
||||||
|
<FreesoundRow
|
||||||
|
key={r.id}
|
||||||
|
name={r.name}
|
||||||
|
duration={r.duration}
|
||||||
|
onPreview={() => r.previewUrl && playPreview(r.previewUrl)}
|
||||||
|
onImport={() => importSound(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Images Tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ImagesTab() {
|
||||||
|
const [images, setImages] = useState<LibraryImage[]>([]);
|
||||||
|
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<void>((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 (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{uploading && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[9px] font-mono text-slate-500">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-cyan-500 transition-all duration-200" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div {...getRootProps()} className="border-2 border-dashed border-slate-700/40 rounded-lg p-3 text-center cursor-pointer hover:border-cyan-600/40 transition-colors">
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<p className="text-[10px] font-mono text-slate-500">Drop images here or click to upload</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-6 gap-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{images.map(img => {
|
||||||
|
const filename = img.url.split("/").pop() || "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={ev => {
|
||||||
|
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 src={img.url} alt={img.originalName} className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteImage(filename)}
|
||||||
|
className="absolute top-0.5 right-0.5 w-4 h-4 flex items-center justify-center rounded bg-red-900/80 text-[8px] text-red-300 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Row Components ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function SoundRow({ label, duration, url, onPlay, onDelete }: {
|
||||||
|
label: string; duration: number; url: string; onPlay: () => void; onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={ev => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="text-xs">🔊</span>
|
||||||
|
<span className="flex-1 truncate text-slate-300">{label}</span>
|
||||||
|
<span className="text-slate-600 shrink-0">{duration.toFixed(1)}s</span>
|
||||||
|
<button type="button" onClick={e => { e.stopPropagation(); onPlay(); }} className="px-1.5 py-0.5 rounded hover:bg-slate-700/50 text-slate-400 hover:text-cyan-300 cursor-pointer">▶</button>
|
||||||
|
<button type="button" onClick={e => { e.stopPropagation(); onDelete(); }} className="px-1.5 py-0.5 rounded hover:bg-red-800/50 text-red-400 cursor-pointer">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreesoundRow({ name, duration, onPreview, onImport }: {
|
||||||
|
name: string; duration: number; onPreview: () => void; onImport: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded bg-slate-800/20 border border-slate-700/15 text-[10px] font-mono">
|
||||||
|
<span className="text-xs">🌐</span>
|
||||||
|
<span className="flex-1 truncate text-slate-400">{name}</span>
|
||||||
|
<span className="text-slate-600 shrink-0">{duration.toFixed(1)}s</span>
|
||||||
|
<button type="button" onClick={onPreview} className="px-1.5 py-0.5 rounded hover:bg-slate-700/50 text-slate-400 hover:text-cyan-300 cursor-pointer">▶</button>
|
||||||
|
<button type="button" onClick={onImport} className="px-1.5 py-0.5 rounded bg-cyan-800/40 text-cyan-400 hover:bg-cyan-700/50 cursor-pointer">Import</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ResourceBrowser ───────────────────────────────────────────
|
||||||
|
|
||||||
|
type Tab = "sounds" | "images" | "emojis";
|
||||||
|
|
||||||
|
export function ResourceBrowser({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const [tab, setTab] = useState<Tab>("sounds");
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-slate-700/30 bg-slate-950/95 mt-3">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center px-2 border-b border-slate-700/20">
|
||||||
|
{([
|
||||||
|
{ key: "sounds" as const, label: "🔊 Sounds" },
|
||||||
|
{ key: "images" as const, label: "🖼️ Images" },
|
||||||
|
{ key: "emojis" as const, label: "😀 Emojis" },
|
||||||
|
]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-3 py-2 text-[10px] font-mono border-b-2 transition-colors cursor-pointer ${
|
||||||
|
tab === t.key
|
||||||
|
? "border-cyan-500 text-cyan-300"
|
||||||
|
: "border-transparent text-slate-600 hover:text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={onClose} className="px-2 py-2 text-[10px] font-mono text-slate-600 hover:text-slate-300 cursor-pointer">
|
||||||
|
✕ Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{tab === "sounds" && <SoundsTab />}
|
||||||
|
{tab === "images" && <ImagesTab />}
|
||||||
|
{tab === "emojis" && <EmojisTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="p-3"><EmojiPickerGrid onSelect={handleSelect} /></div>;
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ services:
|
|||||||
- ./uploads:/app/public/uploads
|
- ./uploads:/app/public/uploads
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
|
||||||
|
|||||||
@@ -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_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 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 sound_url TEXT"); } catch {}
|
||||||
|
try { sqlite.exec("ALTER TABLE bingo_items ADD COLUMN image_url TEXT"); } catch {}
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|||||||
@@ -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", {
|
export const users = sqliteTable("users", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -31,12 +31,32 @@ export const bingoItems = sqliteTable("bingo_items", {
|
|||||||
emoji: text("emoji").notNull().default("💀"),
|
emoji: text("emoji").notNull().default("💀"),
|
||||||
soundCategory: text("sound_category").notNull().default("horn"),
|
soundCategory: text("sound_category").notNull().default("horn"),
|
||||||
soundUrl: text("sound_url"),
|
soundUrl: text("sound_url"),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
gridIndex: integer("grid_index").notNull(),
|
gridIndex: integer("grid_index").notNull(),
|
||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
uniqueIndex("idx_campaign_item_pos").on(table.campaignId, table.gridIndex),
|
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", {
|
export const marks = sqliteTable("marks", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
|
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
96
package-lock.json
generated
96
package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "barotraumabingo",
|
"name": "barotraumabingo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-avatar": "^1.1.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
@@ -17,15 +19,19 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-toast": "^1.2.16",
|
"@radix-ui/react-toast": "^1.2.16",
|
||||||
|
"@types/howler": "^2.2.13",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.1",
|
"better-sqlite3": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next": "^16.2.5",
|
"next": "^16.2.5",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-dropzone": "^14.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
@@ -294,6 +300,22 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2383,7 +2405,7 @@
|
|||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -2394,6 +2416,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2406,7 +2434,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2414,7 +2442,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2422,7 +2450,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -3273,6 +3301,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3614,7 +3651,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -3914,6 +3951,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4555,6 +4598,18 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -5388,7 +5449,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -5789,7 +5849,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -6063,7 +6122,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6356,7 +6414,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -6442,9 +6499,25 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
@@ -7158,6 +7231,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss-animate": {
|
"node_modules/tailwindcss-animate": {
|
||||||
@@ -7445,7 +7519,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-avatar": "^1.1.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
@@ -18,15 +20,19 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-toast": "^1.2.16",
|
"@radix-ui/react-toast": "^1.2.16",
|
||||||
|
"@types/howler": "^2.2.13",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.1",
|
"better-sqlite3": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next": "^16.2.5",
|
"next": "^16.2.5",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-dropzone": "^14.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user