somehwat AI CSS...but not good at all in teh slightest, logic works

This commit is contained in:
2026-01-28 18:57:52 -06:00
parent bf59155c1e
commit 778a41d1b1
11 changed files with 2104 additions and 140 deletions

View File

@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { getRoom, buzz, startGame, loadQuestion, startTossupTimer, startBonusTimer, markTossupCorrect, markTossupWrong, markBonusCorrect, markBonusWrong, endGame } from '@/lib/rooms';
import { getRandomQuestion } from '@/lib/scibowl-api';
import { z, ZodError } from 'zod';
const gameActionSchema = z.object({
action: z.enum(['start', 'load_question', 'start_timer', 'buzz', 'tossup_correct', 'tossup_wrong', 'bonus_start_timer', 'bonus_correct', 'bonus_wrong', 'end']),
moderatorId: z.string().optional(),
playerId: z.string().optional(),
});
export async function POST(
req: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
const { code } = await params;
const body = await req.json();
const { action, moderatorId, playerId } = gameActionSchema.parse(body);
let success = false;
switch (action) {
case 'start':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = startGame(code, moderatorId);
break;
case 'load_question':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
const question = await getRandomQuestion();
success = loadQuestion(code, moderatorId, question);
break;
case 'start_timer':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = startTossupTimer(code, moderatorId);
break;
case 'buzz':
if (!playerId) return NextResponse.json({ error: 'Missing playerId' }, { status: 400 });
success = buzz(code, playerId);
break;
case 'tossup_correct':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = markTossupCorrect(code, moderatorId);
break;
case 'tossup_wrong':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = markTossupWrong(code, moderatorId);
break;
case 'bonus_start_timer':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = startBonusTimer(code, moderatorId);
break;
case 'bonus_correct':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = markBonusCorrect(code, moderatorId);
break;
case 'bonus_wrong':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = markBonusWrong(code, moderatorId);
break;
case 'end':
if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 });
success = endGame(code, moderatorId);
break;
}
if (!success) {
return NextResponse.json({ error: 'Action failed' }, { status: 400 });
}
const room = getRoom(code);
return NextResponse.json({
success: true,
gameState: room?.gameState
});
} catch (error: unknown) {
console.error('Game action error:', error);
if (error instanceof ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View File

@@ -1,12 +1,24 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getRoom, closeRoom, leaveRoom } from '@/lib/rooms'; import { getRoom, closeRoom, leaveRoom } from '@/lib/rooms';
import { rateLimit, getClientIdentifier } from '@/lib/rate-limit';
import { roomCodeSchema, roomActionSchema } from '@/lib/validation';
import { ZodError } from 'zod';
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: Promise<{ code: string }> } { params }: { params: Promise<{ code: string }> }
) { ) {
try {
const clientId = getClientIdentifier(req);
const rateLimitResult = rateLimit(`get:${clientId}`, 500, 60000); // 200 requests per minute
if (!rateLimitResult.success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
const { code } = await params; const { code } = await params;
const room = getRoom(code); const validatedCode = roomCodeSchema.parse(code);
const room = getRoom(validatedCode);
if (!room) { if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 }); return NextResponse.json({ error: 'Room not found' }, { status: 404 });
@@ -15,27 +27,76 @@ export async function GET(
return NextResponse.json({ return NextResponse.json({
code: room.code, code: room.code,
playerCount: room.players.size, playerCount: room.players.size,
players: Array.from(room.players.values()), players: Array.from(room.players.values()).map(p => ({
id: p.id,
name: p.name,
score: p.score,
teamId: p.teamId,
})),
teams: Array.from(room.teams.values()),
gameState: room.gameState,
isActive: room.isActive, isActive: room.isActive,
}); });
} catch (error: unknown) {
console.error('Get room error:', error);
if (error instanceof ZodError) {
return NextResponse.json({ error: 'Invalid room code' }, { status: 400 });
}
return NextResponse.json({ error: 'Failed to get room' }, { status: 400 });
}
} }
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: Promise<{ code: string }> } { params }: { params: Promise<{ code: string }> }
) { ) {
const { code } = await params; try {
const body = await req.json(); const clientId = getClientIdentifier(req);
const rateLimitResult = rateLimit(`action:${clientId}`, 30, 60000);
if (body.action === 'close') { if (!rateLimitResult.success) {
const success = closeRoom(code, body.moderatorId); return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
return NextResponse.json({ success });
} }
if (body.action === 'leave') { const { code } = await params;
const success = leaveRoom(code, body.playerId); const validatedCode = roomCodeSchema.parse(code);
const body = await req.json();
const validatedAction = roomActionSchema.parse(body);
if (validatedAction.action === 'close') {
if (!validatedAction.moderatorId) {
return NextResponse.json({ error: 'Moderator ID required' }, { status: 400 });
}
const success = closeRoom(validatedCode, validatedAction.moderatorId);
if (!success) {
return NextResponse.json({ error: 'Unauthorized or room not found' }, { status: 403 });
}
return NextResponse.json({ success: true });
}
if (validatedAction.action === 'leave') {
if (!validatedAction.playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 });
}
const success = leaveRoom(validatedCode, validatedAction.playerId);
return NextResponse.json({ success }); return NextResponse.json({ success });
} }
return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error: unknown) {
console.error('Room action error:', error);
if (error instanceof ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 });
}
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
} }

View File

