V1 bingo
Some checks failed
Deploy / build-and-deploy (push) Failing after 2m53s

This commit is contained in:
2026-06-14 21:29:43 +03:00
commit 05677924b5
55 changed files with 10816 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
data/*.db
data/*.db-wal
data/*.db-shm
.git
.gitignore
*.md
.env
.env.local
tsconfig.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,38 @@
name: Deploy
run-name: Deploy to barabingo
on:
push:
branches: [main, master]
jobs:
build-and-deploy:
runs-on: [ubuntu-22.04]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PKEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H barabingo >> ~/.ssh/known_hosts 2>/dev/null
- name: Build Docker image
run: |
docker build -t barabingo:latest .
- name: Save and compress image
run: |
docker save barabingo:latest | gzip > /tmp/barabingo.tar.gz
- name: Copy image to server
run: |
scp /tmp/barabingo.tar.gz root@barabingo:/tmp/barabingo.tar.gz
- name: Deploy on server
run: |
ssh root@barabingo 'cd /opt/barabingo && docker load < /tmp/barabingo.tar.gz && docker compose down --remove-orphans 2>/dev/null; docker compose up -d && rm -f /tmp/barabingo.tar.gz && docker image prune -f'

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# sqlite
/data/*.db
/data/*.db-wal
/data/*.db-shm
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,72 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { ItemEditor } from "@/components/ItemEditor";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
type Campaign = {
id: string;
name: string;
gridSize: number;
};
export default function EditCampaignPage() {
const { user, loading: authLoading } = useAuth();
const params = useParams();
const router = useRouter();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading) return;
if (!user || !user.isAdmin) { router.push("/"); return; }
const campaignId = params.campaignId as string;
fetch("/api/campaigns")
.then(r => r.json())
.then(campaigns => {
const c = campaigns.find((c: Campaign) => c.id === campaignId);
if (c) setCampaign(c);
})
.finally(() => setLoading(false));
}, [user, authLoading, params.campaignId, router]);
if (authLoading || loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center font-mono text-sm text-slate-600 animate-pulse">Loading...</div>
</div>
);
}
if (!campaign) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="text-4xl mb-3">🗺💀</div>
<p className="text-sm font-mono text-slate-500">Campaign not found</p>
<Button variant="outline" className="mt-4 font-mono text-xs" onClick={() => router.push("/admin")}>
Back
</Button>
</div>
</div>
);
}
return (
<div className="py-4 max-w-2xl mx-auto space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push("/admin")} className="font-mono text-xs">
BACK
</Button>
<div>
<h1 className="text-xl font-mono text-cyan-300 uppercase tracking-wider">{campaign.name}</h1>
<p className="text-xs text-slate-500 font-mono">{campaign.gridSize}×{campaign.gridSize} grid</p>
</div>
</div>
<ItemEditor campaign={campaign} />
</div>
);
}

34
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,34 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { AdminDashboard } from "@/components/AdminDashboard";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AdminPage() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) { router.push("/"); return; }
if (!user.isAdmin) { router.push("/"); return; }
}, [user, loading, router]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center font-mono text-sm text-slate-600 animate-pulse">
<div className="text-3xl mb-2">🔐</div>
Verifying command clearance...
</div>
</div>
);
}
if (!user || !user.isAdmin) {
return null;
}
return <AdminDashboard />;
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { loginUser, deleteSession, getServerSession, SESSION_COOKIE } from "@/lib/auth";
import { cookies } from "next/headers";
export async function POST(req: NextRequest) {
try {
const { nickname, password } = await req.json();
if (!nickname || !password) {
return NextResponse.json({ error: "Nickname and password required" }, { status: 400 });
}
const result = await loginUser(nickname, password);
if ("error" in result) {
return NextResponse.json(result, { status: 401 });
}
const res = NextResponse.json(result.user);
res.cookies.set(SESSION_COOKIE, result.sessionId, {
httpOnly: true,
secure: false,
sameSite: "lax",
path: "/",
maxAge: 7 * 86400,
});
return res;
} catch {
return NextResponse.json({ error: "Login failed" }, { status: 500 });
}
}
export async function DELETE() {
const session = await getServerSession();
if (session) {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) await deleteSession(token);
}
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return res;
}

10
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
export async function GET() {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user: { id: session.id, nickname: session.nickname, isAdmin: session.isAdmin } });
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { registerUser } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { nickname, password } = await req.json();
if (!nickname || !password) {
return NextResponse.json({ error: "Nickname and password required" }, { status: 400 });
}
if (nickname.length < 2 || nickname.length > 20) {
return NextResponse.json({ error: "Nickname 2-20 characters" }, { status: 400 });
}
if (password.length < 4) {
return NextResponse.json({ error: "Password min 4 characters" }, { status: 400 });
}
const result = await registerUser(nickname, password);
if ("error" in result) {
return NextResponse.json(result, { status: 409 });
}
return NextResponse.json(result);
} catch {
return NextResponse.json({ error: "Registration failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { bingoItems } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
export async function GET(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { campaignId } = await params;
const items = db.select().from(bingoItems).where(eq(bingoItems.campaignId, campaignId)).orderBy(bingoItems.gridIndex).all();
return NextResponse.json(items);
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { campaignId } = await params;
const { text, emoji, soundCategory, soundUrl, gridIndex } = await req.json();
if (!text) return NextResponse.json({ error: "Text required" }, { status: 400 });
const existing = db.select().from(bingoItems)
.where(and(eq(bingoItems.campaignId, campaignId), eq(bingoItems.gridIndex, gridIndex)))
.get();
if (existing) {
return NextResponse.json({ error: "Grid position taken" }, { status: 409 });
}
const id = uuidv4();
const now = new Date().toISOString();
db.insert(bingoItems).values({ id, campaignId, text, emoji: emoji || "💀", soundCategory: soundCategory || "horn", soundUrl: soundUrl || null, gridIndex, createdAt: now }).run();
return NextResponse.json({ id });
} catch {
return NextResponse.json({ error: "Failed to add item" }, { status: 500 });
}
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { campaignId } = await params;
const body = await req.json();
if (!body.id) return NextResponse.json({ error: "Item ID required" }, { status: 400 });
const updates: Record<string, unknown> = {};
if (body.text) updates.text = body.text;
if (body.emoji) updates.emoji = body.emoji;
if (body.soundCategory) updates.soundCategory = body.soundCategory;
if (body.soundUrl !== undefined) updates.soundUrl = body.soundUrl;
if (body.gridIndex !== undefined) updates.gridIndex = body.gridIndex;
db.update(bingoItems).set(updates)
.where(and(eq(bingoItems.id, body.id), eq(bingoItems.campaignId, campaignId)))
.run();
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { campaignId } = await params;
const url = new URL(req.url);
const itemId = url.searchParams.get("itemId");
if (!itemId) return NextResponse.json({ error: "itemId required" }, { status: 400 });
db.delete(bingoItems)
.where(and(eq(bingoItems.id, itemId), eq(bingoItems.campaignId, campaignId)))
.run();
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { campaigns } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { campaignId } = await params;
db.delete(campaigns).where(eq(campaigns.id, campaignId)).run();
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { campaignId } = await params;
const body = await req.json();
const updates: Record<string, unknown> = {};
if (body.name) updates.name = body.name;
if (body.status) updates.status = body.status;
if (body.gridSize) updates.gridSize = body.gridSize;
db.update(campaigns).set(updates).where(eq(campaigns.id, campaignId)).run();
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { campaigns } from "@/lib/db/schema";
import { desc } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
export async function GET() {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const all = db.select().from(campaigns).orderBy(desc(campaigns.createdAt)).all();
return NextResponse.json(all);
}
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session || !session.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { name, gridSize = 5 } = await req.json();
if (!name) return NextResponse.json({ error: "Name required" }, { status: 400 });
if (gridSize < 3 || gridSize > 10) {
return NextResponse.json({ error: "Grid size 3-10" }, { status: 400 });
}
const id = uuidv4();
const now = new Date().toISOString();
db.insert(campaigns).values({ id, name, gridSize, createdBy: session.id, createdAt: now }).run();
return NextResponse.json({ id, name, gridSize });
} catch {
return NextResponse.json({ error: "Failed to create campaign" }, { status: 500 });
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { campaigns, bingoItems, marks, users } from "@/lib/db/schema";
import { eq, inArray, desc } from "drizzle-orm";
export async function GET(req: NextRequest, { params }: { params: Promise<{ campaignId: string }> }) {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const { campaignId } = await params;
const campaign = db.select().from(campaigns).where(eq(campaigns.id, campaignId)).get();
if (!campaign) return NextResponse.json({ error: "Campaign not found" }, { status: 404 });
const items = db.select().from(bingoItems).where(eq(bingoItems.campaignId, campaignId)).orderBy(bingoItems.gridIndex).all();
const allMarks = db.select().from(marks).where(eq(marks.campaignId, campaignId)).orderBy(desc(marks.createdAt)).all();
const userIds = [...new Set(allMarks.map(m => m.userId))];
const userMap: Record<string, string> = {};
if (userIds.length > 0) {
const markedUsers = db.select({ id: users.id, nickname: users.nickname })
.from(users).where(inArray(users.id, userIds)).all();
markedUsers.forEach(u => { userMap[u.id] = u.nickname; });
}
const itemMap: Record<string, typeof items[0]> = {};
items.forEach(it => { itemMap[it.id] = it; });
const markedItemIds = allMarks.map(m => m.itemId);
const markCountMap: Record<string, number> = {};
const markUsersMap: Record<string, string[]> = {};
for (const m of allMarks) {
markCountMap[m.itemId] = (markCountMap[m.itemId] || 0) + 1;
if (!markUsersMap[m.itemId]) markUsersMap[m.itemId] = [];
if (!markUsersMap[m.itemId].includes(userMap[m.userId] || "???")) {
markUsersMap[m.itemId].push(userMap[m.userId] || "???");
}
}
const activityLog = allMarks.slice(0, 20).map(m => {
const nickname = userMap[m.userId] || "???";
const item = itemMap[m.itemId];
const emoji = item?.emoji || "💀";
const text = item?.text || "unknown";
const time = new Date(m.createdAt).toLocaleTimeString();
return `${time} ${nickname} marked ${emoji} "${text}"`;
});
const totalCells = campaign.gridSize * campaign.gridSize;
const grid: Array<{
index: number;
item: typeof items[0] | null;
marked: boolean;
markedBy: string[];
markCount: number;
}> = [];
for (let i = 0; i < totalCells; i++) {
const item = items.find(it => it.gridIndex === i) || null;
grid.push({
index: i,
item,
marked: markedItemIds.includes(item?.id || ""),
markedBy: markUsersMap[item?.id || ""] || [],
markCount: markCountMap[item?.id || ""] || 0,
});
}
return NextResponse.json({ campaign, grid, activityLog });
} catch {
return NextResponse.json({ error: "Failed to load game state" }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { marks, bingoItems } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const { campaignId, itemId } = await req.json();
if (!campaignId || !itemId) {
return NextResponse.json({ error: "campaignId and itemId required" }, { status: 400 });
}
const item = db.select().from(bingoItems)
.where(and(eq(bingoItems.id, itemId), eq(bingoItems.campaignId, campaignId)))
.get();
if (!item) return NextResponse.json({ error: "Item not found" }, { status: 404 });
const existing = db.select().from(marks)
.where(and(eq(marks.campaignId, campaignId), eq(marks.itemId, itemId)))
.get();
if (existing) {
return NextResponse.json({ error: "Already marked", mark: existing }, { status: 409 });
}
const id = uuidv4();
const now = new Date().toISOString();
db.insert(marks).values({ id, campaignId, itemId, userId: session.id, createdAt: now }).run();
return NextResponse.json({ id, markedBy: session.nickname });
} catch {
return NextResponse.json({ error: "Failed to mark" }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
const session = await getServerSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const { campaignId, itemId } = await req.json();
if (!campaignId || !itemId) {
return NextResponse.json({ error: "campaignId and itemId required" }, { status: 400 });
}
db.delete(marks)
.where(and(eq(marks.campaignId, campaignId), eq(marks.itemId, itemId)))
.run();
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Failed to unmark" }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
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 formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
if (!file.name.toLowerCase().endsWith(".ogg")) {
return NextResponse.json({ error: "Only OGG files allowed" }, { status: 400 });
}
if (file.size > 2 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 2MB)" }, { status: 400 });
}
await mkdir(UPLOAD_DIR, { recursive: true });
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));
return NextResponse.json({ url: `/uploads/sounds/${filename}` });
} catch {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/components/AuthProvider";
import { BingoCard } from "@/components/BingoCard";
import { useParams, useRouter } from "next/navigation";
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
};
type GridCell = {
index: number;
item: Item | null;
marked: boolean;
markedBy: string[];
markCount: number;
};
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
gridIndex: number;
};
export default function GamePage() {
const { user, loading: authLoading } = useAuth();
const params = useParams();
const router = useRouter();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [grid, setGrid] = useState<GridCell[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (authLoading) return;
if (!user) { router.push("/"); return; }
const campaignId = params.campaignId as string;
fetch(`/api/game/${campaignId}/state`)
.then(r => r.json())
.then(data => {
if (data.error) { setError(data.error); return; }
setCampaign(data.campaign);
setGrid(data.grid);
})
.catch(() => setError("Failed to load game state"))
.finally(() => setLoading(false));
}, [user, authLoading, params.campaignId, router]);
if (authLoading || loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center font-mono text-sm text-slate-600 animate-pulse">
<div className="text-3xl mb-2">🔄</div>
Loading submarine systems...
</div>
</div>
);
}
if (error || !campaign) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="text-4xl mb-3">💀</div>
<h2 className="text-xl font-mono text-red-400 mb-2">Campaign Lost</h2>
<p className="text-sm font-mono text-slate-500 mb-4">{error || "Campaign not found"}</p>
</div>
</div>
);
}
return (
<div className="py-4">
<BingoCard
campaign={campaign}
initialGrid={grid}
currentUserNickname={user!.nickname}
isAdmin={user!.isAdmin}
/>
</div>
);
}

89
app/globals.css Normal file
View File

@@ -0,0 +1,89 @@
@import "tailwindcss";
@theme inline {
--color-background: #0a0e1a;
--color-foreground: #e0e0e0;
--font-sans: var(--font-geist-sans), ui-monospace, monospace;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--animate-shake: shake 0.3s ease-in-out;
--animate-glow-pulse: glow-pulse 2s ease-in-out;
--animate-scan: scan 4s linear infinite;
--animate-flicker: flicker 0.15s ease-in-out 3;
--animate-float: float 3s ease-in-out infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px) rotate(-1deg); }
40% { transform: translateX(3px) rotate(1deg); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 5px rgba(0, 229, 255, 0.2); }
50% { box-shadow: 0 0 20px rgba(0, 229, 255, 0.4), 0 0 40px rgba(0, 229, 255, 0.1); }
}
@keyframes scan {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0a0e1a;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
/* Selection */
::selection {
background: rgba(0, 229, 255, 0.2);
color: #e0e0e0;
}
/* Scanline overlay */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
}

