feat(phase6): Add sounds, team chat, reactions, monitor, settings, and CSV import/export

Sound System:
- Add soundStore with volume/mute persistence
- Add useSound hook with Web Audio API fallback
- Add SoundControl component for in-game volume adjustment
- Play sounds for correct/incorrect, steal, timer, victory/defeat

Team Chat:
- Add TeamChat component with collapsible panel
- Add team_message WebSocket event (team-only visibility)
- Store up to 50 messages per session

Emoji Reactions:
- Add EmojiReactions bar with 8 emojis
- Add ReactionOverlay with floating animations (Framer Motion)
- Add rate limiting (1 reaction per 3 seconds)
- Broadcast reactions to all players in room

Admin Monitor:
- Add Monitor page showing active rooms from Redis
- Display player counts, team composition, status
- Add ability to close problematic rooms

Admin Settings:
- Add Settings page for game configuration
- Configure points/times by difficulty, steal penalty, max players
- Store config in JSON file with service helpers

CSV Import/Export:
- Add export endpoint with optional filters
- Add import endpoint with validation and error reporting
- Add UI buttons and import result modal in Questions page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 08:58:33 +00:00
parent 90fa220890
commit 720432702f
23 changed files with 2753 additions and 51 deletions

View File

@@ -1,15 +1,31 @@
import { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import { useGameStore } from '../stores/gameStore'
import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore'
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
import type { Reaction } from '../stores/gameStore'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
// Team message type
export interface TeamMessage {
player_name: string
team: 'A' | 'B'
message: string
timestamp: string
}
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } =
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
useGameStore()
// Initialize sound player with current theme
const currentTheme = useThemeStore.getState().currentTheme
soundPlayer.loadTheme(currentTheme)
useEffect(() => {
// Create socket connection
socketRef.current = io(SOCKET_URL, {
@@ -61,14 +77,27 @@ export function useSocket() {
socket.on('answer_result', (data: AnswerResult) => {
setRoom(data.room)
// Play appropriate sound based on answer result
const volume = useSoundStore.getState().volume
if (data.valid) {
soundPlayer.play('correct', volume)
} else {
soundPlayer.play('incorrect', volume)
}
if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
}
})
socket.on('steal_attempted', (data: { room: GameRoom }) => {
socket.on('steal_attempted', (data: { room: GameRoom; success?: boolean }) => {
setRoom(data.room)
setShowStealPrompt(false)
// Play steal sound when a steal is attempted
const volume = useSoundStore.getState().volume
soundPlayer.play('steal', volume)
})
socket.on('steal_passed', (data: { room: GameRoom }) => {
@@ -91,8 +120,23 @@ export function useSocket() {
})
socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => {
// Handle emoji reaction display
console.log(`${data.player_name} reacted with ${data.emoji}`)
// Legacy handler - redirect to new reaction system
addReaction({
player_name: data.player_name,
team: data.team as 'A' | 'B',
emoji: data.emoji,
timestamp: new Date().toISOString(),
})
})
socket.on('receive_reaction', (data: Omit<Reaction, 'id'>) => {
// Add reaction to the store for display in overlay
addReaction(data)
})
// Team chat events
socket.on('receive_team_message', (data: TeamMessage) => {
addTeamMessage(data)
})
socket.on('game_finished', (data: {
@@ -107,6 +151,18 @@ export function useSocket() {
}>
}) => {
setRoom(data.room)
// Determine if current player is on the winning team
const currentPlayerName = useGameStore.getState().playerName
const myTeam = data.room.teams.A.find(p => p.name === currentPlayerName) ? 'A' : 'B'
const volume = useSoundStore.getState().volume
if (data.winner === myTeam) {
soundPlayer.play('victory', volume)
} else if (data.winner !== null) {
soundPlayer.play('defeat', volume)
}
setGameResult({
winner: data.winner,
finalScores: data.final_scores,
@@ -122,7 +178,7 @@ export function useSocket() {
return () => {
socket.disconnect()
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult])
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
// Socket methods
const createRoom = useCallback((playerName: string) => {
@@ -179,6 +235,26 @@ export function useSocket() {
socketRef.current?.emit('emoji_reaction', { emoji })
}, [])
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
socketRef.current?.emit('send_reaction', {
emoji,
room_code: roomCode,
player_name: playerName,
})
}, [])
const sendTeamMessage = useCallback(
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
socketRef.current?.emit('team_message', {
room_code: roomCode,
team,
player_name: playerName,
message,
})
},
[]
)
const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {})
}, [])
@@ -194,6 +270,8 @@ export function useSocket() {
stealDecision,
sendChatMessage,
sendEmojiReaction,
sendReaction,
sendTeamMessage,
notifyTimerExpired,
}
}