@@ -1,11 +1,14 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createRoom } from '@/lib/rooms'; import { createRoom } from '@/lib/rooms';
import { rateLimit, getClientIdentifier } from '@/lib/rate-limit'; import { rateLimit, getClientIdentifier } from '@/lib/rate-limit';
import { createRoomSchema } from '@/lib/validation'; import { z } from 'zod';
const createRoomSchema = z.object({
teamMode: z.enum(['1', '2']).optional().default('2'),
});
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
// Rate limit: max 5 rooms per IP per hour
const clientId = getClientIdentifier(req); const clientId = getClientIdentifier(req);
const rateLimitResult = rateLimit(`create:${clientId}`, 5, 3600000); const rateLimitResult = rateLimit(`create:${clientId}`, 5, 3600000);
@@ -16,18 +19,17 @@ export async function POST(req: Request) {
); );
} }
// Validate request (empty body is fine for create)
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
createRoomSchema.parse(body); const { teamMode } = createRoomSchema.parse(body);
const { code, moderatorId } = createRoom(); const { code, moderatorId } = createRoom(parseInt(teamMode) as 1 | 2);
return NextResponse.json({ return NextResponse.json({
code, code,
moderatorId, moderatorId,
remaining: rateLimitResult.remaining remaining: rateLimitResult.remaining
}); });
} catch (error: unknown) { } catch (error) {
console.error('Create room error:', error); console.error('Create room error:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create room' }, { error: 'Failed to create room' },

View File

@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { joinRoom } from '@/lib/rooms'; import { joinRoom } from '@/lib/rooms';
import { rateLimit, getClientIdentifier } from '@/lib/rate-limit'; import { rateLimit, getClientIdentifier } from '@/lib/rate-limit';
import { joinRoomSchema } from '@/lib/validation'; import { z } from 'zod';
const joinRoomSchema = z.object({
code: z.string().length(6).regex(/^[A-Z0-9]+$/),
playerName: z.string().min(1).max(20).regex(/^[a-zA-Z0-9\s]+$/).transform(val => val.trim()),
teamId: z.enum(['1', '2']),
});
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
// Rate limit: max 20 join attempts per IP per minute
const clientId = getClientIdentifier(req); const clientId = getClientIdentifier(req);
const rateLimitResult = rateLimit(`join:${clientId}`, 20, 60000); const rateLimitResult = rateLimit(`join:${clientId}`, 20, 60000);
@@ -19,26 +24,22 @@ export async function POST(req: Request) {
const body = await req.json(); const body = await req.json();
const validatedData = joinRoomSchema.parse(body); const validatedData = joinRoomSchema.parse(body);
const result = joinRoom(validatedData.code, validatedData.playerName); const result = joinRoom(
validatedData.code,
validatedData.playerName,
parseInt(validatedData.teamId) as 1 | 2
);
if (!result) { if (!result) {
return NextResponse.json( return NextResponse.json(
{ error: 'Room not found or inactive' }, { error: 'Room not found, inactive, or game in progress' },
{ status: 404 } { status: 404 }
); );
} }
return NextResponse.json(result); return NextResponse.json(result);
} catch (error: any) { } catch (error) {
console.error('Join room error:', error); console.error('Join room error:', error);
if (error.name === 'ZodError') {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid request' }, { error: 'Invalid request' },
{ status: 400 } { status: 400 }

View File

@@ -1,3 +1,627 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
/* Catppuccin Mocha */
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
* {
box-sizing: border-box;
}
body {
background: linear-gradient(
135deg,
var(--ctp-base) 0%,
var(--ctp-mantle) 50%,
var(--ctp-crust) 100%
);
background-attachment: fixed;
color: var(--ctp-text);
min-height: 100vh;
}
/* Gradient overlays */
.gradient-overlay {
position: relative;
overflow: hidden;
}
.gradient-overlay::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(203, 166, 247, 0.1) 0%,
rgba(137, 180, 250, 0.1) 100%
);
pointer-events: none;
animation: gradient-shift 8s ease infinite;
}
@keyframes gradient-shift {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.8;
}
}
/* Card styles */
.card {
background: var(--ctp-surface0);
border: 2px solid var(--ctp-surface2);
border-radius: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
transition: left 0.5s;
}
.card:hover::before {
left: 100%;
}
.card:hover {
border-color: var(--ctp-mauve);
transform: translateY(-2px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
/* Button styles */
.btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
font-weight: 700;
border-radius: 0.75rem;
cursor: pointer;
border: none;
}
.btn::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transform: translate(-50%, -50%);
transition:
width 0.6s,
height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow:
0 0 20px var(--ctp-red),
0 0 40px var(--ctp-red),
0 0 60px var(--ctp-red);
}
50% {
box-shadow:
0 0 30px var(--ctp-red),
0 0 60px var(--ctp-red),
0 0 90px var(--ctp-red);
}
}
@keyframes pulse-scale {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes timer-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Utility classes */
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.slide-in-left {
animation: slideInLeft 0.5s ease-out;
}
.slide-in-right {
animation: slideInRight 0.5s ease-out;
}
.scale-in {
animation: scaleIn 0.4s ease-out;
}
.float {
animation: float 3s ease-in-out infinite;
}
/* Buzz button */
.buzz-button {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(
135deg,
var(--ctp-red) 0%,
var(--ctp-maroon) 100%
);
box-shadow: 0 10px 40px rgba(243, 139, 168, 0.5);
}
.buzz-button::after {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.3) 50%,
transparent 70%
);
transform: rotate(45deg);
animation: shimmer 3s infinite;
}
.buzz-button:hover {
transform: scale(1.1);
box-shadow: 0 15px 50px rgba(243, 139, 168, 0.7);
}
.buzz-button:active {
transform: scale(0.95);
}
.buzz-active {
animation:
pulse-glow 1s infinite,
pulse-scale 1s infinite;
}
.buzz-disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
/* Timer */
.timer-warning {
animation: timer-pulse 0.5s ease-in-out infinite;
}
.timer-bar {
transition:
width 0.3s ease-out,
background-color 0.3s ease;
border-radius: 9999px;
position: relative;
overflow: hidden;
}
.timer-bar::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 2s infinite;
}
/* Score displays */
.score-card {
background: linear-gradient(
135deg,
var(--ctp-surface0) 0%,
var(--ctp-surface1) 100%
);
border: 2px solid;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.score-card::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 70%
);
animation: spin-slow 20s linear infinite;
}
.team1-card {
border-color: var(--ctp-blue);
box-shadow: 0 0 30px rgba(137, 180, 250, 0.2);
}
.team2-card {
border-color: var(--ctp-red);
box-shadow: 0 0 30px rgba(243, 139, 168, 0.2);
}
.individual-card {
border-color: var(--ctp-mauve);
box-shadow: 0 0 30px rgba(203, 166, 247, 0.2);
}
/* Question cards */
.question-card {
background: linear-gradient(
135deg,
var(--ctp-surface0) 0%,
var(--ctp-surface1) 100%
);
border: 2px solid var(--ctp-surface2);
border-radius: 1.5rem;
padding: 2rem;
position: relative;
overflow: hidden;
}
.question-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
var(--ctp-red),
var(--ctp-yellow),
var(--ctp-green),
var(--ctp-blue),
var(--ctp-mauve)
);
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
}
/* Glow effects */
.glow-green {
box-shadow: 0 0 20px rgba(166, 227, 161, 0.5);
}
.glow-red {
box-shadow: 0 0 20px rgba(243, 139, 168, 0.5);
}
.glow-blue {
box-shadow: 0 0 20px rgba(137, 180, 250, 0.5);
}
.glow-yellow {
box-shadow: 0 0 20px rgba(249, 226, 175, 0.5);
}
.glow-mauve {
box-shadow: 0 0 20px rgba(203, 166, 247, 0.5);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 14px;
}
::-webkit-scrollbar-track {
background: var(--ctp-mantle);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(
180deg,
var(--ctp-surface1) 0%,
var(--ctp-surface2) 100%
);
border-radius: 10px;
border: 2px solid var(--ctp-mantle);
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(
180deg,
var(--ctp-surface2) 0%,
var(--ctp-overlay0) 100%
);
}
/* Text gradients */
.text-gradient-mauve {
background: linear-gradient(
135deg,
var(--ctp-mauve) 0%,
var(--ctp-pink) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-blue {
background: linear-gradient(
135deg,
var(--ctp-blue) 0%,
var(--ctp-sapphire) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-green {
background: linear-gradient(
135deg,
var(--ctp-green) 0%,
var(--ctp-teal) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Loading states */
.skeleton {
background: linear-gradient(
90deg,
var(--ctp-surface0) 25%,
var(--ctp-surface1) 50%,
var(--ctp-surface0) 75%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 0.5rem;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-weight: 700;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Status indicators */
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
animation: pulse-scale 2s ease-in-out infinite;
}
.status-active {
background: var(--ctp-green);
box-shadow: 0 0 10px var(--ctp-green);
}
.status-waiting {
background: var(--ctp-yellow);
box-shadow: 0 0 10px var(--ctp-yellow);
}
.status-error {
background: var(--ctp-red);
box-shadow: 0 0 10px var(--ctp-red);
}
/* Glass morphism */
.glass {
background: rgba(49, 50, 68, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(205, 214, 244, 0.1);
}
/* Neon text */
.neon-text {
text-shadow:
0 0 10px currentColor,
0 0 20px currentColor,
0 0 30px currentColor,
0 0 40px currentColor;
}
/* Particle background effect */
@keyframes particle-float {
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
33% {
transform: translate(30px, -30px) rotate(120deg);
}
66% {
transform: translate(-20px, 20px) rotate(240deg);
}
}
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
width: 4px;
height: 4px;
background: var(--ctp-mauve);
border-radius: 50%;
opacity: 0.3;
animation: particle-float 20s infinite;
}

View File

@@ -1,17 +1,41 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Player } from "@/types";
import Cookies from "js-cookie";
import Timer from "@/components/Timer";
export default function HostPage() { export default function HostPage() {
const [roomCode, setRoomCode] = useState(""); const [roomCode, setRoomCode] = useState("");
const [moderatorId, setModeratorId] = useState(""); const [moderatorId, setModeratorId] = useState("");
const [players, setPlayers] = useState<any[]>([]); const [players, setPlayers] = useState<Player[]>([]);
const [gameState, setGameState] = useState<any>(null);
const [roomClosed, setRoomClosed] = useState(false);
const [teamMode, setTeamMode] = useState<"1" | "2">("2");
useEffect(() => {
const savedCode = Cookies.get("host_room_code");
const savedModId = Cookies.get("host_moderator_id");
if (savedCode && savedModId) {
setRoomCode(savedCode);
setModeratorId(savedModId);
}
}, []);
async function createRoom() { async function createRoom() {
const res = await fetch("/api/room/create", { method: "POST" }); const res = await fetch("/api/room/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ teamMode }),
});
const data = await res.json(); const data = await res.json();
setRoomCode(data.code); setRoomCode(data.code);
setModeratorId(data.moderatorId); setModeratorId(data.moderatorId);
Cookies.set("host_room_code", data.code, { expires: 1 / 12 });
Cookies.set("host_moderator_id", data.moderatorId, { expires: 1 / 12 });
} }
useEffect(() => { useEffect(() => {
@@ -19,62 +43,442 @@ export default function HostPage() {
const interval = setInterval(async () => { const interval = setInterval(async () => {
const res = await fetch(`/api/room/${roomCode}`); const res = await fetch(`/api/room/${roomCode}`);
if (!res.ok) {
setRoomClosed(true);
Cookies.remove("host_room_code");
Cookies.remove("host_moderator_id");
clearInterval(interval);
return;
}
const data = await res.json(); const data = await res.json();
setPlayers(data.players || []); setPlayers(data.players || []);
}, 2000); setGameState(data.gameState);
}, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [roomCode]); }, [roomCode]);
async function gameAction(action: string) {
await fetch(`/api/game/${roomCode}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, moderatorId }),
});
}
async function closeRoom() { async function closeRoom() {
await fetch(`/api/room/${roomCode}`, { await fetch(`/api/room/${roomCode}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "close", moderatorId }), body: JSON.stringify({ action: "close", moderatorId }),
}); });
Cookies.remove("host_room_code");
Cookies.remove("host_moderator_id");
window.location.href = "/"; window.location.href = "/";
} }
if (roomClosed) {
return (
<main className="min-h-screen flex flex-col items-center justify-center p-8">
<div className="fade-in bg-[var(--ctp-surface0)] p-12 rounded-2xl border-2 border-[var(--ctp-surface2)] max-w-md">
<h1 className="text-4xl font-bold mb-6 text-[var(--ctp-red)] text-center">
Room Closed
</h1>
<a
href="/"
className="btn inline-block w-full text-center px-8 py-4 bg-[var(--ctp-blue)] hover:bg-[var(--ctp-sapphire)] text-[var(--ctp-base)] rounded-lg transition-colors font-bold"
>
Go Home
</a>
</div>
</main>
);
}
if (!roomCode) { if (!roomCode) {
return ( return (
<main className="min-h-screen flex items-center justify-center"> <main className="min-h-screen flex flex-col items-center justify-center p-8">
<div className="fade-in bg-[var(--ctp-surface0)] p-12 rounded-2xl border-2 border-[var(--ctp-surface2)] max-w-lg w-full">
<h1 className="text-5xl font-bold mb-12 text-center text-[var(--ctp-mauve)]">
Create Room
</h1>
<div className="space-y-6 mb-12">
<label className="flex items-start p-6 bg-[var(--ctp-surface1)] rounded-xl cursor-pointer hover:bg-[var(--ctp-surface2)] transition-colors border-2 border-transparent hover:border-[var(--ctp-mauve)]">
<input
type="radio"
value="1"
checked={teamMode === "1"}
onChange={(e) => setTeamMode(e.target.value as "1" | "2")}
className="mr-4 mt-1 w-5 h-5"
/>
<div>
<div className="font-bold text-lg text-[var(--ctp-text)] mb-2">
Individual Scoring
</div>
<div className="text-sm text-[var(--ctp-subtext0)]">
Track each player's performance separately
</div>
</div>
</label>
<label className="flex items-start p-6 bg-[var(--ctp-surface1)] rounded-xl cursor-pointer hover:bg-[var(--ctp-surface2)] transition-colors border-2 border-transparent hover:border-[var(--ctp-blue)]">
<input
type="radio"
value="2"
checked={teamMode === "2"}
onChange={(e) => setTeamMode(e.target.value as "1" | "2")}
className="mr-4 mt-1 w-5 h-5"
/>
<div>
<div className="font-bold text-lg text-[var(--ctp-text)] mb-2">
Team Mode
</div>
<div className="text-sm text-[var(--ctp-subtext0)]">
Two teams competing against each other
</div>
</div>
</label>
</div>
<button <button
onClick={createRoom} onClick={createRoom}
className="px-12 py-6 bg-green-600 text-white rounded-lg text-2xl hover:bg-green-700" className="btn w-full px-12 py-6 bg-[var(--ctp-green)] hover:bg-[var(--ctp-teal)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
> >
Create Room Create Room
</button> </button>
</div>
</main> </main>
); );
} }
const team1 = players.filter((p) => p.teamId === 1);
const team2 = players.filter((p) => p.teamId === 2);
const isIndividual = teamMode === "1";
const phase = gameState?.phase || "lobby";
const question = gameState?.currentQuestion;
const buzzedPlayer = players.find((p) => p.id === gameState?.buzzedPlayer);
return ( return (
<main className="min-h-screen p-8"> <main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto"> <div className="max-w-7xl mx-auto space-y-8">
<h1 className="text-4xl font-bold mb-8">Room Code</h1> {/* Header */}
<div className="text-8xl font-bold text-blue-600 tracking-widest mb-12"> <div className="bg-[var(--ctp-surface0)] p-8 rounded-2xl border-2 border-[var(--ctp-surface2)] flex justify-between items-center">
<div>
<h1 className="text-6xl font-bold text-[var(--ctp-mauve)] mb-3 tracking-wider">
{roomCode} {roomCode}
</h1>
<p className="text-xl text-[var(--ctp-subtext0)]">
{isIndividual ? "Individual Mode" : "Team Mode"} Question{" "}
{gameState?.questionNumber || 0} /{" "}
{gameState?.totalQuestions || 20}
</p>
</div> </div>
<div className="bg-gray-100 p-6 rounded-lg">
<h2 className="text-2xl font-bold mb-4">
Players ({players.length})
</h2>
{players.map((p) => (
<div key={p.id} className="py-3 border-b border-gray-300">
{p.name}
</div>
))}
</div>
<button <button
onClick={closeRoom} onClick={closeRoom}
className="mt-8 px-8 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700" className="btn px-8 py-4 bg-[var(--ctp-red)] hover:bg-[var(--ctp-maroon)] text-[var(--ctp-base)] rounded-xl font-bold text-lg transition-all"
> >
Close Room Close Room
</button> </button>
</div> </div>
{/* Scores */}
<div
className={`grid ${isIndividual ? "grid-cols-1" : "grid-cols-2"} gap-8`}
>
<div className="bg-[var(--ctp-blue)]/10 border-3 border-[var(--ctp-blue)] p-8 rounded-2xl">
<h2 className="text-3xl font-bold mb-6 text-[var(--ctp-blue)]">
{isIndividual ? "Players" : "Team 1"}:{" "}
{team1.reduce((sum, p) => sum + p.score, 0)}
</h2>
<div className="space-y-3">
{team1.length === 0 ? (
<p className="text-[var(--ctp-overlay0)] italic py-4">
No players yet...
</p>
) : (
team1.map((p) => (
<div
key={p.id}
className="flex justify-between items-center bg-[var(--ctp-surface0)] p-5 rounded-xl"
>
<span className="text-[var(--ctp-text)] text-lg">
{p.name}
</span>
<span className="font-bold text-xl text-[var(--ctp-blue)]">
{p.score}
</span>
</div>
))
)}
</div>
</div>
{!isIndividual && (
<div className="bg-[var(--ctp-red)]/10 border-3 border-[var(--ctp-red)] p-8 rounded-2xl">
<h2 className="text-3xl font-bold mb-6 text-[var(--ctp-red)]">
Team 2: {team2.reduce((sum, p) => sum + p.score, 0)}
</h2>
<div className="space-y-3">
{team2.length === 0 ? (
<p className="text-[var(--ctp-overlay0)] italic py-4">
No players yet...
</p>
) : (
team2.map((p) => (
<div
key={p.id}
className="flex justify-between items-center bg-[var(--ctp-surface0)] p-5 rounded-xl"
>
<span className="text-[var(--ctp-text)] text-lg">
{p.name}
</span>
<span className="font-bold text-xl text-[var(--ctp-red)]">
{p.score}
</span>
</div>
))
)}
</div>
</div>
)}
</div>
{/* Game State */}
<div className="bg-[var(--ctp-surface0)] border-2 border-[var(--ctp-surface2)] p-12 rounded-2xl min-h-[500px] flex items-center justify-center">
{phase === "lobby" && (
<div className="fade-in text-center w-full">
<h2 className="text-5xl font-bold mb-8 text-[var(--ctp-yellow)]">
Waiting for Players...
</h2>
<p className="text-2xl mb-12 text-[var(--ctp-subtext1)]">
{players.length} player{players.length !== 1 && "s"} joined
</p>
<button
onClick={() => gameAction("start")}
className="btn px-16 py-8 bg-[var(--ctp-green)] hover:bg-[var(--ctp-teal)] disabled:bg-[var(--ctp-surface1)] disabled:text-[var(--ctp-overlay0)] text-[var(--ctp-base)] rounded-xl text-3xl font-bold transition-all disabled:cursor-not-allowed"
disabled={players.length === 0}
>
Start Game
</button>
</div>
)}
{phase === "intermission" && (
<div className="fade-in text-center w-full space-y-8">
<h2 className="text-5xl font-bold text-[var(--ctp-sky)] mb-12">
Ready for Next Question
</h2>
<div className="flex gap-6 justify-center flex-wrap">
<button
onClick={() => gameAction("load_question")}
className="btn px-12 py-6 bg-[var(--ctp-blue)] hover:bg-[var(--ctp-sapphire)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Load Question
</button>
<button
onClick={() => gameAction("end")}
className="btn px-12 py-6 bg-[var(--ctp-overlay0)] hover:bg-[var(--ctp-overlay1)] text-[var(--ctp-text)] rounded-xl text-2xl font-bold transition-all"
>
End Game
</button>
</div>
</div>
)}
{phase === "tossup_reading" && question && (
<div className="fade-in w-full space-y-8">
<div className="flex justify-between items-start mb-8">
<h2 className="text-5xl font-bold text-[var(--ctp-yellow)]">
TOSSUP
</h2>
<span className="text-2xl px-6 py-3 bg-[var(--ctp-surface1)] rounded-xl text-[var(--ctp-yellow)] font-bold">
{question.category}
</span>
</div>
<p className="text-2xl mb-10 text-[var(--ctp-text)] leading-relaxed">
{question.tossup_question}
</p>
<div className="p-6 bg-[var(--ctp-surface1)] rounded-xl mb-10">
<span className="text-sm text-[var(--ctp-subtext0)] uppercase tracking-wide block mb-3">
Answer:
</span>
<p className="text-2xl font-bold text-[var(--ctp-green)]">
{question.tossup_answer}
</p>
</div>
<button
onClick={() => gameAction("start_timer")}
className="btn px-12 py-6 bg-[var(--ctp-green)] hover:bg-[var(--ctp-teal)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Start Timer (20s)
</button>
</div>
)}
{phase === "tossup_buzzing" && question && (
<div className="fade-in w-full space-y-8">
<div className="flex justify-between items-center mb-10">
<h2 className="text-5xl font-bold text-[var(--ctp-green)]">
BUZZING OPEN
</h2>
<Timer startTime={gameState?.tossupStartTime} duration={20} />
</div>
{!buzzedPlayer ? (
<p className="text-3xl text-[var(--ctp-text)] text-center py-20">
Waiting for buzz...
</p>
) : (
<div className="space-y-8">
<div className="bg-[var(--ctp-blue)]/20 border-3 border-[var(--ctp-blue)] p-8 rounded-2xl">
<p className="text-3xl font-bold text-[var(--ctp-blue)] mb-3">
{buzzedPlayer.name} (Team {buzzedPlayer.teamId}) buzzed!
</p>
{gameState?.tossupInterrupted && (
<p className="text-[var(--ctp-red)] font-bold text-2xl">
INTERRUPTED
</p>
)}
</div>
<div className="p-6 bg-[var(--ctp-surface1)] rounded-xl">
<span className="text-sm text-[var(--ctp-subtext0)] uppercase tracking-wide block mb-3">
Answer:
</span>
<p className="text-2xl font-bold text-[var(--ctp-green)]">
{question.tossup_answer}
</p>
</div>
<div className="grid grid-cols-2 gap-6">
<button
onClick={() => gameAction("tossup_correct")}
className="btn px-10 py-8 bg-[var(--ctp-green)] hover:bg-[var(--ctp-teal)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Correct (+4)
</button>
<button
onClick={() => gameAction("tossup_wrong")}
className="btn px-10 py-8 bg-[var(--ctp-red)] hover:bg-[var(--ctp-maroon)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Wrong{" "}
{gameState?.tossupInterrupted
? "(-4 / +4)"
: "(no penalty)"}
</button>
</div>
</div>
)}
</div>
)}
{phase === "bonus" && question && (
<div className="fade-in w-full space-y-8">
<div className="flex justify-between items-center mb-10">
<h2 className="text-5xl font-bold text-[var(--ctp-mauve)]">
BONUS - Team {gameState?.currentTeam}
</h2>
{gameState?.bonusStartTime && (
<Timer startTime={gameState.bonusStartTime} duration={40} />
)}
</div>
<p className="text-2xl mb-10 text-[var(--ctp-text)] leading-relaxed">
{question.bonus_question}
</p>
<div className="p-6 bg-[var(--ctp-surface1)] rounded-xl mb-10">
<span className="text-sm text-[var(--ctp-subtext0)] uppercase tracking-wide block mb-3">
Answer:
</span>
<p className="text-2xl font-bold text-[var(--ctp-green)]">
{question.bonus_answer}
</p>
</div>
{!gameState?.bonusStartTime ? (
<button
onClick={() => gameAction("bonus_start_timer")}
className="btn w-full px-12 py-6 bg-[var(--ctp-yellow)] hover:bg-[var(--ctp-peach)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all mb-6"
>
Start Timer (40s)
</button>
) : (
<div className="grid grid-cols-2 gap-6">
<button
onClick={() => gameAction("bonus_correct")}
className="btn px-10 py-8 bg-[var(--ctp-green)] hover:bg-[var(--ctp-teal)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Correct (+10)
</button>
<button
onClick={() => gameAction("bonus_wrong")}
className="btn px-10 py-8 bg-[var(--ctp-red)] hover:bg-[var(--ctp-maroon)] text-[var(--ctp-base)] rounded-xl text-2xl font-bold transition-all"
>
Wrong
</button>
</div>
)}
</div>
)}
{phase === "finished" && (
<div className="fade-in text-center w-full">
<h2 className="text-6xl font-bold mb-12 text-[var(--ctp-green)]">
Game Over!
</h2>
<div className="text-4xl mb-8">
{isIndividual ? (
<div className="space-y-6">
<p className="text-[var(--ctp-subtext1)] text-2xl mb-8">
Final Scores:
</p>
{[...team1]
.sort((a, b) => b.score - a.score)
.map((p, i) => (
<div
key={p.id}
className={`text-3xl ${i === 0 ? "text-[var(--ctp-yellow)] text-5xl font-bold" : "text-[var(--ctp-text)]"}`}
>
{i === 0 && "🏆 "}
{p.name}: {p.score}
</div>
))}
</div>
) : (
<div className="space-y-8">
<div className="flex justify-center items-center gap-12 text-4xl">
<span className="text-[var(--ctp-blue)]">
Team 1: {team1.reduce((s, p) => s + p.score, 0)}
</span>
<span className="text-[var(--ctp-subtext0)]">|</span>
<span className="text-[var(--ctp-red)]">
Team 2: {team2.reduce((s, p) => s + p.score, 0)}
</span>
</div>
<p className="text-5xl font-bold text-[var(--ctp-yellow)]">
{team1.reduce((s, p) => s + p.score, 0) >
team2.reduce((s, p) => s + p.score, 0)
? "🏆 Team 1 Wins!"
: team2.reduce((s, p) => s + p.score, 0) >
team1.reduce((s, p) => s + p.score, 0)
? "🏆 Team 2 Wins!"
: "🤝 Tie!"}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
</main> </main>
); );
} }

View File

@@ -1,6 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "SciBowl Quiz", title: "SciBowl Quiz",
description: "Science Bowl quiz game", description: "Science Bowl quiz game",
@@ -13,7 +19,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body className={jetbrainsMono.className}>{children}</body>
</html> </html>
); );
} }

View File

@@ -1,18 +1,102 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import Cookies from "js-cookie";
export default function PlayPage() { export default function PlayPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [teamId, setTeamId] = useState<"1" | "2">("1");
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const [playerId, setPlayerId] = useState(""); const [playerId, setPlayerId] = useState("");
const [roomClosed, setRoomClosed] = useState(false);
const [gameState, setGameState] = useState<any>(null);
const [myScore, setMyScore] = useState(0);
const [canBuzz, setCanBuzz] = useState(false);
const [buzzing, setBuzzing] = useState(false);
useEffect(() => {
const savedCode = Cookies.get("player_room_code");
const savedPlayerId = Cookies.get("player_id");
const savedName = Cookies.get("player_name");
const savedTeamId = Cookies.get("player_team_id");
if (savedCode && savedPlayerId && savedName && savedTeamId) {
setCode(savedCode);
setPlayerId(savedPlayerId);
setName(savedName);
setTeamId(savedTeamId as "1" | "2");
setJoined(true);
}
}, []);
useEffect(() => {
if (!joined || !code) return;
const interval = setInterval(async () => {
const res = await fetch(`/api/room/${code}`);
if (!res.ok) {
setRoomClosed(true);
Cookies.remove("player_room_code");
Cookies.remove("player_id");
Cookies.remove("player_name");
Cookies.remove("player_team_id");
clearInterval(interval);
return;
}
const data = await res.json();
setGameState(data.gameState);
const me = data.players?.find((p: any) => p.id === playerId);
if (me) setMyScore(me.score);
const phase = data.gameState?.phase;
setCanBuzz(
(phase === "tossup_reading" || phase === "tossup_buzzing") &&
!data.gameState?.buzzedPlayer,
);
}, 500);
return () => clearInterval(interval);
}, [joined, code, playerId]);
const buzz = useCallback(async () => {
if (!canBuzz || buzzing) return;
setBuzzing(true);
await fetch(`/api/game/${code}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "buzz", playerId }),
});
setTimeout(() => setBuzzing(false), 1000);
setCanBuzz(false);
}, [canBuzz, buzzing, code, playerId]);
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.code === "Space" && canBuzz && !buzzing) {
e.preventDefault();
buzz();
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, [canBuzz, buzzing, buzz]);
async function join() { async function join() {
const res = await fetch("/api/room/join", { const res = await fetch("/api/room/join", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: code.toUpperCase(), playerName: name }), body: JSON.stringify({
code: code.toUpperCase(),
playerName: name,
teamId,
}),
}); });
const data = await res.json(); const data = await res.json();
@@ -23,7 +107,13 @@ export default function PlayPage() {
} }
setPlayerId(data.playerId); setPlayerId(data.playerId);
setTeamId(data.teamId);
setJoined(true); setJoined(true);
Cookies.set("player_room_code", code.toUpperCase(), { expires: 1 / 12 });
Cookies.set("player_id", data.playerId, { expires: 1 / 12 });
Cookies.set("player_name", name, { expires: 1 / 12 });
Cookies.set("player_team_id", data.teamId.toString(), { expires: 1 / 12 });
} }
async function leave() { async function leave() {
@@ -32,57 +122,416 @@ export default function PlayPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "leave", playerId }), body: JSON.stringify({ action: "leave", playerId }),
}); });
Cookies.remove("player_room_code");
Cookies.remove("player_id");
Cookies.remove("player_name");
Cookies.remove("player_team_id");
window.location.href = "/"; window.location.href = "/";
} }
if (joined) { if (roomClosed) {
return ( return (
<main className="min-h-screen flex flex-col items-center justify-center p-8"> <main className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
<h1 className="text-4xl font-bold mb-4"> You're in!</h1> <div className="particles">
<p className="text-xl mb-8"> {[...Array(20)].map((_, i) => (
Room: <strong>{code}</strong> <div
key={i}
className="particle"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 20}s`,
}}
/>
))}
</div>
<div className="card scale-in p-16 max-w-lg text-center z-10 glow-red">
<div className="text-6xl mb-6">😔</div>
<h1 className="text-5xl font-bold mb-6 text-gradient-mauve">
Room Closed
</h1>
<p className="text-xl text-[var(--ctp-subtext1)] mb-8">
The host has ended this game session
</p> </p>
<p className="text-gray-600 mb-8">Waiting for host to start...</p> <a
<button href="/"
onClick={leave} className="btn inline-block px-10 py-4 bg-gradient-to-r from-[var(--ctp-blue)] to-[var(--ctp-sapphire)] text-[var(--ctp-base)] text-xl"
className="px-8 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
> >
Leave Room Return Home
</button> </a>
</div>
</main> </main>
); );
} }
if (!joined) {
return ( return (
<main className="min-h-screen flex items-center justify-center p-8"> <main className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
<div className="w-full max-w-md"> <div className="particles">
<h1 className="text-4xl font-bold mb-8 text-center">Join a Room</h1> {[...Array(30)].map((_, i) => (
<div
key={i}
className="particle"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 20}s`,
}}
/>
))}
</div>
<div className="card scale-in p-12 max-w-md w-full z-10 glow-mauve gradient-overlay">
<h1 className="text-5xl font-bold mb-10 text-center text-gradient-mauve float">
Join Game
</h1>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-[var(--ctp-subtext1)] mb-2 uppercase tracking-wide">
Your Name
</label>
<input <input
type="text" type="text"
placeholder="Your Name" placeholder="Enter your name..."
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-4" className="w-full p-4 text-lg bg-[var(--ctp-surface1)] border-2 border-[var(--ctp-surface2)] rounded-xl text-[var(--ctp-text)] focus:border-[var(--ctp-mauve)] focus:outline-none transition-all placeholder-[var(--ctp-overlay0)]"
maxLength={20} maxLength={20}
/> />
</div>
<div>
<label className="block text-sm font-bold text-[var(--ctp-subtext1)] mb-2 uppercase tracking-wide">
Room Code
</label>
<input <input
type="text" type="text"
placeholder="Room Code" placeholder="XXXXXX"
value={code} value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())} onChange={(e) => setCode(e.target.value.toUpperCase())}
className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-6 uppercase" className="w-full p-4 text-2xl font-bold bg-[var(--ctp-surface1)] border-2 border-[var(--ctp-surface2)] rounded-xl text-[var(--ctp-text)] focus:border-[var(--ctp-blue)] focus:outline-none transition-all uppercase tracking-widest text-center placeholder-[var(--ctp-overlay0)]"
maxLength={6} maxLength={6}
/> />
</div>
<div className="grid grid-cols-2 gap-4">
<label
className={`card cursor-pointer p-4 transition-all ${
teamId === "1"
? "border-[var(--ctp-blue)] glow-blue"
: "border-[var(--ctp-surface2)]"
}`}
>
<input
type="radio"
value="1"
checked={teamId === "1"}
onChange={(e) => setTeamId(e.target.value as "1" | "2")}
className="hidden"
/>
<div className="text-center">
<div
className={`text-3xl mb-2 ${teamId === "1" ? "scale-110" : ""} transition-transform`}
>
🔵
</div>
<div
className={`font-bold ${teamId === "1" ? "text-[var(--ctp-blue)]" : "text-[var(--ctp-subtext0)]"}`}
>
Team 1
</div>
</div>
</label>
<label
className={`card cursor-pointer p-4 transition-all ${
teamId === "2"
? "border-[var(--ctp-red)] glow-red"
: "border-[var(--ctp-surface2)]"
}`}
>
<input
type="radio"
value="2"
checked={teamId === "2"}
onChange={(e) => setTeamId(e.target.value as "1" | "2")}
className="hidden"
/>
<div className="text-center">
<div
className={`text-3xl mb-2 ${teamId === "2" ? "scale-110" : ""} transition-transform`}
>
🔴
</div>
<div
className={`font-bold ${teamId === "2" ? "text-[var(--ctp-red)]" : "text-[var(--ctp-subtext0)]"}`}
>
Team 2
</div>
</div>
</label>
</div>
<button <button
onClick={join} onClick={join}
className="w-full px-8 py-4 bg-blue-600 text-white rounded-lg text-xl hover:bg-blue-700" disabled={!name.trim() || code.length !== 6}
className="btn w-full px-8 py-5 bg-gradient-to-r from-[var(--ctp-mauve)] to-[var(--ctp-pink)] disabled:from-[var(--ctp-surface1)] disabled:to-[var(--ctp-surface1)] disabled:text-[var(--ctp-overlay0)] text-[var(--ctp-base)] text-xl font-bold disabled:cursor-not-allowed"
> >
Join Join Game
</button> </button>
</div> </div>
</div>
</main>
);
}
const phase = gameState?.phase || "lobby";
const teamColor = teamId === "1" ? "blue" : "red";
return (
<main className="min-h-screen p-4 relative overflow-hidden">
<div className="particles">
{[...Array(25)].map((_, i) => (
<div
key={i}
className="particle"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 20}s`,
}}
/>
))}
</div>
<div className="max-w-4xl mx-auto relative z-10">
{/* Player Header */}
<div
className={`card p-8 mb-6 slide-in-left ${teamId === "1" ? "team1-card" : "team2-card"}`}
>
<div className="flex justify-between items-center">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="text-4xl">{teamId === "1" ? "🔵" : "🔴"}</div>
<h1 className="text-4xl font-bold text-[var(--ctp-text)]">
{name}
</h1>
</div>
<div className="flex items-center gap-4">
<div className="badge bg-[var(--ctp-surface1)] text-[var(--ctp-text)]">
Team {teamId}
</div>
<div
className={`badge bg-gradient-to-r ${
teamId === "1"
? "from-[var(--ctp-blue)] to-[var(--ctp-sapphire)]"
: "from-[var(--ctp-red)] to-[var(--ctp-maroon)]"
} text-[var(--ctp-base)]`}
>
Room: {code}
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm text-[var(--ctp-subtext0)] uppercase tracking-wide mb-1">
Your Score
</div>
<div
className={`text-6xl font-bold ${
teamId === "1"
? "text-gradient-blue"
: "text-[var(--ctp-red)]"
} neon-text`}
>
{myScore}
</div>
</div>
</div>
</div>
{/* Game Status */}
<div className="card p-12 question-card fade-in">
{phase === "lobby" && (
<div className="text-center">
<div className="text-6xl mb-6 float"></div>
<h2 className="text-4xl font-bold mb-4 text-gradient-mauve">
Waiting for Game Start...
</h2>
<p className="text-xl text-[var(--ctp-subtext1)]">
Get ready to buzz in!
</p>
<div className="mt-8">
<div className="skeleton h-8 w-64 mx-auto mb-4" />
<div className="skeleton h-8 w-48 mx-auto" />
</div>
</div>
)}
{phase === "intermission" && (
<div className="text-center">
<div className="text-6xl mb-6 float">📚</div>
<h2 className="text-4xl font-bold mb-4 text-gradient-blue">
Next Question Loading...
</h2>
<p className="text-xl text-[var(--ctp-subtext1)]">
Stay focused and ready!
</p>
</div>
)}
{(phase === "tossup_reading" || phase === "tossup_buzzing") && (
<div className="text-center">
<div className="mb-8">
<div className="badge bg-[var(--ctp-yellow)] text-[var(--ctp-base)] text-lg mb-4">
TOSSUP QUESTION
</div>
<h2 className="text-5xl font-bold text-gradient-green mb-6">
Listen Carefully!
</h2>
</div>
{canBuzz && !buzzing ? (
<div className="scale-in">
<button
onClick={buzz}
className="buzz-button buzz-active w-64 h-64 rounded-full text-white text-4xl font-bold relative z-10 mx-auto block"
>
<div className="relative z-10">
<div className="text-6xl mb-2">🔔</div>
<div>BUZZ!</div>
</div>
</button>
<p className="mt-8 text-2xl text-[var(--ctp-subtext1)] animate-pulse">
Press{" "}
<kbd className="px-3 py-1 bg-[var(--ctp-surface1)] rounded font-mono">
SPACE
</kbd>{" "}
to buzz
</p>
</div>
) : buzzing ? (
<div className="scale-in">
<div className="buzz-button buzz-active w-64 h-64 rounded-full text-white text-4xl font-bold relative z-10 mx-auto flex items-center justify-center">
<div className="text-6xl"></div>
</div>
<p className="mt-8 text-3xl font-bold text-[var(--ctp-green)]">
Buzzed!
</p>
</div>
) : gameState?.buzzedPlayer === playerId ? (
<div className="scale-in">
<div className="w-64 h-64 rounded-full bg-gradient-to-br from-[var(--ctp-yellow)] to-[var(--ctp-peach)] text-[var(--ctp-base)] text-4xl font-bold relative z-10 mx-auto flex items-center justify-center glow-yellow">
<div>
<div className="text-6xl mb-2"></div>
<div>You Buzzed!</div>
</div>
</div>
<p className="mt-8 text-2xl text-[var(--ctp-yellow)] font-bold">
Waiting for moderator judgment...
</p>
</div>
) : gameState?.buzzedPlayer ? (
<div className="scale-in">
<div className="w-64 h-64 rounded-full bg-[var(--ctp-surface1)] border-4 border-[var(--ctp-overlay0)] text-4xl font-bold relative z-10 mx-auto flex items-center justify-center">
<div>
<div className="text-6xl mb-2 opacity-50">🔔</div>
<div className="text-[var(--ctp-overlay1)]">
Someone
<br />
Buzzed
</div>
</div>
</div>
<p className="mt-8 text-xl text-[var(--ctp-subtext0)]">
Waiting for answer...
</p>
</div>
) : (
<div className="scale-in">
<div className="w-64 h-64 rounded-full bg-[var(--ctp-surface1)] border-4 border-dashed border-[var(--ctp-overlay0)] text-4xl font-bold relative z-10 mx-auto flex items-center justify-center buzz-disabled">
<div>
<div className="text-6xl mb-2">👂</div>
<div className="text-[var(--ctp-overlay1)]">
Listen...
</div>
</div>
</div>
<p className="mt-8 text-xl text-[var(--ctp-subtext0)]">
Question being read...
</p>
</div>
)}
</div>
)}
{phase === "bonus" && (
<div className="text-center">
<div className="mb-8">
<div className="badge bg-[var(--ctp-mauve)] text-[var(--ctp-base)] text-lg mb-4">
BONUS QUESTION
</div>
<h2 className="text-5xl font-bold text-gradient-mauve mb-4">
{gameState?.currentTeam === parseInt(teamId)
? "Your Team Bonus!"
: "Other Team Bonus"}
</h2>
</div>
{gameState?.currentTeam === parseInt(teamId) ? (
<div className="scale-in">
<div className="text-7xl mb-6 float">🎯</div>
<p className="text-2xl text-[var(--ctp-text)] mb-6">
Work together with your team!
</p>
<div className="inline-block px-8 py-4 bg-[var(--ctp-mauve)]/20 border-2 border-[var(--ctp-mauve)] rounded-xl">
<p className="text-[var(--ctp-mauve)] font-bold text-lg">
+10 points if correct
</p>
</div>
</div>
) : (
<div className="scale-in">
<div className="text-7xl mb-6 opacity-50"></div>
<p className="text-2xl text-[var(--ctp-subtext0)]">
Waiting for other team...
</p>
</div>
)}
</div>
)}
{phase === "finished" && (
<div className="text-center">
<div className="text-8xl mb-8 float">🏁</div>
<h2 className="text-6xl font-bold mb-6 text-gradient-green">
Game Over!
</h2>
<div className="inline-block px-12 py-8 bg-gradient-to-br from-[var(--ctp-surface0)] to-[var(--ctp-surface1)] rounded-2xl border-4 border-[var(--ctp-mauve)] glow-mauve">
<p className="text-[var(--ctp-subtext1)] text-xl mb-2">
Your Final Score
</p>
<p className="text-7xl font-bold text-gradient-mauve neon-text">
{myScore}
</p>
</div>
</div>
)}
</div>
{/* Leave Button */}
<div className="mt-6 text-center slide-in-right">
<button
onClick={leave}
className="btn px-8 py-4 bg-[var(--ctp-overlay0)] hover:bg-[var(--ctp-overlay1)] text-[var(--ctp-text)] font-bold"
>
Leave Game
</button>
</div>
</div>
</main> </main>
); );
} }