35
app/layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/components/AuthProvider";
import { Navbar } from "@/components/Navbar";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "BaraBingo — Barotrauma Bingo",
description: "Chaotic bingo for Barotrauma crews",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AuthProvider>
<Navbar />
<main className="container mx-auto px-4 py-6 max-w-5xl">
{children}
</main>
{/* Floating bubbles */}
<div className="fixed bottom-0 left-0 w-full pointer-events-none z-0 opacity-[0.03]">
<div className="animate-float mx-auto w-16 h-16 rounded-full bg-cyan-400 blur-xl" style={{ marginLeft: '10%', animationDelay: '0s' }} />
<div className="animate-float mx-auto w-8 h-8 rounded-full bg-cyan-400 blur-lg" style={{ marginLeft: '30%', animationDelay: '1s' }} />
<div className="animate-float mx-auto w-12 h-12 rounded-full bg-cyan-400 blur-xl" style={{ marginLeft: '60%', animationDelay: '2s' }} />
<div className="animate-float mx-auto w-6 h-6 rounded-full bg-cyan-400 blur-md" style={{ marginLeft: '80%', animationDelay: '0.5s' }} />
</div>
</AuthProvider>
</body>
</html>
);
}

61
app/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { LoginForm } from "@/components/LoginForm";
import { CampaignList } from "@/components/CampaignList";
import { useRouter } from "next/navigation";
export default function Home() {
const { user, loading } = useAuth();
const router = useRouter();
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center font-mono text-sm text-slate-600 animate-pulse">
<div className="text-3xl mb-2">🔊</div>
Connecting to submarine network...
</div>
</div>
);
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="text-5xl mb-3">🤡💥</div>
<h1 className="text-3xl font-bold font-mono text-cyan-300 tracking-wider uppercase mb-2">
BaraBingo
</h1>
<p className="text-sm font-mono text-slate-500">
Barotrauma Chaos Bingo mark the madness as it happens
</p>
</div>
<LoginForm />
<div className="mt-6 text-center">
<p className="text-[10px] text-slate-700 font-mono">
First user to register as &ldquo;admin&rdquo; gets command access
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center min-h-[60vh] pt-8 gap-6">
<div className="text-center">
<div className="text-4xl mb-2">🌊🎮</div>
<h1 className="text-2xl font-mono text-cyan-300 uppercase tracking-wider">
Welcome, {user.nickname}
</h1>
<p className="text-xs text-slate-500 font-mono mt-1">
Pick a campaign and dive in
</p>
</div>
<CampaignList onSelect={(id) => router.push(`/game/${id}`)} />
</div>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Badge } from "./ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "./ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Separator } from "./ui/separator";
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
createdAt: string;
};
export function AdminDashboard() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newGridSize, setNewGridSize] = useState("5");
const [creating, setCreating] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchCampaigns = async () => {
const res = await fetch("/api/campaigns");
const data = await res.json();
setCampaigns(data);
setLoading(false);
};
useEffect(() => { fetchCampaigns(); }, []);
const createCampaign = async () => {
if (!newName) return;
setCreating(true);
await fetch("/api/campaigns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName, gridSize: parseInt(newGridSize) }),
});
setNewName("");
setDialogOpen(false);
await fetchCampaigns();
setCreating(false);
};
const deleteCampaign = async (id: string) => {
await fetch(`/api/campaigns/${id}`, { method: "DELETE" });
await fetchCampaigns();
};
const statusColors: Record<string, "success" | "warning" | "secondary"> = {
active: "success",
completed: "warning",
archived: "secondary",
};
return (
<div className="space-y-6 w-full max-w-2xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-mono text-cyan-300 uppercase tracking-wider"> Command Center</h2>
<p className="text-xs text-slate-500 font-mono">Admin terminal v1.0</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="font-mono text-xs">
+ NEW CAMPAIGN
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-mono text-cyan-300">New Campaign</DialogTitle>
<DialogDescription className="font-mono text-xs text-slate-500">
Deploy a new bingo operation
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase">Mission Name</label>
<Input
placeholder='e.g. "Traitor Hunt #42"'
value={newName}
onChange={e => setNewName(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase">Grid Size</label>
<Select value={newGridSize} onValueChange={setNewGridSize}>
<SelectTrigger className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3×3 (Quick)</SelectItem>
<SelectItem value="4">4×4</SelectItem>
<SelectItem value="5">5×5 (Classic)</SelectItem>
<SelectItem value="6">6×6 (Chaotic)</SelectItem>
<SelectItem value="7">7×7 (Meltdown)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} className="font-mono">Cancel</Button>
<Button onClick={createCampaign} disabled={creating || !newName} className="font-mono">
{creating ? "DEPLOYING..." : "▶ DEPLOY"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{loading ? (
<div className="text-center text-slate-500 font-mono text-sm py-8">
Loading submarine manifests...
</div>
) : campaigns.length === 0 ? (
<Card className="border-dashed border-slate-700/30">
<CardContent className="py-12 text-center">
<div className="text-4xl mb-3">🗺💀</div>
<p className="text-slate-500 font-mono text-sm">No campaigns deployed</p>
<p className="text-slate-600 font-mono text-xs mt-1">Click &ldquo;New Campaign&rdquo; to start the chaos</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{campaigns.map(c => (
<Card key={c.id} className="border-slate-700/30 hover:border-cyan-800/30 transition-colors">
<CardContent className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-200">{c.name}</span>
<Badge variant={statusColors[c.status] || "secondary"} className="text-[9px] uppercase">
{c.status}
</Badge>
</div>
<div className="flex gap-3 mt-1 text-[10px] text-slate-600 font-mono">
<span>{c.gridSize}×{c.gridSize}</span>
<span>{new Date(c.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<a href={`/admin/campaigns/${c.id}`}>
<Button variant="ghost" size="sm" className="text-xs font-mono">EDIT</Button>
</a>
<Button variant="destructive" size="sm" className="text-xs font-mono" onClick={() => deleteCampaign(c.id)}>
DEL
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
type User = { id: string; nickname: string; isAdmin: boolean } | null;
type AuthContext = {
user: User;
login: (nickname: string, password: string) => Promise<string | null>;
register: (nickname: string, password: string) => Promise<string | null>;
logout: () => Promise<void>;
loading: boolean;
refetch: () => Promise<void>;
};
const ctx = createContext<AuthContext>({
user: null,
login: async () => null,
register: async () => null,
logout: async () => {},
loading: true,
refetch: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>(null);
const [loading, setLoading] = useState(true);
const refetch = useCallback(async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
setUser(data.user);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refetch(); }, [refetch]);
const login = async (nickname: string, password: string): Promise<string | null> => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, password }),
});
const data = await res.json();
if (data.error) return data.error;
setUser(data);
return null;
};
const register = async (nickname: string, password: string): Promise<string | null> => {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, password }),
});
const data = await res.json();
if (data.error) return data.error;
const loginErr = await login(nickname, password);
return loginErr;
};
const logout = async () => {
await fetch("/api/auth/login", { method: "DELETE" });
setUser(null);
};
return (
<ctx.Provider value={{ user, login, register, logout, loading, refetch }}>
{children}
</ctx.Provider>
);
}
export const useAuth = () => useContext(ctx);

299
components/BingoCard.tsx Normal file
View File

@@ -0,0 +1,299 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { BingoCell } from "./BingoCell";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { playSound, playBingo } from "@/lib/sounds";
import { cn } from "@/lib/utils";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
gridIndex: number;
};
type GridCell = {
index: number;
item: Item | null;
marked: boolean;
markedBy: string[];
markCount: number;
};
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
};
type Props = {
campaign: Campaign;
initialGrid: GridCell[];
currentUserNickname: string;
isAdmin: boolean;
};
function checkBingo(grid: GridCell[], gridSize: number): number[][] {
const lines: number[][] = [];
const size = gridSize;
for (let row = 0; row < size; row++) {
const indices = Array.from({ length: size }, (_, c) => row * size + c);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
for (let col = 0; col < size; col++) {
const indices = Array.from({ length: size }, (_, r) => r * size + col);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
const diag1 = Array.from({ length: size }, (_, i) => i * size + i);
if (diag1.every(i => grid[i]?.marked)) lines.push(diag1);
const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1);
if (diag2.every(i => grid[i]?.marked)) lines.push(diag2);
return lines;
}
export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) {
const [grid, setGrid] = useState<GridCell[]>(initialGrid);
const [bingoLines, setBingoLines] = useState<number[][]>([]);
const [marking, setMarking] = useState<string | null>(null);
const [chaosLevel, setChaosLevel] = useState(0);
const [showBingo, setShowBingo] = useState(false);
const [activityLog, setActivityLog] = useState<string[]>([]);
const bingoNotified = useRef(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch(`/api/game/${campaign.id}/state`);
const data = await res.json();
if (data.grid) {
setGrid(data.grid);
const lines = checkBingo(data.grid, campaign.gridSize);
setBingoLines(lines);
const markedCount = data.grid.filter((c: GridCell) => c.marked).length;
const totalItems = data.grid.filter((c: GridCell) => c.item).length;
setChaosLevel(totalItems > 0 ? Math.min(markedCount / totalItems, 1) : 0);
if (lines.length > 0 && !bingoNotified.current) {
bingoNotified.current = true;
setShowBingo(true);
playBingo();
}
if (data.activityLog) {
setActivityLog(data.activityLog);
}
}
} catch {}
}, [campaign.id, campaign.gridSize]);
useEffect(() => {
fetchState();
pollingRef.current = setInterval(fetchState, 3000);
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [fetchState]);
const handleMark = async (itemId: string) => {
if (marking) return;
setMarking(itemId);
try {
const res = await fetch("/api/game/mark", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }),
});
if (res.ok) {
const item = grid.find(c => c.item?.id === itemId);
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
await fetchState();
} else if (res.status === 409) {
await fetchState();
}
} catch {}
setMarking(null);
};
const markedCount = grid.filter(c => c.marked).length;
const totalItems = grid.filter(c => c.item).length;
const hullHealth = Math.max(0, 100 - chaosLevel * 100);
const reactorTemp = 80 + chaosLevel * 120;
const o2Level = Math.max(0, 100 - chaosLevel * 130);
const stageLabel =
chaosLevel < 0.25 ? "✅ Sub stable" :
chaosLevel < 0.5 ? "⚠️ Leaking" :
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ SUB DESTROYED";
const chaosStageClass =
chaosLevel < 0.3 ? "from-cyan-600 to-cyan-500" :
chaosLevel < 0.6 ? "from-amber-600 to-orange-500" :
"from-red-600 to-red-500";
return (
<div className="flex flex-col items-center gap-4 w-full max-w-5xl mx-auto">
{/* Header */}
<div className="text-center w-full">
<h2 className="text-2xl font-bold text-cyan-300 font-mono tracking-wide uppercase">
{campaign.name}
</h2>
<p className="text-sm text-slate-500 font-mono mt-1">
{markedCount}/{totalItems} cells marked
</p>
</div>
{/* Chaos Meter */}
<div className="w-full space-y-1">
<div className="flex justify-between text-xs font-mono text-slate-500">
<span>{stageLabel}</span>
<span>{Math.round(chaosLevel * 100)}% chaos</span>
</div>
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
chaosStageClass,
chaosLevel >= 0.6 && "animate-pulse"
)}
style={{ width: `${chaosLevel * 100}%` }}
/>
</div>
</div>
{/* Sub Status + Grid Row */}
<div className="flex gap-4 w-full">
{/* Sub Status Panel */}
<div className="hidden md:flex flex-col gap-2 w-48 shrink-0">
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 space-y-2">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider">Sub Status</h3>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Hull</span>
<span className={cn(hullHealth < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(hullHealth)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", hullHealth < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${hullHealth}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Reactor</span>
<span className={cn(reactorTemp > 120 ? "text-orange-400" : "text-slate-400")}>{Math.round(reactorTemp)}°C</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", reactorTemp > 120 ? "bg-orange-500 animate-pulse" : "bg-cyan-500")} style={{ width: `${Math.min(reactorTemp / 2, 100)}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">O</span>
<span className={cn(o2Level < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(o2Level)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", o2Level < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${o2Level}%` }} />
</div>
</div>
</div>
<div className="text-[8px] font-mono text-slate-700 pt-1 border-t border-slate-700/30">
{chaosLevel < 0.25 && "All systems nominal"}
{chaosLevel >= 0.25 && chaosLevel < 0.5 && "⚠️ Minor breaches detected"}
{chaosLevel >= 0.5 && chaosLevel < 0.75 && "🚨 EVACUATE! SUB COMPROMISED"}
{chaosLevel >= 0.75 && "☢️ MELTDOWN IN PROGRESS"}
</div>
</div>
{/* Activity Log */}
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 flex-1 min-h-0 max-h-60 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2 sticky top-0 bg-slate-900/80 pb-1">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
</div>
{/* Bingo Grid */}
<div className="flex-1 min-w-0">
<div
className="grid gap-1.5 w-full"
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
>
{grid.map((cell) => (
<BingoCell
key={cell.index}
item={cell.item}
index={cell.index}
marked={cell.marked}
markCount={cell.markCount}
markedBy={cell.markedBy}
gridSize={campaign.gridSize}
onMark={handleMark}
disabled={marking !== null}
/>
))}
</div>
</div>
</div>
{/* Bingo Lines */}
{bingoLines.length > 0 && (
<div className="flex flex-wrap gap-2 items-center justify-center">
<Badge variant="destructive" className="text-sm px-4 py-1 animate-pulse shadow-lg shadow-red-600/30">
🏆 BINGO! {bingoLines.length} line(s)
</Badge>
</div>
)}
{/* Bingo Celebration Modal */}
{showBingo && (
<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="text-6xl mb-4">🏆🔥💀</div>
<h2 className="text-3xl font-bold text-amber-400 font-mono mb-2">БИНГО!</h2>
<p className="text-slate-300 mb-4">Хаос победил! Суб взорван, экипаж мёртв, всем весело!</p>
<div className="text-4xl mb-4 animate-pulse">🎉💥🌊🤡</div>
<Button variant="outline" onClick={() => setShowBingo(false)}>
Продолжить хаос
</Button>
</div>
</div>
)}
{/* Mobile Activity Log */}
<div className="md:hidden w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 max-h-40 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 justify-center text-[10px] text-slate-600 font-mono">
<span>🎺 horn</span>
<span>🚨 alarm</span>
<span>🌊 flood</span>
<span>💥 boom</span>
<span>👹 monster</span>
<span>💀 death</span>
<span>🔥 chaos</span>
</div>
</div>
);
}

113
components/BingoCell.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
gridIndex: number;
};
type Props = {
item: Item | null;
index: number;
marked: boolean;
markCount: number;
markedBy: string[];
gridSize: number;
onMark: (itemId: string) => void;
disabled?: boolean;
isFreeSpace?: boolean;
};
function getSoundEmoji(cat: string) {
switch (cat) {
case "horn": return "🎺";
case "alarm": return "🚨";
case "flood": return "🌊";
case "explosion": return "💥";
case "monster": return "👹";
case "death": return "💀";
case "chaos": return "🔥";
default: return "🔔";
}
}
export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, disabled, isFreeSpace }: Props) {
const [shake, setShake] = useState(false);
const [glow, setGlow] = useState(false);
useEffect(() => {
if (marked) {
setShake(true);
setGlow(true);
const t1 = setTimeout(() => setShake(false), 300);
const t2 = setTimeout(() => setGlow(false), 2000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}
}, [marked]);
if (!item) {
return (
<div className={cn(
"flex items-center justify-center rounded-lg border border-dashed border-slate-700/30 bg-slate-900/20",
"text-slate-600 text-sm",
gridSize > 5 ? "p-1" : "p-2"
)}>
</div>
);
}
const isFree = isFreeSpace || item.text.startsWith("FREE SPACE");
return (
<button
onClick={() => !disabled && !marked && onMark(item.id)}
disabled={disabled || marked}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
"select-none",
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
marked
? "border-cyan-500/60 bg-cyan-900/40 cursor-default"
: "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",
shake && "animate-shake",
glow && "animate-glow-pulse",
isFree && "border-amber-500/40 bg-amber-900/20",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
)}
title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text}
>
<span className={cn(gridSize > 5 ? "text-lg" : "text-2xl", "leading-none")}>
{item.emoji || "💀"}
</span>
<span className={cn(
"text-center font-medium leading-tight text-slate-200",
gridSize > 5 ? "text-[10px]" : "text-xs",
"line-clamp-2"
)}>
{item.text}
</span>
{marked && markCount > 0 && (
<span className={cn(
"absolute top-0.5 right-1 font-bold text-cyan-400",
gridSize > 5 ? "text-[9px]" : "text-xs"
)}>
{markCount}
</span>
)}
{!marked && !isFree && (
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
)}
{isFree && !marked && (
<span className={cn("text-[9px] text-amber-500/60 mt-0.5", gridSize > 5 ? "hidden" : "")}>
🎯 mark me
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { useAuth } from "./AuthProvider";
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
createdAt: string;
};
export function CampaignList({ onSelect }: { onSelect: (id: string) => void }) {
const { user } = useAuth();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/campaigns")
.then(r => r.json())
.then(data => setCampaigns(data))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="text-center text-slate-500 font-mono text-sm py-8">
Scanning submarine network...
</div>
);
}
if (campaigns.length === 0) {
return (
<Card className="border-slate-700/30">
<CardContent className="py-8 text-center">
<div className="text-3xl mb-2">🗺</div>
<p className="text-slate-500 font-mono text-sm">No active campaigns</p>
<p className="text-slate-600 font-mono text-xs mt-1">Admin can create one</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-3 w-full max-w-md">
{campaigns.map(c => (
<Card key={c.id} className="border-slate-700/30 hover:border-cyan-700/30 transition-all cursor-pointer group" onClick={() => onSelect(c.id)}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<h3 className="font-mono text-sm text-slate-200 group-hover:text-cyan-300 transition-colors">
{c.name}
</h3>
<div className="flex gap-2 mt-1">
<Badge variant={c.status === "active" ? "success" : "secondary"} className="text-[9px]">
{c.status}
</Badge>
<span className="text-[10px] text-slate-600 font-mono">{c.gridSize}×{c.gridSize}</span>
</div>
</div>
<Button variant="ghost" size="sm" className="font-mono text-xs">
JOIN
</Button>
</CardContent>
</Card>
))}
</div>
);
}

