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:
116
frontend/src/components/chat/ReactionOverlay.tsx
Normal file
116
frontend/src/components/chat/ReactionOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user