somehwat AI CSS...but not good at all in teh slightest, logic works
This commit is contained in:
97
src/app/api/game/[code]/route.ts
Normal file
97
src/app/api/game/[code]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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(
|
||||
req: Request,
|
||||
{ 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 room = getRoom(code);
|
||||
const validatedCode = roomCodeSchema.parse(code);
|
||||
|
||||
const room = getRoom(validatedCode);
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
@@ -15,27 +27,76 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
code: room.code,
|
||||
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,
|
||||
});
|
||||
} 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(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
const { code } = await params;
|
||||
const body = await req.json();
|
||||
try {
|
||||
const clientId = getClientIdentifier(req);
|
||||
const rateLimitResult = rateLimit(`action:${clientId}`, 30, 60000);
|
||||
|
||||
if (body.action === 'close') {
|
||||
const success = closeRoom(code, body.moderatorId);
|
||||
return NextResponse.json({ success });
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
|
||||
}
|
||||
|
||||
if (body.action === 'leave') {
|
||||
const success = leaveRoom(code, body.playerId);
|
||||
const { code } = await params;
|
||||
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({ 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createRoom } from '@/lib/rooms';
|
||||
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) {
|
||||
try {
|
||||
// Rate limit: max 5 rooms per IP per hour
|
||||
const clientId = getClientIdentifier(req);
|
||||
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(() => ({}));
|
||||
createRoomSchema.parse(body);
|
||||
const { teamMode } = createRoomSchema.parse(body);
|
||||
|
||||
const { code, moderatorId } = createRoom();
|
||||
const { code, moderatorId } = createRoom(parseInt(teamMode) as 1 | 2);
|
||||
|
||||
return NextResponse.json({
|
||||
code,
|
||||
moderatorId,
|
||||
remaining: rateLimitResult.remaining
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
console.error('Create room error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create room' },
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { joinRoom } from '@/lib/rooms';
|
||||
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) {
|
||||
try {
|
||||
// Rate limit: max 20 join attempts per IP per minute
|
||||
const clientId = getClientIdentifier(req);
|
||||
const rateLimitResult = rateLimit(`join:${clientId}`, 20, 60000);
|
||||
|
||||
@@ -19,26 +24,22 @@ export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Room not found or inactive' },
|
||||
{ error: 'Room not found, inactive, or game in progress' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Join room error:', error);
|
||||
|
||||
if (error.name === 'ZodError') {
|
||||
return NextResponse.json(
|
||||
{ error: error.errors[0].message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -1,3 +1,627 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Player } from "@/types";
|
||||
import Cookies from "js-cookie";
|
||||
import Timer from "@/components/Timer";
|
||||
|
||||
export default function HostPage() {
|
||||
const [roomCode, setRoomCode] = 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() {
|
||||
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();
|
||||
|
||||
setRoomCode(data.code);
|
||||
setModeratorId(data.moderatorId);
|
||||
|
||||
Cookies.set("host_room_code", data.code, { expires: 1 / 12 });
|
||||
Cookies.set("host_moderator_id", data.moderatorId, { expires: 1 / 12 });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,62 +43,442 @@ export default function HostPage() {
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
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();
|
||||
setPlayers(data.players || []);
|
||||
}, 2000);
|
||||
setGameState(data.gameState);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [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() {
|
||||
await fetch(`/api/room/${roomCode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "close", moderatorId }),
|
||||
});
|
||||
|
||||
Cookies.remove("host_room_code");
|
||||
Cookies.remove("host_moderator_id");
|
||||
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) {
|
||||
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
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</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 (
|
||||
<main className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-8">Room Code</h1>
|
||||
<div className="text-8xl font-bold text-blue-600 tracking-widest mb-12">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<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}
|
||||
</h1>
|
||||
<p className="text-xl text-[var(--ctp-subtext0)]">
|
||||
{isIndividual ? "Individual Mode" : "Team Mode"} • Question{" "}
|
||||
{gameState?.questionNumber || 0} /{" "}
|
||||
{gameState?.totalQuestions || 20}
|
||||
</p>
|
||||
</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
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SciBowl Quiz",
|
||||
description: "Science Bowl quiz game",
|
||||
@@ -13,7 +19,7 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body className={jetbrainsMono.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export default function PlayPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [teamId, setTeamId] = useState<"1" | "2">("1");
|
||||
const [joined, setJoined] = useState(false);
|
||||
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() {
|
||||
const res = await fetch("/api/room/join", {
|
||||
method: "POST",
|
||||
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();
|
||||
@@ -23,7 +107,13 @@ export default function PlayPage() {
|
||||
}
|
||||
|
||||
setPlayerId(data.playerId);
|
||||
setTeamId(data.teamId);
|
||||
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() {
|
||||
@@ -32,57 +122,416 @@ export default function PlayPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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 = "/";
|
||||
}
|
||||
|
||||
if (joined) {
|
||||
if (roomClosed) {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
<h1 className="text-4xl font-bold mb-4">✓ You're in!</h1>
|
||||
<p className="text-xl mb-8">
|
||||
Room: <strong>{code}</strong>
|
||||
<main className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
|
||||
<div className="particles">
|
||||
{[...Array(20)].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-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 className="text-gray-600 mb-8">Waiting for host to start...</p>
|
||||
<button
|
||||
onClick={leave}
|
||||
className="px-8 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
|
||||
<a
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
Leave Room
|
||||
</button>
|
||||
Return Home
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-4xl font-bold mb-8 text-center">Join a Room</h1>
|
||||
<main className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
|
||||
<div className="particles">
|
||||
{[...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
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
placeholder="Enter your name..."
|
||||
value={name}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-[var(--ctp-subtext1)] mb-2 uppercase tracking-wide">
|
||||
Room Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Room Code"
|
||||
placeholder="XXXXXX"
|
||||
value={code}
|
||||
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}
|
||||
/>
|
||||
</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
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/Timer.tsx
Normal file
91
src/components/Timer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/lib/rooms.ts
247
src/lib/rooms.ts
@@ -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 {
|
||||
var roomStorage: Map<string, Room> | undefined;
|
||||
}
|
||||
|
||||
// Initialize global storage if it doesn't exist
|
||||
if (!global.roomStorage) {
|
||||
global.roomStorage = new Map();
|
||||
}
|
||||
@@ -23,7 +21,21 @@ function generateId(): string {
|
||||
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 moderatorId = generateId();
|
||||
|
||||
@@ -31,37 +43,53 @@ export function createRoom() {
|
||||
code,
|
||||
moderatorId,
|
||||
players: new Map(),
|
||||
teams: new Map([
|
||||
[1, { id: 1, score: 0, players: [] }],
|
||||
[2, { id: 2, score: 0, players: [] }],
|
||||
]),
|
||||
gameState: createInitialGameState(),
|
||||
isActive: true,
|
||||
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 };
|
||||
}
|
||||
|
||||
export function joinRoom(code: string, name: string) {
|
||||
export function joinRoom(code: string, name: string, teamId: 1 | 2) {
|
||||
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.gameState.phase !== 'lobby') 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 };
|
||||
// In individual mode, force everyone to team 1
|
||||
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) {
|
||||
const room = rooms.get(code);
|
||||
console.log('🔍 Get room:', code, 'Exists:', !!room, 'Total rooms:', rooms.size);
|
||||
return room;
|
||||
return rooms.get(code);
|
||||
}
|
||||
|
||||
export function closeRoom(code: string, moderatorId: string) {
|
||||
const room = rooms.get(code);
|
||||
if (room?.moderatorId === moderatorId) {
|
||||
room.isActive = false;
|
||||
rooms.delete(code);
|
||||
console.log('🔒 Room closed:', code);
|
||||
return true;
|
||||
}
|
||||
@@ -70,14 +98,193 @@ export function closeRoom(code: string, moderatorId: string) {
|
||||
|
||||
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);
|
||||
if (!room) return false;
|
||||
|
||||
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(() => {
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
@@ -89,4 +296,4 @@ setInterval(() => {
|
||||
console.log('🗑️ Cleaned up room:', code);
|
||||
}
|
||||
}
|
||||
}, 300000); // Check every 5 minutes
|
||||
}, 300000);
|
||||
@@ -16,12 +16,34 @@ export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
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 {
|
||||
code: string;
|
||||
moderatorId: string;
|
||||
players: Map<string, Player>;
|
||||
teams: Map<1 | 2, Team>;
|
||||
gameState: GameState;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode
|
||||
}
|
||||
Reference in New Issue
Block a user