57
components/ChaosMeter.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { playChaosRiser } from "@/lib/sounds";
type Props = {
markedCount: number;
totalItems: number;
};
export function ChaosMeter({ markedCount, totalItems }: Props) {
const [prevCount, setPrevCount] = useState(markedCount);
const ratio = totalItems > 0 ? markedCount / totalItems : 0;
const percent = Math.round(ratio * 100);
useEffect(() => {
if (markedCount > prevCount && markedCount % 3 === 0) {
playChaosRiser();
}
setPrevCount(markedCount);
}, [markedCount, prevCount]);
const stage =
ratio < 0.25 ? "stable" :
ratio < 0.5 ? "unstable" :
ratio < 0.75 ? "critical" : "meltdown";
const stageLabels: Record<string, string> = {
stable: "✅ Sub stable",
unstable: "⚠️ Leaking",
critical: "🚨 MELTDOWN",
meltdown: "☢️ NUKE",
};
return (
<div className="w-full space-y-1">
<div className="flex justify-between text-xs font-mono text-slate-500">
<span>{stageLabels[stage]}</span>
<span>{percent}% chaos</span>
</div>
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out",
stage === "stable" && "bg-gradient-to-r from-cyan-600 to-cyan-400",
stage === "unstable" && "bg-gradient-to-r from-amber-600 to-orange-400",
stage === "critical" && "bg-gradient-to-r from-orange-600 to-red-500 animate-pulse",
stage === "meltdown" && "bg-gradient-to-r from-red-600 to-purple-600 animate-pulse shadow-lg shadow-red-500/50"
)}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}

