Added Chat btwn teams for bonuses
This commit is contained in:
62
src/app/api/chat/[code]/route.ts
Normal file
62
src/app/api/chat/[code]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { sendChatMessage, getChatMessages } from '@/lib/rooms';
|
||||||
|
import { z, ZodError } from 'zod';
|
||||||
|
|
||||||
|
const chatMessageSchema = z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
message: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatQuerySchema = z.object({
|
||||||
|
teamId: z.coerce.number().int().min(1).max(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ code: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { code } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { playerId, message } = chatMessageSchema.parse(body);
|
||||||
|
|
||||||
|
const success = sendChatMessage(code, playerId, message);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({ error: 'Cannot send message' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ code: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { code } = await params;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const teamId = url.searchParams.get('teamId');
|
||||||
|
|
||||||
|
const { teamId: validatedTeamId } = chatQuerySchema.parse({ teamId });
|
||||||
|
|
||||||
|
const messages = getChatMessages(code, validatedTeamId);
|
||||||
|
|
||||||
|
return NextResponse.json({ messages });
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,4 +100,4 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}2
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import Chat from "@/components/Chat";
|
||||||
|
|
||||||
export default function PlayPage() {
|
export default function PlayPage() {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -796,6 +797,16 @@ export default function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Team Chat - only during bonus for your team */}
|
||||||
|
{phase === "bonus" && gameState?.currentTeam === parseInt(teamId) && (
|
||||||
|
<Chat
|
||||||
|
roomCode={code}
|
||||||
|
playerId={playerId}
|
||||||
|
teamId={parseInt(teamId)}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Leave Button */}
|
{/* Leave Button */}
|
||||||
<div style={{ marginTop: "24px", textAlign: "center" }}>
|
<div style={{ marginTop: "24px", textAlign: "center" }}>
|
||||||
|
|||||||
169
src/components/Chat.tsx
Normal file
169
src/components/Chat.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { ChatMessage } from "@/types";
|
||||||
|
|
||||||
|
interface ChatProps {
|
||||||
|
roomCode: string;
|
||||||
|
playerId: string;
|
||||||
|
teamId: number;
|
||||||
|
isActive: boolean; // Only active during bonus for your team
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat({
|
||||||
|
roomCode,
|
||||||
|
playerId,
|
||||||
|
teamId,
|
||||||
|
isActive,
|
||||||
|
}: ChatProps) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const res = await fetch(`/api/chat/${roomCode}?teamId=${teamId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessages(data.messages || []);
|
||||||
|
}
|
||||||
|
}, 500); // Poll every 500ms
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [roomCode, teamId, isActive]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
async function sendMessage(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || !isActive) return;
|
||||||
|
|
||||||
|
await fetch(`/api/chat/${roomCode}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ playerId, message: input }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isActive) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
marginTop: "16px",
|
||||||
|
maxHeight: "300px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
paddingBottom: "12px",
|
||||||
|
borderBottom: "2px solid var(--ctp-surface2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "1.25rem" }}>💬</span>
|
||||||
|
<h3
|
||||||
|
className="text-text"
|
||||||
|
style={{ fontSize: "1rem", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
Team Chat
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
className="bg-surface1"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
minHeight: "150px",
|
||||||
|
maxHeight: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-subtext0"
|
||||||
|
style={{ fontSize: "0.875rem", fontStyle: "italic" }}
|
||||||
|
>
|
||||||
|
No messages yet...
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
msg.playerId === playerId ? "text-mauve" : "text-blue"
|
||||||
|
}
|
||||||
|
style={{ fontSize: "0.875rem", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
{msg.playerName}:
|
||||||
|
</span>
|
||||||
|
<span className="text-text" style={{ fontSize: "0.875rem" }}>
|
||||||
|
{msg.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form onSubmit={sendMessage} style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
maxLength={200}
|
||||||
|
className="input"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
background: "var(--ctp-surface1)",
|
||||||
|
border: "2px solid var(--ctp-surface2)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="btn btn-primary text-base"
|
||||||
|
style={{ padding: "10px 20px", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/lib/rooms.ts
121
src/lib/rooms.ts
@@ -1,5 +1,4 @@
|
|||||||
import { Room, Player, GameState } from '@/types';
|
import type { Room, Player, GameState, ChatMessage } from '@/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var roomStorage: Map<string, Room> | undefined;
|
var roomStorage: Map<string, Room> | undefined;
|
||||||
}
|
}
|
||||||
@@ -34,7 +33,6 @@ function createInitialGameState(): GameState {
|
|||||||
totalQuestions: 20,
|
totalQuestions: 20,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRoom(teamMode: 1 | 2 = 2) {
|
export function createRoom(teamMode: 1 | 2 = 2) {
|
||||||
const code = generateCode();
|
const code = generateCode();
|
||||||
const moderatorId = generateId();
|
const moderatorId = generateId();
|
||||||
@@ -51,12 +49,15 @@ export function createRoom(teamMode: 1 | 2 = 2) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
teamMode,
|
teamMode,
|
||||||
|
chatMessages: new Map([ // ADD THIS
|
||||||
|
[1, []],
|
||||||
|
[2, []],
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Created room:', code, 'Mode:', teamMode === 1 ? 'Individual' : 'Team');
|
console.log('✅ Created room:', code, 'Mode:', teamMode === 1 ? 'Individual' : 'Team');
|
||||||
return { code, moderatorId };
|
return { code, moderatorId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinRoom(code: string, name: string, teamId: 1 | 2) {
|
export function joinRoom(code: string, name: string, teamId: 1 | 2) {
|
||||||
const room = rooms.get(code);
|
const room = rooms.get(code);
|
||||||
|
|
||||||
@@ -272,6 +273,68 @@ export function markTossupWrong(code: string, moderatorId: string) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z, ZodError } from 'zod';
|
||||||
|
|
||||||
|
const chatMessageSchema = z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
message: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatQuerySchema = z.object({
|
||||||
|
teamId: z.coerce.number().int().min(1).max(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ code: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { code } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { playerId, message } = chatMessageSchema.parse(body);
|
||||||
|
|
||||||
|
const success = sendChatMessage(code, playerId, message);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({ error: 'Cannot send message' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ code: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { code } = await params;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const teamId = url.searchParams.get('teamId');
|
||||||
|
|
||||||
|
const { teamId: validatedTeamId } = chatQuerySchema.parse({ teamId });
|
||||||
|
|
||||||
|
const messages = getChatMessages(code, validatedTeamId);
|
||||||
|
|
||||||
|
return NextResponse.json({ messages });
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json({ error: error.issues[0].message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function moveOn(code: string, moderatorId: string) {
|
export function moveOn(code: string, moderatorId: string) {
|
||||||
const room = rooms.get(code);
|
const room = rooms.get(code);
|
||||||
if (!room || room.moderatorId !== moderatorId) return false;
|
if (!room || room.moderatorId !== moderatorId) return false;
|
||||||
@@ -319,6 +382,56 @@ export function endGame(code: string, moderatorId: string) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat functions
|
||||||
|
export function sendChatMessage(code: string, playerId: string, message: string) {
|
||||||
|
const room = rooms.get(code);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
const player = room.players.get(playerId);
|
||||||
|
if (!player) return false;
|
||||||
|
|
||||||
|
// Only allow chat during bonus phase
|
||||||
|
if (room.gameState.phase !== 'bonus') return false;
|
||||||
|
|
||||||
|
// Only allow chat for the team that has the bonus
|
||||||
|
if (room.gameState.currentTeam !== player.teamId) return false;
|
||||||
|
|
||||||
|
const teamMessages = room.chatMessages.get(player.teamId) || [];
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: generateId(),
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.name,
|
||||||
|
message: message.trim().slice(0, 200), // Max 200 chars
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
teamMessages.push(chatMessage);
|
||||||
|
|
||||||
|
// Keep only last 50 messages per team
|
||||||
|
if (teamMessages.length > 50) {
|
||||||
|
teamMessages.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
room.chatMessages.set(player.teamId, teamMessages);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatMessages(code: string, teamId: number) {
|
||||||
|
const room = rooms.get(code);
|
||||||
|
if (!room) return [];
|
||||||
|
|
||||||
|
return room.chatMessages.get(teamId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTeamChat(code: string, teamId: number) {
|
||||||
|
const room = rooms.get(code);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
room.chatMessages.set(teamId, []);
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -41,9 +41,19 @@ export interface Room {
|
|||||||
code: string;
|
code: string;
|
||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
players: Map<string, Player>;
|
players: Map<string, Player>;
|
||||||
teams: Map<1 | 2, Team>;
|
teams: Map<number, Team>;
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode
|
teamMode: 1 | 2;
|
||||||
}
|
chatMessages: Map<number, ChatMessage[]>; // teamId -> messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
playerId: string;
|
||||||
|
playerName: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user