diff --git a/bun.lock b/bun.lock index 25649d7..7f2d86f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", + "zod": "^4.3.6", }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/package.json b/package.json index c91bbe9..99759f5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/api/room/[code]/route.ts b/src/app/api/room/[code]/route.ts new file mode 100644 index 0000000..8c6cccf --- /dev/null +++ b/src/app/api/room/[code]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getRoom, closeRoom, leaveRoom } from '@/lib/rooms'; + +export async function GET( + req: Request, + { params }: { params: Promise<{ code: string }> } +) { + const { code } = await params; + const room = getRoom(code); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + return NextResponse.json({ + code: room.code, + playerCount: room.players.size, + players: Array.from(room.players.values()), + isActive: room.isActive, + }); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ code: string }> } +) { + const { code } = await params; + const body = await req.json(); + + if (body.action === 'close') { + const success = closeRoom(code, body.moderatorId); + return NextResponse.json({ success }); + } + + if (body.action === 'leave') { + const success = leaveRoom(code, body.playerId); + return NextResponse.json({ success }); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); +} \ No newline at end of file diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts new file mode 100644 index 0000000..5bb8b5c --- /dev/null +++ b/src/app/api/room/create/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { createRoom } from '@/lib/rooms'; + +export async function POST() { + const { code, moderatorId } = createRoom(); + return NextResponse.json({ code, moderatorId }); +} \ No newline at end of file diff --git a/src/app/api/room/join/route.ts b/src/app/api/room/join/route.ts new file mode 100644 index 0000000..08ca73e --- /dev/null +++ b/src/app/api/room/join/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { joinRoom } from '@/lib/rooms'; + +export async function POST(req: Request) { + const { code, playerName } = await req.json(); + const result = joinRoom(code, playerName); + + if (!result) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + return NextResponse.json(result); +} \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..b5c61c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,3 @@ -@import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/app/host/page.tsx b/src/app/host/page.tsx new file mode 100644 index 0000000..429329a --- /dev/null +++ b/src/app/host/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export default function HostPage() { + const [roomCode, setRoomCode] = useState(""); + const [moderatorId, setModeratorId] = useState(""); + const [players, setPlayers] = useState([]); + + async function createRoom() { + const res = await fetch("/api/room/create", { method: "POST" }); + const data = await res.json(); + setRoomCode(data.code); + setModeratorId(data.moderatorId); + } + + useEffect(() => { + if (!roomCode) return; + + const interval = setInterval(async () => { + const res = await fetch(`/api/room/${roomCode}`); + const data = await res.json(); + setPlayers(data.players || []); + }, 2000); + + return () => clearInterval(interval); + }, [roomCode]); + + async function closeRoom() { + await fetch(`/api/room/${roomCode}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "close", moderatorId }), + }); + window.location.href = "/"; + } + + if (!roomCode) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Room Code

+
+ {roomCode} +
+ +
+

+ Players ({players.length}) +

+ + {players.map((p) => ( +
+ {p.name} +
+ ))} +
+ + +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..d2399dc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,19 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "SciBowl Quiz", + description: "Science Bowl quiz game", }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - {children} - + {children} ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..c9893b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,25 @@ -import Image from "next/image"; +import Link from "next/link"; export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+
+

๐Ÿงช SciBowl Quiz