303
components/ItemEditor.tsx Normal file
View File

@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
import { Badge } from "./ui/badge";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
gridIndex: number;
};
type Campaign = {
id: string;
name: string;
gridSize: number;
};
function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-10 h-10 flex items-center justify-center text-xl rounded border border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 cursor-pointer"
>
{value || "?"}
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 bg-slate-900 border border-slate-700/50 rounded-lg p-2 shadow-xl w-[280px]">
<div className="grid grid-cols-8 gap-1">
{EMOJIS.map(e => (
<button
key={e}
type="button"
onClick={() => { onChange(e); setOpen(false); }}
className={`w-8 h-8 flex items-center justify-center text-base rounded hover:bg-slate-700 cursor-pointer ${e === value ? "bg-cyan-700/50 ring-1 ring-cyan-400" : ""}`}
>
{e}
</button>
))}
</div>
</div>
)}
</div>
);
}
export function ItemEditor({ campaign }: { campaign: Campaign }) {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [editText, setEditText] = useState("");
const [editEmoji, setEditEmoji] = useState("💀");
const [editSound, setEditSound] = useState("horn");
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchItems = async () => {
const res = await fetch(`/api/campaigns/${campaign.id}/items`);
const data = await res.json();
const sorted = [...data].sort((a: Item, b: Item) => a.gridIndex - b.gridIndex);
setItems(sorted);
setLoading(false);
};
useEffect(() => { fetchItems(); }, [campaign.id]);
const updateItem = async (item: Item) => {
await fetch(`/api/campaigns/${campaign.id}/items`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
text: item.text,
emoji: item.emoji,
soundCategory: item.soundCategory,
soundUrl: item.soundUrl,
gridIndex: item.gridIndex,
}),
});
await fetchItems();
};
const deleteItem = async (itemId: string) => {
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
await fetchItems();
};
const addItem = async () => {
if (!editText) return;
const maxIdx = items.reduce((max, it) => Math.max(max, it.gridIndex), -1);
const totalCells = campaign.gridSize * campaign.gridSize;
if (maxIdx + 1 >= totalCells) {
alert("Grid is full! Delete some items first.");
return;
}
await fetch(`/api/campaigns/${campaign.id}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: editText,
emoji: editEmoji,
soundCategory: editSound,
soundUrl: editSoundUrl,
gridIndex: maxIdx + 1,
}),
});
setEditText("");
setEditEmoji("💀");
setEditSound("horn");
setEditSoundUrl(null);
await fetchItems();
};
const uploadSound = async (file: File) => {
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload/sound", { method: "POST", body: formData });
const data = await res.json();
if (data.url) setEditSoundUrl(data.url);
setUploading(false);
};
const totalCells = campaign.gridSize * campaign.gridSize;
if (loading) {
return <div className="text-center text-slate-500 font-mono text-sm py-8">Loading items...</div>;
}
return (
<div className="space-y-4">
<Card className="border-cyan-800/30">
<CardContent className="p-4 space-y-3">
<h3 className="text-xs font-mono text-cyan-400 uppercase">+ Add New Cell</h3>
<div className="flex flex-wrap gap-2 items-end">
<div className="flex-1 min-w-[150px]">
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
<Input
placeholder="Reactor explodes"
value={editText}
onChange={e => setEditText(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
<EmojiPicker value={editEmoji} onChange={setEditEmoji} />
</div>
<div className="w-32">
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
<Select
value={editSound}
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }}
>
<SelectTrigger className="font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editSound === "custom" && (
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
<div className="flex gap-1 items-center">
<input
ref={fileInputRef}
type="file"
accept=".ogg"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
/>
<Button
variant="outline"
size="sm"
className="font-mono text-[9px] h-10"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
</Button>
{editSoundUrl && (
<button
type="button"
onClick={() => { const a = new Audio(editSoundUrl); a.volume = 0.4; a.play(); }}
className="text-sm cursor-pointer hover:text-cyan-300"
>
</button>
)}
</div>
</div>
)}
<Button size="sm" onClick={addItem} disabled={!editText} className="font-mono text-xs">
ADD
</Button>
</div>
</CardContent>
</Card>
<div
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
>
{Array.from({ length: totalCells }).map((_, idx) => {
const item = items.find(it => it.gridIndex === idx);
if (!item) {
return (
<div key={idx} className="aspect-square rounded border border-dashed border-slate-700/20 bg-slate-900/20 flex items-center justify-center text-slate-700 text-[10px] font-mono">
</div>
);
}
return (
<Card key={item.id} className="border-slate-700/30 aspect-square group">
<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-[8px] text-center font-mono text-slate-300 leading-tight line-clamp-2">
{item.text}
</span>
<Badge variant="outline" className="text-[6px] 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="destructive"
size="sm"
className="h-4 text-[8px] px-1 py-0 flex-1"
onClick={() => deleteItem(item.id)}
>
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="space-y-1">
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>
{items.map(item => (
<div key={item.id} className="flex items-center gap-2 bg-slate-800/30 rounded p-2 border border-slate-700/20">
<span className="text-sm w-6">{item.emoji}</span>
<input
className="flex-1 bg-transparent text-xs font-mono text-slate-200 border-b border-slate-700/30 focus:border-cyan-500/50 outline-none px-1"
value={item.text}
onChange={e => {
setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it));
}}
onBlur={() => updateItem(item)}
/>
<Select
value={item.soundCategory}
onValueChange={val => {
const updated = { ...item, soundCategory: val, soundUrl: val !== "custom" ? null : item.soundUrl };
setItems(prev => prev.map(it => it.id === item.id ? updated : it));
updateItem(updated);
}}
>
<SelectTrigger className="h-6 text-[9px] w-20 font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[9px] text-slate-600 font-mono w-6 text-right">#{item.gridIndex}</span>
</div>
))}
</div>
</div>
);
}

83
components/LoginForm.tsx Normal file
View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { useAuth } from "./AuthProvider";
export function LoginForm() {
const { login, register } = useAuth();
const [nickname, setNickname] = useState("");
const [password, setPassword] = useState("");
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const fn = isRegister ? register : login;
const err = await fn(nickname, password);
if (err) setError(err);
setLoading(false);
};
return (
<Card className="w-full max-w-md mx-auto border-cyan-700/30 shadow-2xl shadow-cyan-900/20">
<CardHeader className="text-center">
<div className="text-4xl mb-2">🤡💥</div>
<CardTitle className="text-2xl font-mono text-cyan-300 tracking-wider uppercase">
BaraBingo
</CardTitle>
<CardDescription className="text-slate-500 font-mono text-xs">
{isRegister ? "SUB CREW REGISTRATION" : "SUB NETWORK LOGIN"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase tracking-wider">Nickname</label>
<Input
placeholder="e.g. DrunkEngineer69"
value={nickname}
onChange={e => setNickname(e.target.value)}
required
minLength={2}
maxLength={20}
className="font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase tracking-wider">Password</label>
<Input
type="password"
placeholder="••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={4}
className="font-mono"
/>
</div>
{error && (
<div className="text-red-400 text-xs font-mono bg-red-950/30 border border-red-800/30 rounded-md p-2">
{error}
</div>
)}
<Button type="submit" className="w-full font-mono" disabled={loading}>
{loading ? "Connecting..." : isRegister ? "▶ REGISTER" : "▶ LOGIN"}
</Button>
<button
type="button"
onClick={() => { setIsRegister(!isRegister); setError(""); }}
className="w-full text-center text-xs text-slate-600 hover:text-cyan-400 transition-colors font-mono cursor-pointer"
>
{isRegister ? "Already have clearance? Login" : "Need a badge? Register"}
</button>
</form>
</CardContent>
</Card>
);
}

47
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { useAuth } from "./AuthProvider";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
export function Navbar() {
const { user, loading, logout } = useAuth();
return (
<nav className="border-b border-slate-800/50 bg-slate-950/50 backdrop-blur-sm sticky top-0 z-50">
<div className="container mx-auto px-4 h-12 flex items-center justify-between max-w-5xl">
<a href="/" className="flex items-center gap-2 font-mono text-sm text-cyan-300 hover:text-cyan-200 transition-colors no-underline">
<span>🤡</span>
<span className="font-bold tracking-wider uppercase hidden sm:inline">BaraBingo</span>
</a>
{!loading && (
<div className="flex items-center gap-3">
{user ? (
<>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">{user.nickname}</span>
{user.isAdmin && (
<Badge variant="default" className="text-[8px] px-1.5 py-0 uppercase">Admin</Badge>
)}
</div>
{user.isAdmin && (
<a href="/admin">
<Button variant="ghost" size="sm" className="text-xs font-mono h-7"></Button>
</a>
)}
<Button variant="ghost" size="sm" className="text-xs font-mono text-slate-600 h-7" onClick={logout}>
EXIT
</Button>
</>
) : (
<a href="/">
<Button variant="outline" size="sm" className="text-xs font-mono h-7">LOGIN</Button>
</a>
)}
</div>
)}
</div>
</nav>
);
}

30
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,30 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-cyan-600/80 text-cyan-50",
secondary: "border-transparent bg-slate-700 text-slate-200",
destructive: "border-transparent bg-red-600/80 text-red-50",
success: "border-transparent bg-emerald-600/80 text-emerald-50",
warning: "border-transparent bg-amber-600/80 text-amber-50",
outline: "text-slate-300 border-slate-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

48
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer",
{
variants: {
variant: {
default: "bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-900/30",
destructive: "bg-red-700 text-white hover:bg-red-600 shadow-lg shadow-red-900/30",
outline: "border border-cyan-700/50 bg-transparent text-cyan-300 hover:bg-cyan-950/50",
secondary: "bg-slate-800 text-slate-200 hover:bg-slate-700",
ghost: "text-slate-300 hover:bg-slate-800/50 hover:text-white",
link: "text-cyan-400 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

53
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-slate-700/50 bg-slate-900/80 text-slate-100 shadow-xl backdrop-blur-sm",
className
)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-slate-400", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

95
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,95 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-700/50 bg-slate-900 p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4 text-slate-400" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight text-slate-100", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-700/50 bg-slate-800/50 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/50 focus-visible:border-cyan-500/50 disabled:cursor-not-allowed disabled:opacity-50 transition-all",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

137
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-slate-700/50 bg-slate-800/50 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 transition-all",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-700/50 bg-slate-900 text-slate-100 shadow-md animate-in fade-in-80",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-400", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-800 focus:text-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-cyan-400" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-slate-700/50", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,15 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-slate-700/50 h-[1px] w-full", className)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
barabingo:
image: barabingo:latest
container_name: barabingo
restart: unless-stopped
ports:
- "80:3000"
volumes:
- ./data:/app/data
- ./uploads:/app/public/uploads
environment:
- NODE_ENV=production

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

78
lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcryptjs";
import { db } from "./db";
import { users, sessions } from "./db/schema";
import { eq, and } from "drizzle-orm";
import { cookies } from "next/headers";
const SESSION_COOKIE = "barabingo_session";
const SESSION_DAYS = 7;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
const ADMIN_NICKNAMES = ["admin"];
export async function registerUser(nickname: string, password: string) {
const existing = db.select().from(users).where(eq(users.nickname, nickname)).get();
if (existing) return { error: "Nickname taken" };
const userCount = db.select().from(users).all().length;
const nicknameLower = nickname.toLowerCase();
const firstUser = userCount === 0;
const isAdmin = firstUser || ADMIN_NICKNAMES.includes(nicknameLower);
const id = uuidv4();
const passwordHash = await hashPassword(password);
const createdAt = new Date().toISOString();
db.insert(users).values({ id, nickname, passwordHash, isAdmin, createdAt }).run();
return { id, nickname, isAdmin };
}
export async function loginUser(nickname: string, password: string) {
const user = db.select().from(users).where(eq(users.nickname, nickname)).get();
if (!user) return { error: "User not found" };
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) return { error: "Wrong password" };
const sessionId = uuidv4();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000).toISOString();
const createdAt = new Date().toISOString();
db.insert(sessions).values({ id: sessionId, userId: user.id, expiresAt, createdAt }).run();
return { sessionId, user: { id: user.id, nickname: user.nickname, isAdmin: user.isAdmin } };
}
export async function getSession(token?: string) {
if (!token) return null;
const session = db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return null;
if (new Date(session.expiresAt) < new Date()) {
db.delete(sessions).where(eq(sessions.id, token)).run();
return null;
}
const user = db.select().from(users).where(eq(users.id, session.userId)).get();
if (!user) return null;
return { id: user.id, nickname: user.nickname, isAdmin: user.isAdmin };
}
export async function deleteSession(token: string) {
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function getServerSession() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
return getSession(token);
}
export { SESSION_COOKIE };

65
lib/bingo-data.ts Normal file
View File

@@ -0,0 +1,65 @@
export type BingoEntry = {
text: string;
emoji: string;
soundCategory: string;
};
const FUNNY_ITEMS: BingoEntry[] = [
{ text: "Капитан затопил весь суб", emoji: "🌊", soundCategory: "flood" },
{ text: "Реактор взорвался", emoji: "💥", soundCategory: "explosion" },
{ text: "Вечеринка заразы", emoji: "🧟", soundCategory: "monster" },
{ text: "Клоун-убийца", emoji: "🤡", soundCategory: "horn" },
{ text: "Мудраптор в балласте", emoji: "🦖", soundCategory: "monster" },
{ text: "Уборщик всех спас", emoji: "🧹", soundCategory: "horn" },
{ text: "Доктор всех обколол морфием", emoji: "💉", soundCategory: "death" },
{ text: "Охранник застрелил капитана", emoji: "🔫", soundCategory: "explosion" },
{ text: "Все пьяны", emoji: "🍺", soundCategory: "horn" },
{ text: "Таламус пророс в суб", emoji: "🌿", soundCategory: "monster" },
{ text: "Червь съел суб целиком", emoji: "🐛", soundCategory: "monster" },
{ text: "Гудок в войсе", emoji: "📯", soundCategory: "horn" },
{ text: "Механик заварил себя в стену", emoji: "🔧", soundCategory: "horn" },
{ text: "Кислород кончился во время роя", emoji: "🫁", soundCategory: "alarm" },
{ text: "Капитан полный вперёд в стену", emoji: "🚀", soundCategory: "explosion" },
{ text: "Психоз и массовая паника", emoji: "🌀", soundCategory: "alarm" },
{ text: "Питомец-мудраптор сбесился", emoji: "🐊", soundCategory: "monster" },
{ text: "Ядерные стержни как оружие", emoji: "☢️", soundCategory: "alarm" },
{ text: "Уборщик моет радиоактивную воду", emoji: "☣️", soundCategory: "death" },
{ text: "Заражённый всё ещё работает", emoji: "😵", soundCategory: "death" },
{ text: "Рейлган дружественный огонь", emoji: "🎯", soundCategory: "explosion" },
{ text: "Балласт залит кислотой", emoji: "🧪", soundCategory: "alarm" },
{ text: "Доктор сварил химкоктейль", emoji: "🧬", soundCategory: "death" },
{ text: "Суб сплющило на 5000м", emoji: "🔱", soundCategory: "flood" },
{ text: "Последние слова капитана", emoji: "🗣️", soundCategory: "death" },
{ text: "Все умерли от одного мудраптора", emoji: "🦎", soundCategory: "monster" },
{ text: "Клоунский рожок как маяк", emoji: "🎺", soundCategory: "horn" },
{ text: "Охранник арестован за военные преступления", emoji: "⛓️", soundCategory: "horn" },
{ text: "Реактор в порядке (ложь)", emoji: "🤥", soundCategory: "alarm" },
{ text: "Ассистент починил корпус скотчем", emoji: "📦", soundCategory: "horn" },
{ text: "Капитан торгуется с монстром", emoji: "🤝", soundCategory: "horn" },
{ text: "Весь экипаж на гиперзине", emoji: "💊", soundCategory: "chaos" },
{ text: "Ассистент с ножом", emoji: "🔪", soundCategory: "death" },
{ text: "Скелет экипажа", emoji: "💀", soundCategory: "death" },
{ text: "FREE SPACE — Хаос", emoji: "🔥", soundCategory: "chaos" },
];
export const SOUND_CATEGORIES = [
{ value: "horn", label: "🎺 Клоунский рожок" },
{ value: "alarm", label: "🚨 Тревога" },
{ value: "flood", label: "🌊 Затопление" },
{ value: "explosion", label: "💥 Взрыв" },
{ value: "monster", label: "👹 Монстр" },
{ value: "death", label: "💀 Смерть" },
{ value: "chaos", label: "🔥 Хаос" },
{ value: "bingo", label: "🏆 Бинго" },
{ value: "custom", label: "📂 Свой звук" },
];
export const EMOJIS = [
"💀", "🔥", "🌊", "💥", "🤡", "🧟", "🦖", "🧹",
"💉", "🔫", "🍺", "🌿", "🐛", "📯", "🔧", "🫁",
"🚀", "🌀", "🐊", "☢️", "☣️", "😵", "🎯", "🧪",
"🧬", "🔱", "🗣️", "🦎", "🎺", "⛓️", "🤥", "📦",
"🤝", "💊", "🔪", "🎮", "🪠", "🦈", "🐙", "🧨",
"⚡", "🕳️", "🎭", "🪤", "🧲", "⚰️", "📡", "🛸",
"👽", "🤖", "👾", "🎵", "🚽", "🛏️", "🍕", "🧀",
];

64
lib/db/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";
import path from "path";
const DB_PATH = path.join(process.cwd(), "data", "bingo.db");
function getDb() {
const sqlite = new Database(DB_PATH);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
return sqlite;
}
function initDatabase(sqlite: Database.Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
nickname TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
grid_size INTEGER NOT NULL DEFAULT 5,
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bingo_items (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
text TEXT NOT NULL,
emoji TEXT NOT NULL DEFAULT '💀',
sound_category TEXT NOT NULL DEFAULT 'horn',
sound_url TEXT,
grid_index INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS marks (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES bingo_items(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
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);
`);
try { sqlite.exec("ALTER TABLE bingo_items ADD COLUMN sound_url TEXT"); } catch {}
}
const sqlite = getDb();
initDatabase(sqlite);
export const db = drizzle(sqlite, { schema });

