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

@@ -0,0 +1,116 @@
import { useEffect, useRef, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useGameStore } from '../../stores/gameStore'
import { useThemeStyles } from '../../themes/ThemeProvider'
const REACTION_DURATION_MS = 2000 // Auto-remove after 2 seconds
export default function ReactionOverlay() {
const { reactions, removeReaction } = useGameStore()
const { config } = useThemeStyles()
const timeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
// Auto-remove reactions after duration
useEffect(() => {
reactions.forEach((reaction) => {
// Only set timeout if we haven't already set one for this reaction
if (!timeoutsRef.current.has(reaction.id)) {
const timeout = setTimeout(() => {
removeReaction(reaction.id)
timeoutsRef.current.delete(reaction.id)
}, REACTION_DURATION_MS)
timeoutsRef.current.set(reaction.id, timeout)
}
})
// Cleanup removed reactions
return () => {
timeoutsRef.current.forEach((timeout) => clearTimeout(timeout))
}
}, [reactions, removeReaction])
// Pre-calculate random positions for each reaction to avoid recalculation on re-render
const reactionPositions = useMemo(() => {
const positions = new Map<string, number>()
reactions.forEach((reaction) => {
if (!positions.has(reaction.id)) {
positions.set(reaction.id, 10 + Math.random() * 80)
}
})
return positions
}, [reactions])
return (
<div className="fixed inset-0 pointer-events-none overflow-hidden z-40">
<AnimatePresence>
{reactions.map((reaction) => {
// Use pre-calculated random horizontal position (10% to 90% of screen width)
const randomX = reactionPositions.get(reaction.id) || 50
return (
<motion.div
key={reaction.id}
initial={{
opacity: 0,
y: '100vh',
x: `${randomX}vw`,
scale: 0.5,
}}
animate={{
opacity: 1,
y: '20vh',
scale: 1,
}}
exit={{
opacity: 0,
y: '-10vh',
scale: 0.8,
}}
transition={{
duration: REACTION_DURATION_MS / 1000,
ease: 'easeOut',
}}
className="absolute flex flex-col items-center"
style={{
transform: `translateX(-50%)`,
}}
>
{/* Emoji */}
<motion.span
className="text-5xl"
animate={{
scale: [1, 1.2, 1],
}}
transition={{
duration: 0.3,
repeat: 2,
repeatType: 'reverse',
}}
>
{reaction.emoji}
</motion.span>
{/* Player name badge */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="mt-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
style={{
backgroundColor:
reaction.team === 'A'
? `${config.colors.primary}CC`
: `${config.colors.secondary}CC`,
color: config.colors.bg,
backdropFilter: 'blur(4px)',
}}
>
{reaction.player_name}
</motion.div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}