This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
|
||||
38
.gitea/workflows/deploy.yml
Normal file
38
.gitea/workflows/deploy.yml
Normal 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
46
.gitignore
vendored
Normal 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
32
Dockerfile
Normal 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
36
README.md
Normal 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.
|
||||
72
app/admin/campaigns/[campaignId]/page.tsx
Normal file
72
app/admin/campaigns/[campaignId]/page.tsx
Normal 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
34
app/admin/page.tsx
Normal 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 />;
|
||||
}
|
||||
39
app/api/auth/login/route.ts
Normal file
39
app/api/auth/login/route.ts
Normal 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
10
app/api/auth/me/route.ts
Normal 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 } });
|
||||
}
|
||||
24
app/api/auth/register/route.ts
Normal file
24
app/api/auth/register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
83
app/api/campaigns/[campaignId]/items/route.ts
Normal file
83
app/api/campaigns/[campaignId]/items/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
app/api/campaigns/[campaignId]/route.ts
Normal file
34
app/api/campaigns/[campaignId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
app/api/campaigns/route.ts
Normal file
37
app/api/campaigns/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
74
app/api/game/[campaignId]/state/route.ts
Normal file
74
app/api/game/[campaignId]/state/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
app/api/game/mark/route.ts
Normal file
58
app/api/game/mark/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
app/api/upload/sound/route.ts
Normal file
39
app/api/upload/sound/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
91
app/game/[campaignId]/page.tsx
Normal file
91
app/game/[campaignId]/page.tsx
Normal 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
89
app/globals.css
Normal 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
35
app/layout.tsx
Normal 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
61
app/page.tsx
Normal 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 “admin” 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>
|
||||
);
|
||||
}
|
||||
164
components/AdminDashboard.tsx
Normal file
164
components/AdminDashboard.tsx
Normal 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 “New Campaign” 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>
|
||||
);
|
||||
}
|
||||
79
components/AuthProvider.tsx
Normal file
79
components/AuthProvider.tsx
Normal 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
299
components/BingoCard.tsx
Normal 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
113
components/BingoCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
components/CampaignList.tsx
Normal file
73
components/CampaignList.tsx
Normal 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
57
components/ChaosMeter.tsx
Normal 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
303
components/ItemEditor.tsx
Normal 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
83
components/LoginForm.tsx
Normal 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
47
components/Navbar.tsx
Normal 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
30
components/ui/badge.tsx
Normal 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
48
components/ui/button.tsx
Normal 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
53
components/ui/card.tsx
Normal 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
95
components/ui/dialog.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
137
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/separator.tsx
Normal file
15
components/ui/separator.tsx
Normal 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
12
docker-compose.yml
Normal 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
18
eslint.config.mjs
Normal 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
78
lib/auth.ts
Normal 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
65
lib/bingo-data.ts
Normal 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
64
lib/db/index.ts
Normal 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
48
lib/db/schema.ts
Normal 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
134
lib/sounds.ts
Normal 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
6
lib/utils.ts
Normal 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
8
next.config.ts
Normal 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
7731
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user