48
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,48 @@
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
nickname: text("nickname").unique().notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
createdAt: text("created_at").notNull(),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull(),
});
export const campaigns = sqliteTable("campaigns", {
id: text("id").primaryKey(),
name: text("name").notNull(),
gridSize: integer("grid_size").notNull().default(5),
status: text("status").notNull().default("active"),
createdBy: text("created_by").notNull().references(() => users.id),
createdAt: text("created_at").notNull(),
});
export const bingoItems = sqliteTable("bingo_items", {
id: text("id").primaryKey(),
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
text: text("text").notNull(),
emoji: text("emoji").notNull().default("💀"),
soundCategory: text("sound_category").notNull().default("horn"),
soundUrl: text("sound_url"),
gridIndex: integer("grid_index").notNull(),
createdAt: text("created_at").notNull(),
}, (table) => [
uniqueIndex("idx_campaign_item_pos").on(table.campaignId, table.gridIndex),
]);
export const marks = sqliteTable("marks", {
id: text("id").primaryKey(),
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
itemId: text("item_id").notNull().references(() => bingoItems.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull(),
}, (table) => [
uniqueIndex("idx_unique_mark").on(table.campaignId, table.itemId),
]);

134
lib/sounds.ts Normal file
View File

@@ -0,0 +1,134 @@
let audioCtx: AudioContext | null = null;
function getCtx(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
type OscillatorConfig = {
freq: number;
type: OscillatorType;
duration: number;
gain?: number;
};
function playTone(config: OscillatorConfig) {
try {
const ctx = getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = config.type;
osc.frequency.setValueAtTime(config.freq, ctx.currentTime);
gain.gain.setValueAtTime(config.gain ?? 0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + config.duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + config.duration);
} catch {
// Audio not supported
}
}
function playNoise(duration: number, gain = 0.15) {
try {
const ctx = getCtx();
const bufferSize = Math.floor(ctx.sampleRate * duration);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
source.connect(gainNode);
gainNode.connect(ctx.destination);
source.start();
} catch {
// Audio not supported
}
}
export function playHonk() {
playTone({ freq: 800, type: "square", duration: 0.08, gain: 0.2 });
setTimeout(() => playTone({ freq: 600, type: "square", duration: 0.12, gain: 0.2 }), 80);
}
export function playReactorAlarm() {
for (let i = 0; i < 4; i++) {
setTimeout(() => {
playTone({ freq: 440 + i * 110, type: "sawtooth", duration: 0.2, gain: 0.15 });
setTimeout(() => playTone({ freq: 330 + i * 80, type: "sawtooth", duration: 0.2, gain: 0.15 }), 100);
}, i * 300);
}
}
export function playHullBreach() {
playTone({ freq: 100, type: "sine", duration: 0.5, gain: 0.4 });
setTimeout(() => playTone({ freq: 80, type: "sine", duration: 0.5, gain: 0.3 }), 200);
playNoise(0.5, 0.1);
}
export function playExplosion() {
playNoise(0.6, 0.4);
playTone({ freq: 60, type: "sine", duration: 0.5, gain: 0.5 });
setTimeout(() => playTone({ freq: 40, type: "sine", duration: 0.3, gain: 0.3 }), 200);
}
export function playMonster() {
playTone({ freq: 80, type: "sawtooth", duration: 0.4, gain: 0.2 });
setTimeout(() => playTone({ freq: 60, type: "sawtooth", duration: 0.4, gain: 0.15 }), 200);
setTimeout(() => playTone({ freq: 50, type: "square", duration: 0.5, gain: 0.2 }), 400);
}
export function playDeath() {
playTone({ freq: 400, type: "sine", duration: 0.15, gain: 0.2 });
setTimeout(() => playTone({ freq: 300, type: "sine", duration: 0.15, gain: 0.2 }), 150);
setTimeout(() => playTone({ freq: 200, type: "sine", duration: 0.3, gain: 0.2 }), 300);
}
export function playBingo() {
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "square", duration: 0.2, gain: 0.2 }), i * 120);
});
setTimeout(() => playNoise(0.3, 0.1), 480);
}
export function playChaosRiser() {
const pitches = [200, 250, 300, 400, 500, 600, 800, 1000];
pitches.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "sawtooth", duration: 0.1, gain: 0.08 }), i * 50);
});
}
export function playSoundUrl(url: string) {
try {
const audio = new Audio(url);
audio.volume = 0.4;
audio.play().catch(() => {});
} catch {}
}
export function playSound(category: string, soundUrl?: string | null) {
if (soundUrl) {
playSoundUrl(soundUrl);
return;
}
switch (category) {
case "horn": playHonk(); break;
case "alarm": playReactorAlarm(); break;
case "flood": playHullBreach(); break;
case "explosion": playExplosion(); break;
case "monster": playMonster(); break;
case "death": playDeath(); break;
case "bingo": playBingo(); break;
case "chaos": playChaosRiser(); break;
default: playHonk(); break;
}
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

8
next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;

7731
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "barotraumabingo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-toast": "^1.2.16",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.18.0",
"next": "^16.2.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^14.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}