91
src/components/Timer.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
interface TimerProps {
startTime: number | null;
duration: number;
onExpire?: () => void;
showWarning?: boolean;
}
export default function Timer({
startTime,
duration,
onExpire,
showWarning = true,
}: TimerProps) {
const [remaining, setRemaining] = useState(duration);
useEffect(() => {
if (!startTime) {
setRemaining(duration);
return;
}
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const left = Math.max(0, duration - elapsed);
setRemaining(left);
if (left === 0 && onExpire) {
onExpire();
}
}, 100);
return () => clearInterval(interval);
}, [startTime, duration, onExpire]);
const percentage = (remaining / duration) * 100;
const isWarning = remaining <= 10;
const isCritical = remaining <= 5;
return (
<div className="w-full max-w-md scale-in">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2">
<span
className={`status-dot ${isCritical ? "status-error" : isWarning ? "status-waiting" : "status-active"}`}
/>
<span
className={`text-3xl font-bold transition-all duration-300 ${
isCritical
? "text-[var(--ctp-red)] timer-warning neon-text"
: isWarning
? "text-[var(--ctp-yellow)]"
: "text-[var(--ctp-green)]"
}`}
>
{remaining}s
</span>
</div>
<span className="text-sm text-[var(--ctp-subtext0)] font-mono">
/ {duration}s
</span>
</div>
<div className="relative">
<div className="w-full h-4 bg-[var(--ctp-surface0)] rounded-full overflow-hidden shadow-inner">
<div
className={`timer-bar h-full ${
isCritical
? "bg-gradient-to-r from-[var(--ctp-red)] to-[var(--ctp-maroon)] glow-red"
: isWarning
? "bg-gradient-to-r from-[var(--ctp-yellow)] to-[var(--ctp-peach)] glow-yellow"
: "bg-gradient-to-r from-[var(--ctp-green)] to-[var(--ctp-teal)] glow-green"
}`}
style={{ width: `${percentage}%` }}
/>
</div>
{showWarning && isCritical && (
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 scale-in">
<div className="bg-[var(--ctp-red)] text-[var(--ctp-base)] px-4 py-2 rounded-lg font-bold shadow-lg glow-red">
TIME RUNNING OUT!
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { Room } from '@/types'; import { Room, Player, GameState } from '@/types';
// Use global to persist across Next.js hot reloads
declare global { declare global {
var roomStorage: Map<string, Room> | undefined; var roomStorage: Map<string, Room> | undefined;
} }
// Initialize global storage if it doesn't exist
if (!global.roomStorage) { if (!global.roomStorage) {
global.roomStorage = new Map(); global.roomStorage = new Map();
} }
@@ -23,7 +21,21 @@ function generateId(): string {
return Math.random().toString(36).substring(2, 15); return Math.random().toString(36).substring(2, 15);
} }
export function createRoom() { function createInitialGameState(): GameState {
return {
phase: 'lobby',
currentQuestion: null,
currentTeam: null,
buzzedPlayer: null,
tossupStartTime: null,
bonusStartTime: null,
tossupInterrupted: false,
questionNumber: 0,
totalQuestions: 20,
};
}
export function createRoom(teamMode: 1 | 2 = 2) {
const code = generateCode(); const code = generateCode();
const moderatorId = generateId(); const moderatorId = generateId();
@@ -31,37 +43,53 @@ export function createRoom() {
code, code,
moderatorId, moderatorId,
players: new Map(), players: new Map(),
teams: new Map([
[1, { id: 1, score: 0, players: [] }],
[2, { id: 2, score: 0, players: [] }],
]),
gameState: createInitialGameState(),
isActive: true, isActive: true,
createdAt: new Date(), createdAt: new Date(),
teamMode,
}); });
console.log('✅ Created room:', code, 'Total rooms:', rooms.size); console.log('✅ Created room:', code, 'Mode:', teamMode === 1 ? 'Individual' : 'Team');
return { code, moderatorId }; return { code, moderatorId };
} }
export function joinRoom(code: string, name: string) { export function joinRoom(code: string, name: string, teamId: 1 | 2) {
const room = rooms.get(code); const room = rooms.get(code);
console.log('👤 Join attempt for room:', code, 'Room exists:', !!room, 'Total rooms:', rooms.size);
if (!room?.isActive) return null; if (!room?.isActive) return null;
if (room.gameState.phase !== 'lobby') return null;
const playerId = generateId(); const playerId = generateId();
room.players.set(playerId, { id: playerId, name, score: 0 });
console.log('✅ Player joined:', name, 'Total players:', room.players.size); // In individual mode, force everyone to team 1
return { playerId }; const actualTeamId = room.teamMode === 1 ? 1 : teamId;
const player: Player = {
id: playerId,
name,
score: 0,
teamId: actualTeamId,
};
room.players.set(playerId, player);
room.teams.get(actualTeamId)?.players.push(playerId);
console.log('✅ Player joined:', name, 'Team:', actualTeamId, '(Mode:', room.teamMode === 1 ? 'Individual' : 'Team', ')');
return { playerId, teamId: actualTeamId };
} }
export function getRoom(code: string) { export function getRoom(code: string) {
const room = rooms.get(code); return rooms.get(code);
console.log('🔍 Get room:', code, 'Exists:', !!room, 'Total rooms:', rooms.size);
return room;
} }
export function closeRoom(code: string, moderatorId: string) { export function closeRoom(code: string, moderatorId: string) {
const room = rooms.get(code); const room = rooms.get(code);
if (room?.moderatorId === moderatorId) { if (room?.moderatorId === moderatorId) {
room.isActive = false; rooms.delete(code);
console.log('🔒 Room closed:', code); console.log('🔒 Room closed:', code);
return true; return true;
} }
@@ -70,14 +98,193 @@ export function closeRoom(code: string, moderatorId: string) {
export function leaveRoom(code: string, playerId: string) { export function leaveRoom(code: string, playerId: string) {
const room = rooms.get(code); const room = rooms.get(code);
const result = room?.players.delete(playerId) ?? false; if (!room) return false;
if (result) {
console.log('👋 Player left room:', code, 'Remaining players:', room?.players.size); const player = room.players.get(playerId);
if (player) {
const team = room.teams.get(player.teamId);
if (team) {
team.players = team.players.filter(id => id !== playerId);
} }
return result; }
room.players.delete(playerId);
if (room.players.size === 0) {
rooms.delete(code);
console.log('🗑️ Room deleted (empty):', code);
}
return true;
} }
// Auto-cleanup inactive rooms after 2 hours // Game actions
export function startGame(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
room.gameState.phase = 'intermission';
console.log('🎮 Game started:', code);
return true;
}
export function loadQuestion(code: string, moderatorId: string, question: any) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
room.gameState.currentQuestion = question;
room.gameState.questionNumber++;
room.gameState.phase = 'tossup_reading';
room.gameState.tossupStartTime = null;
room.gameState.bonusStartTime = null;
room.gameState.buzzedPlayer = null;
room.gameState.currentTeam = null;
room.gameState.tossupInterrupted = false;
console.log('📖 Question loaded:', room.gameState.questionNumber);
return true;
}
export function startTossupTimer(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
room.gameState.phase = 'tossup_buzzing';
room.gameState.tossupStartTime = Date.now();
console.log('⏱️ Tossup timer started');
return true;
}
export function startBonusTimer(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
if (room.gameState.phase !== 'bonus') return false;
room.gameState.bonusStartTime = Date.now();
console.log('⏱️ Bonus timer started');
return true;
}
export function buzz(code: string, playerId: string) {
const room = rooms.get(code);
// Can buzz during reading OR buzzing phase
if (!room || (room.gameState.phase !== 'tossup_reading' && room.gameState.phase !== 'tossup_buzzing')) {
return false;
}
if (room.gameState.buzzedPlayer) return false; // Already buzzed
const player = room.players.get(playerId);
if (!player) return false;
room.gameState.buzzedPlayer = playerId;
// Interrupted = buzzed BEFORE timer started
room.gameState.tossupInterrupted = room.gameState.phase === 'tossup_reading';
// Move to buzzing phase if we were still reading
if (room.gameState.phase === 'tossup_reading') {
room.gameState.phase = 'tossup_buzzing';
}
console.log('🔔 Buzz from:', player.name, 'Interrupted:', room.gameState.tossupInterrupted);
return true;
}
export function markBonusCorrect(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
if (room.gameState.phase !== 'bonus') return false;
if (!room.gameState.currentTeam) return false;
const team = room.teams.get(room.gameState.currentTeam);
if (team) {
team.score += 10;
// DO NOT add to individual scores - bonus doesn't count for individuals
}
console.log('✅ Bonus correct! +10 to team', room.gameState.currentTeam);
room.gameState.phase = 'intermission';
return true;
}
export function markTossupCorrect(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
if (!room.gameState.buzzedPlayer) return false;
const player = room.players.get(room.gameState.buzzedPlayer);
if (!player) return false;
// Award points
player.score += 4;
const team = room.teams.get(player.teamId);
if (team) team.score += 4;
// Move to bonus (but DON'T start timer yet)
room.gameState.currentTeam = player.teamId;
room.gameState.phase = 'bonus';
room.gameState.bonusStartTime = null; // Timer starts when moderator clicks button
console.log('✅ Tossup correct! Team', player.teamId, 'gets bonus');
return true;
}
export function markTossupWrong(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
if (!room.gameState.buzzedPlayer) return false;
const player = room.players.get(room.gameState.buzzedPlayer);
if (!player) return false;
// If interrupted, penalty to individual player and team
if (room.gameState.tossupInterrupted) {
player.score -= 4; // Individual penalty
const buzzTeam = room.teams.get(player.teamId);
if (buzzTeam) buzzTeam.score -= 4;
const otherTeamId = player.teamId === 1 ? 2 : 1;
const otherTeam = room.teams.get(otherTeamId);
if (otherTeam) otherTeam.score += 4;
console.log('❌ Wrong interrupt! -4 to', player.name, 'and team', player.teamId, '+4 to team', otherTeamId);
} else {
console.log('❌ Wrong answer (no penalty)');
}
room.gameState.phase = 'intermission';
room.gameState.buzzedPlayer = null;
return true;
}
export function markBonusWrong(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
console.log('❌ Bonus wrong');
room.gameState.phase = 'intermission';
return true;
}
export function endGame(code: string, moderatorId: string) {
const room = rooms.get(code);
if (!room || room.moderatorId !== moderatorId) return false;
room.gameState.phase = 'finished';
console.log('🏁 Game finished');
return true;
}
// Cleanup
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; const twoHours = 2 * 60 * 60 * 1000;
@@ -89,4 +296,4 @@ setInterval(() => {
console.log('🗑️ Cleaned up room:', code); console.log('🗑️ Cleaned up room:', code);
} }
} }
}, 300000); // Check every 5 minutes }, 300000);

View File

@@ -16,12 +16,34 @@ export interface Player {
id: string; id: string;
name: string; name: string;
score: number; score: number;
teamId: 1 | 2;
}
export interface Team {
id: 1 | 2;
score: number;
players: string[]; // player IDs
}
export interface GameState {
phase: 'lobby' | 'tossup_reading' | 'tossup_buzzing' | 'bonus' | 'intermission' | 'finished';
currentQuestion: Question | null;
currentTeam: 1 | 2 | null; // Team that buzzed in correctly
buzzedPlayer: string | null; // Player ID who buzzed
tossupStartTime: number | null;
bonusStartTime: number | null;
tossupInterrupted: boolean;
questionNumber: number;
totalQuestions: number;
} }
export interface Room { export interface Room {
code: string; code: string;
moderatorId: string; moderatorId: string;
players: Map<string, Player>; players: Map<string, Player>;
teams: Map<1 | 2, Team>;
gameState: GameState;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode
} }