+ +
+ + Host a Game + + + + Join a Game + +
+
); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx new file mode 100644 index 0000000..7fbd223 --- /dev/null +++ b/src/app/play/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; + +export default function PlayPage() { + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [joined, setJoined] = useState(false); + const [playerId, setPlayerId] = useState(""); + + async function join() { + const res = await fetch("/api/room/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: code.toUpperCase(), playerName: name }), + }); + + const data = await res.json(); + + if (data.error) { + alert(data.error); + return; + } + + setPlayerId(data.playerId); + setJoined(true); + } + + async function leave() { + await fetch(`/api/room/${code}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "leave", playerId }), + }); + window.location.href = "/"; + } + + if (joined) { + return ( +
+

โœ“ You're in!

+

+ Room: {code} +

+

Waiting for host to start...

+ +
+ ); + } + + return ( +
+
+

Join a Room

+ + setName(e.target.value)} + className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-4" + maxLength={20} + /> + + setCode(e.target.value.toUpperCase())} + className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-6 uppercase" + maxLength={6} + /> + + +
+
+ ); +} diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts new file mode 100644 index 0000000..4379df8 --- /dev/null +++ b/src/lib/rooms.ts @@ -0,0 +1,78 @@ +import { Room } from '@/types'; + +// Use global to persist across Next.js hot reloads +declare global { + var roomStorage: Map | undefined; +} + +// Initialize global storage if it doesn't exist +if (!global.roomStorage) { + global.roomStorage = new Map(); +} + +export const rooms = global.roomStorage; + +function generateCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return Array.from({ length: 6 }, () => + chars[Math.floor(Math.random() * chars.length)] + ).join(''); +} + +function generateId(): string { + return Math.random().toString(36).substring(2, 15); +} + +export function createRoom() { + const code = generateCode(); + const moderatorId = generateId(); + + rooms.set(code, { + code, + moderatorId, + players: new Map(), + isActive: true, + createdAt: new Date(), + }); + + console.log('โœ… Created room:', code, 'Total rooms:', rooms.size); + return { code, moderatorId }; +} + +export function joinRoom(code: string, name: string) { + const room = rooms.get(code); + console.log('๐Ÿ‘ค Join attempt for room:', code, 'Room exists:', !!room, 'Total rooms:', rooms.size); + + if (!room?.isActive) return null; + + const playerId = generateId(); + room.players.set(playerId, { id: playerId, name, score: 0 }); + + console.log('โœ… Player joined:', name, 'Total players:', room.players.size); + return { playerId }; +} + +export function getRoom(code: string) { + const room = rooms.get(code); + console.log('๐Ÿ” Get room:', code, 'Exists:', !!room, 'Total rooms:', rooms.size); + return room; +} + +export function closeRoom(code: string, moderatorId: string) { + const room = rooms.get(code); + if (room?.moderatorId === moderatorId) { + room.isActive = false; + console.log('๐Ÿ”’ Room closed:', code); + return true; + } + return false; +} + +export function leaveRoom(code: string, playerId: string) { + const room = rooms.get(code); + const result = room?.players.delete(playerId) ?? false; + if (result) { + console.log('๐Ÿ‘‹ Player left room:', code, 'Remaining players:', room?.players.size); + } + return result; +} \ No newline at end of file diff --git a/src/lib/scibowl-api.ts b/src/lib/scibowl-api.ts new file mode 100644 index 0000000..86f3045 --- /dev/null +++ b/src/lib/scibowl-api.ts @@ -0,0 +1,18 @@ +const BASE_URL = 'https://scibowldb.com/api'; + +interface RandomQuestionResponse { + question: any; +} + +export async function getRandomQuestion() { + const res = await fetch(`${BASE_URL}/questions/random`); + if (!res.ok) throw new Error('Failed to fetch question'); + const data: RandomQuestionResponse = await res.json(); + return data.question; +} + +export async function getAllQuestions() { + const res = await fetch(`${BASE_URL}/questions`); + if (!res.ok) throw new Error('Failed to fetch questions'); + return await res.json(); +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..aa212bc --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,27 @@ +export interface Question { + api_url: string; + bonus_answer: string; + bonus_format: string; + bonus_question: string; + category: string; + id: number; + source: string; + tossup_answer: string; + tossup_format: string; + tossup_question: string; + uri: string; +} + +export interface Player { + id: string; + name: string; + score: number; +} + +export interface Room { + code: string; + moderatorId: string; + players: Map; + isActive: boolean; + createdAt: Date; +} \ No newline at end of file