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,67 @@
# Sound Assets for WebTriviasMulti
This directory contains theme-specific sound effects for the trivia game.
## Directory Structure
```
sounds/
drrr/ # DRRR (Dollars) theme - cyberpunk/urban style
retro/ # Retro Arcade theme - 8-bit style sounds
minimal/ # Minimal theme - subtle, clean sounds
rgb/ # Gaming RGB theme - electronic/synthwave
anime/ # Anime 90s theme - kawaii/bright sounds
```
## Required Sound Files
Each theme directory should contain the following MP3 files:
| File | Purpose | Duration | Notes |
|------|---------|----------|-------|
| `correct.mp3` | Played when answer is correct | ~0.5s | Positive, rewarding tone |
| `incorrect.mp3` | Played when answer is wrong | ~0.5s | Negative but not harsh |
| `steal.mp3` | Played when steal opportunity arises | ~0.5s | Tense, exciting |
| `tick.mp3` | Timer countdown tick | ~0.1s | Subtle, not annoying |
| `urgent.mp3` | Timer warning (last 5 seconds) | ~0.2s | More urgent than tick |
| `victory.mp3` | Game win | ~1-2s | Celebratory fanfare |
| `defeat.mp3` | Game loss | ~1-2s | Sympathetic but not depressing |
| `select.mp3` | Question selection | ~0.2s | Subtle click/select |
## Fallback System
If sound files are not available, the application will use Web Audio API generated tones as fallback. These provide basic audio feedback while allowing the game to function without external audio files.
## Recommended Sources for Free Sounds
- [Freesound.org](https://freesound.org) - Creative Commons sounds
- [Mixkit](https://mixkit.co/free-sound-effects/) - Free sound effects
- [Pixabay](https://pixabay.com/sound-effects/) - Royalty-free sounds
- [OpenGameArt](https://opengameart.org) - Game-specific sounds
## Theme Sound Guidelines
### DRRR (Dollars)
- Cyberpunk/urban aesthetic
- Digital, glitchy sounds
- City ambiance influence
### Retro Arcade
- 8-bit chiptune style
- Classic arcade game sounds
- Nostalgic NES/SNES era
### Minimal
- Clean, subtle sounds
- Modern UI feedback tones
- Non-intrusive clicks
### Gaming RGB
- Electronic/synthwave
- Bass-heavy, modern
- Esports broadcast style
### Anime 90s
- Kawaii, bright sounds
- J-pop influenced
- Sparkle and shine effects

View File

@@ -4,7 +4,7 @@ import Lobby from './pages/Lobby'
import Game from './pages/Game'
import Results from './pages/Results'
import Replay from './pages/Replay'
import { AdminLayout, Login, Dashboard, Questions, Calendar } from './pages/admin'
import { AdminLayout, Login, Dashboard, Questions, Calendar, Settings, Monitor } from './pages/admin'
function App() {
return (
@@ -24,6 +24,8 @@ function App() {
<Route path="dashboard" element={<Dashboard />} />
<Route path="questions" element={<Questions />} />
<Route path="calendar" element={<Calendar />} />
<Route path="monitor" element={<Monitor />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</div>

View File

@@ -0,0 +1,89 @@
import { useState, useCallback, useEffect } from 'react'
import { motion } from 'framer-motion'
import { useSocket } from '../../hooks/useSocket'
import { useGameStore } from '../../stores/gameStore'
import { useThemeStyles } from '../../themes/ThemeProvider'
const EMOJIS = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
const COOLDOWN_MS = 3000 // 3 seconds cooldown
export default function EmojiReactions() {
const { sendReaction } = useSocket()
const { room, playerName } = useGameStore()
const { config } = useThemeStyles()
const [isDisabled, setIsDisabled] = useState(false)
const [cooldownRemaining, setCooldownRemaining] = useState(0)
// Handle cooldown timer display
useEffect(() => {
if (!isDisabled) return
const interval = setInterval(() => {
setCooldownRemaining((prev) => {
if (prev <= 100) {
setIsDisabled(false)
return 0
}
return prev - 100
})
}, 100)
return () => clearInterval(interval)
}, [isDisabled])
const handleEmojiClick = useCallback(
(emoji: string) => {
if (isDisabled || !room?.code) return
// Send the reaction via socket
sendReaction(emoji, room.code, playerName)
// Enable cooldown
setIsDisabled(true)
setCooldownRemaining(COOLDOWN_MS)
},
[isDisabled, room?.code, playerName, sendReaction]
)
if (!room) return null
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 px-4 py-2 rounded-full"
style={{
backgroundColor: `${config.colors.bg}CC`,
backdropFilter: 'blur(8px)',
border: `1px solid ${config.colors.primary}40`,
}}
>
{EMOJIS.map((emoji) => (
<motion.button
key={emoji}
onClick={() => handleEmojiClick(emoji)}
disabled={isDisabled}
whileHover={!isDisabled ? { scale: 1.2 } : {}}
whileTap={!isDisabled ? { scale: 0.9 } : {}}
className={`text-2xl p-2 rounded-lg transition-all ${
isDisabled ? 'opacity-40 cursor-not-allowed grayscale' : 'cursor-pointer hover:bg-white/10'
}`}
title={isDisabled ? `Espera ${Math.ceil(cooldownRemaining / 1000)}s` : emoji}
>
{emoji}
</motion.button>
))}
{/* Cooldown indicator */}
{isDisabled && (
<motion.div
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: COOLDOWN_MS / 1000, ease: 'linear' }}
className="absolute bottom-0 left-0 h-1 rounded-full"
style={{ backgroundColor: config.colors.primary }}
/>
)}
</motion.div>
)
}

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>
)
}

View File

@@ -0,0 +1,341 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useThemeStyles } from '../../themes/ThemeProvider'
interface TeamMessage {
player_name: string
team: 'A' | 'B'
message: string
timestamp: string
}
interface TeamChatProps {
roomCode: string
playerName: string
team: 'A' | 'B'
sendTeamMessage: (message: string) => void
teamMessages: TeamMessage[]
}
const MAX_MESSAGES = 50
export default function TeamChat({
roomCode,
playerName,
team,
sendTeamMessage,
teamMessages,
}: TeamChatProps) {
const { config } = useThemeStyles()
const [isOpen, setIsOpen] = useState(false)
const [inputMessage, setInputMessage] = useState('')
const [localMessages, setLocalMessages] = useState<TeamMessage[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Sync external messages with local state, limit to MAX_MESSAGES
useEffect(() => {
setLocalMessages((prev) => {
const combined = [...prev]
teamMessages.forEach((msg) => {
// Avoid duplicates by checking timestamp and player
const exists = combined.some(
(m) =>
m.timestamp === msg.timestamp &&
m.player_name === msg.player_name &&
m.message === msg.message
)
if (!exists) {
combined.push(msg)
}
})
// Keep only the last MAX_MESSAGES
return combined.slice(-MAX_MESSAGES)
})
}, [teamMessages])
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (isOpen && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [localMessages, isOpen])
// Focus input when panel opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isOpen])
const handleSendMessage = useCallback(() => {
const trimmedMessage = inputMessage.trim()
if (!trimmedMessage) return
sendTeamMessage(trimmedMessage)
setInputMessage('')
}, [inputMessage, sendTeamMessage])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
},
[handleSendMessage]
)
const formatTimestamp = (timestamp: string) => {
try {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
const teamColor = team === 'A' ? config.colors.primary : config.colors.secondary
return (
<>
{/* Toggle Button */}
<motion.button
onClick={() => setIsOpen(!isOpen)}
className="fixed right-4 top-1/2 -translate-y-1/2 z-40 p-3 rounded-l-lg shadow-lg"
style={{
backgroundColor: teamColor,
color: config.colors.bg,
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={isOpen ? 'Cerrar chat de equipo' : 'Abrir chat de equipo'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{localMessages.length > 0 && !isOpen && (
<span
className="absolute -top-1 -left-1 w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
style={{
backgroundColor: config.colors.accent,
color: config.colors.bg,
}}
>
{localMessages.length > 9 ? '9+' : localMessages.length}
</span>
)}
</motion.button>
{/* Chat Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed right-0 top-0 h-full w-80 z-50 flex flex-col shadow-2xl"
style={{
backgroundColor: config.colors.bg,
borderLeft: `3px solid ${teamColor}`,
}}
>
{/* Header */}
<div
className="flex items-center justify-between p-4"
style={{
backgroundColor: teamColor + '20',
borderBottom: `2px solid ${teamColor}`,
}}
>
<div className="flex items-center gap-2">
<span className="text-lg font-bold" style={{ color: teamColor }}>
Chat Equipo {team}
</span>
<span
className="text-xs px-2 py-1 rounded"
style={{
backgroundColor: teamColor,
color: config.colors.bg,
}}
>
{roomCode}
</span>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded hover:opacity-80 transition-opacity"
style={{ color: config.colors.text }}
aria-label="Cerrar chat"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Messages List */}
<div
className="flex-1 overflow-y-auto p-3 space-y-2"
style={{ backgroundColor: config.colors.bg }}
>
{localMessages.length === 0 ? (
<div
className="text-center py-8 text-sm"
style={{ color: config.colors.textMuted }}
>
No hay mensajes aun.
<br />
Escribe algo para tu equipo.
</div>
) : (
localMessages.map((msg, index) => (
<motion.div
key={`${msg.timestamp}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-2 rounded-lg ${
msg.player_name === playerName ? 'ml-4' : 'mr-4'
}`}
style={{
backgroundColor:
msg.player_name === playerName
? teamColor + '30'
: config.colors.bg === '#000000'
? '#1a1a1a'
: '#f0f0f0',
border: `1px solid ${
msg.player_name === playerName ? teamColor : 'transparent'
}`,
}}
>
<div className="flex items-center justify-between mb-1">
<span
className="text-xs font-semibold"
style={{
color:
msg.player_name === playerName
? teamColor
: config.colors.text,
}}
>
{msg.player_name === playerName ? 'Tu' : msg.player_name}
</span>
<span
className="text-xs"
style={{ color: config.colors.textMuted }}
>
{formatTimestamp(msg.timestamp)}
</span>
</div>
<p
className="text-sm break-words"
style={{ color: config.colors.text }}
>
{msg.message}
</p>
</motion.div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div
className="p-3"
style={{
backgroundColor: teamColor + '10',
borderTop: `2px solid ${teamColor}`,
}}
>
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un mensaje..."
maxLength={500}
className="flex-1 px-3 py-2 rounded-lg text-sm outline-none transition-all"
style={{
backgroundColor: config.colors.bg,
color: config.colors.text,
border: `2px solid ${teamColor}50`,
}}
/>
<button
onClick={handleSendMessage}
disabled={!inputMessage.trim()}
className="px-4 py-2 rounded-lg font-semibold transition-all hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
backgroundColor: teamColor,
color: config.colors.bg,
}}
aria-label="Enviar mensaje"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
</div>
<div
className="text-xs mt-1 text-right"
style={{ color: config.colors.textMuted }}
>
{inputMessage.length}/500
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Backdrop overlay when chat is open */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/30 z-40 md:hidden"
/>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,199 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useSoundStore } from '../../stores/soundStore'
import { useSound } from '../../hooks/useSound'
import { useThemeStyles } from '../../themes/ThemeProvider'
interface SoundControlProps {
/** Compact mode shows just the icon, expanded shows slider */
compact?: boolean
/** Position for the popup menu when in compact mode */
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
/** Custom class name */
className?: string
}
export default function SoundControl({
compact = false,
popupPosition = 'top',
className = '',
}: SoundControlProps) {
const { volume, muted, setVolume, toggleMute } = useSoundStore()
const { play } = useSound()
const { config } = useThemeStyles()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Close popup when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleVolumeChange = (newVolume: number) => {
setVolume(newVolume)
// Play a test sound when adjusting volume
if (!muted && newVolume > 0) {
play('select')
}
}
const handleToggleMute = () => {
toggleMute()
// Play a sound when unmuting
if (muted) {
setTimeout(() => play('select'), 50)
}
}
const getVolumeIcon = () => {
if (muted || volume === 0) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM17.78 9.22a.75.75 0 10-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 001.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 101.06-1.06L20.56 12l1.72-1.72a.75.75 0 00-1.06-1.06l-1.72 1.72-1.72-1.72z" />
</svg>
)
}
if (volume < 0.33) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
</svg>
)
}
if (volume < 0.66) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
<path d="M17.995 10.404a.75.75 0 011.06 0 3 3 0 010 4.243.75.75 0 01-1.06-1.061 1.5 1.5 0 000-2.122.75.75 0 010-1.06z" />
</svg>
)
}
const getPopupStyles = () => {
switch (popupPosition) {
case 'top':
return 'bottom-full mb-2 left-1/2 -translate-x-1/2'
case 'bottom':
return 'top-full mt-2 left-1/2 -translate-x-1/2'
case 'left':
return 'right-full mr-2 top-1/2 -translate-y-1/2'
case 'right':
return 'left-full ml-2 top-1/2 -translate-y-1/2'
}
}
if (!compact) {
return (
<div
className={`flex items-center gap-3 p-3 rounded-lg ${className}`}
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}30`,
}}
>
<button
onClick={handleToggleMute}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{ color: config.colors.primary }}
title={muted ? 'Activar sonido' : 'Silenciar'}
>
{getVolumeIcon()}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-24 h-2 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
}}
title={`Volumen: ${Math.round((muted ? 0 : volume) * 100)}%`}
/>
<span
className="text-sm w-10 text-center"
style={{ color: config.colors.textMuted }}
>
{Math.round((muted ? 0 : volume) * 100)}%
</span>
</div>
)
}
return (
<div ref={containerRef} className={`relative ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{
color: config.colors.primary,
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}30`,
}}
title="Control de sonido"
>
{getVolumeIcon()}
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className={`absolute ${getPopupStyles()} p-3 rounded-lg shadow-lg z-50`}
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}`,
}}
>
<div className="flex flex-col items-center gap-2 min-w-[120px]">
<button
onClick={handleToggleMute}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{ color: config.colors.primary }}
>
{getVolumeIcon()}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
}}
/>
<span
className="text-xs"
style={{ color: config.colors.textMuted }}
>
{Math.round((muted ? 0 : volume) * 100)}%
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default as SoundControl } from './SoundControl'

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,
}
}

View File

@@ -1,22 +1,63 @@
import { useCallback, useEffect, useRef } from 'react'
import { Howl } from 'howler'
import { useSoundStore, soundPaths } from '../stores/soundStore'
import { useSoundStore, soundPaths, fallbackSoundConfigs, type SoundEffect } from '../stores/soundStore'
import { useThemeStore } from '../stores/themeStore'
type SoundEffect =
| 'correct'
| 'incorrect'
| 'steal'
| 'timer_tick'
| 'timer_urgent'
| 'victory'
| 'defeat'
| 'select'
// Re-export SoundEffect type for convenience
export type { SoundEffect }
// Audio context for fallback sounds
let audioContext: AudioContext | null = null
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
}
return audioContext
}
// Play a fallback sound using Web Audio API
function playFallbackSound(effect: SoundEffect, volume: number): void {
try {
const ctx = getAudioContext()
const config = fallbackSoundConfigs[effect]
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.type = config.type
oscillator.frequency.setValueAtTime(config.frequency, ctx.currentTime)
// Victory and defeat have melody-like patterns
if (effect === 'victory') {
oscillator.frequency.setValueAtTime(523, ctx.currentTime)
oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.15)
oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.3)
} else if (effect === 'defeat') {
oscillator.frequency.setValueAtTime(392, ctx.currentTime)
oscillator.frequency.setValueAtTime(330, ctx.currentTime + 0.15)
oscillator.frequency.setValueAtTime(262, ctx.currentTime + 0.3)
}
gainNode.gain.setValueAtTime(volume * 0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + config.duration)
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + config.duration)
} catch (error) {
console.warn('Failed to play fallback sound:', error)
}
}
export function useSound() {
const { volume, muted } = useSoundStore()
const { volume, muted, setSoundsLoaded, setCurrentLoadedTheme } = useSoundStore()
const { currentTheme } = useThemeStore()
const soundsRef = useRef<Map<string, Howl>>(new Map())
const loadedCountRef = useRef(0)
const failedSoundsRef = useRef<Set<string>>(new Set())
// Preload sounds for current theme
useEffect(() => {
@@ -26,15 +67,36 @@ export function useSound() {
// Clear old sounds
soundsRef.current.forEach((sound) => sound.unload())
soundsRef.current.clear()
failedSoundsRef.current.clear()
loadedCountRef.current = 0
setSoundsLoaded(false)
setCurrentLoadedTheme(null)
const soundEntries = Object.entries(themeSounds)
const totalSounds = soundEntries.length
// Load new sounds
Object.entries(themeSounds).forEach(([key, path]) => {
soundEntries.forEach(([key, path]) => {
const sound = new Howl({
src: [path],
volume: volume,
preload: true,
onloaderror: () => {
console.warn(`Failed to load sound: ${path}`)
onload: () => {
loadedCountRef.current++
if (loadedCountRef.current >= totalSounds - failedSoundsRef.current.size) {
setSoundsLoaded(true)
setCurrentLoadedTheme(currentTheme)
}
},
onloaderror: (_id, error) => {
console.warn(`Failed to load sound: ${path}`, error)
failedSoundsRef.current.add(key)
loadedCountRef.current++
// Still mark as loaded even with failures (will use fallback)
if (loadedCountRef.current >= totalSounds) {
setSoundsLoaded(true)
setCurrentLoadedTheme(currentTheme)
}
},
})
soundsRef.current.set(key, sound)
@@ -43,7 +105,7 @@ export function useSound() {
return () => {
soundsRef.current.forEach((sound) => sound.unload())
}
}, [currentTheme])
}, [currentTheme, setSoundsLoaded, setCurrentLoadedTheme])
// Update volume when it changes
useEffect(() => {
@@ -57,11 +119,16 @@ export function useSound() {
if (muted) return
const sound = soundsRef.current.get(effect)
if (sound) {
// If sound loaded successfully, play it
if (sound && sound.state() === 'loaded') {
sound.play()
} else if (failedSoundsRef.current.has(effect) || !sound || sound.state() !== 'loaded') {
// Use fallback Web Audio API sound
playFallbackSound(effect, volume)
}
},
[muted]
[muted, volume]
)
const stop = useCallback((effect: SoundEffect) => {
@@ -75,9 +142,88 @@ export function useSound() {
soundsRef.current.forEach((sound) => sound.stop())
}, [])
// Convenience method for playing tick sounds (every second)
const playTick = useCallback(
(timeRemaining: number, urgentThreshold: number = 5) => {
if (muted) return
if (timeRemaining <= urgentThreshold && timeRemaining > 0) {
play('timer_urgent')
} else if (timeRemaining > urgentThreshold) {
play('timer_tick')
}
},
[muted, play]
)
return {
play,
stop,
stopAll,
playTick,
volume,
muted,
}
}
// Singleton sound player for use outside of React components
// Useful for playing sounds from socket event handlers
class SoundPlayer {
private static instance: SoundPlayer | null = null
private sounds: Map<string, Howl> = new Map()
private currentTheme: string | null = null
private failedSounds: Set<string> = new Set()
static getInstance(): SoundPlayer {
if (!SoundPlayer.instance) {
SoundPlayer.instance = new SoundPlayer()
}
return SoundPlayer.instance
}
loadTheme(theme: string): void {
if (this.currentTheme === theme) return
// Unload previous sounds
this.sounds.forEach((sound) => sound.unload())
this.sounds.clear()
this.failedSounds.clear()
this.currentTheme = theme
const themeSounds = soundPaths[theme as keyof typeof soundPaths]
if (!themeSounds) return
Object.entries(themeSounds).forEach(([key, path]) => {
const sound = new Howl({
src: [path],
preload: true,
onloaderror: () => {
this.failedSounds.add(key)
},
})
this.sounds.set(key, sound)
})
}
play(effect: SoundEffect, volume: number = 0.7): void {
const { muted } = useSoundStore.getState()
if (muted) return
const sound = this.sounds.get(effect)
if (sound && sound.state() === 'loaded') {
sound.volume(volume)
sound.play()
} else {
playFallbackSound(effect, volume)
}
}
updateVolume(volume: number): void {
this.sounds.forEach((sound) => {
sound.volume(volume)
})
}
}
export const soundPlayer = SoundPlayer.getInstance()

View File

@@ -5,6 +5,10 @@ import { useSocket } from '../hooks/useSocket'
import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import EmojiReactions from '../components/chat/EmojiReactions'
import ReactionOverlay from '../components/chat/ReactionOverlay'
import TeamChat from '../components/chat/TeamChat'
import SoundControl from '../components/ui/SoundControl'
import type { Question } from '../types'
const categories = [
@@ -21,9 +25,9 @@ const categories = [
export default function Game() {
useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
const { selectQuestion, submitAnswer, stealDecision, sendTeamMessage } = useSocket()
const { play } = useSound()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt, teamMessages } = useGameStore()
const { config, styles } = useThemeStyles()
const [answer, setAnswer] = useState('')
@@ -37,7 +41,7 @@ export default function Game() {
}
}, [room?.status, room?.code, navigate])
// Timer logic
// Timer logic with sound effects
useEffect(() => {
if (!currentQuestion || !showingQuestion) return
@@ -48,7 +52,13 @@ export default function Game() {
clearInterval(interval)
return 0
}
if (prev === 6) play('timer_urgent')
// Play urgent sound when time is running low (5 seconds or less)
if (prev <= 6 && prev > 1) {
play('timer_urgent')
} else if (prev > 6) {
// Play tick sound for normal countdown
play('timer_tick')
}
return prev - 1
})
}, 1000)
@@ -95,7 +105,15 @@ export default function Game() {
setShowStealPrompt(false)
}
const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
// Handler for sending team messages
const handleSendTeamMessage = (message: string) => {
if (room && playerName && myTeam) {
sendTeamMessage(message, room.code, myTeam, playerName)
}
}
// Determine if the game is active (playing status)
const isGameActive = room.status === 'playing'
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
@@ -316,20 +334,30 @@ export default function Game() {
)}
</AnimatePresence>
{/* Emoji Reactions */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{emojis.map((emoji) => (
<button
key={emoji}
onClick={() => sendEmojiReaction(emoji)}
className="text-2xl p-2 rounded-lg transition-transform hover:scale-125"
style={{ backgroundColor: config.colors.bg + '80' }}
>
{emoji}
</button>
))}
{/* Emoji Reactions Bar - Fixed at bottom */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<EmojiReactions />
</div>
{/* Sound Control - Fixed at top right */}
<div className="fixed top-4 right-4 z-30">
<SoundControl compact popupPosition="bottom" />
</div>
</div>
{/* Reaction Overlay - Full screen overlay for floating reactions */}
<ReactionOverlay />
{/* Team Chat - Only visible during the game */}
{isGameActive && (
<TeamChat
roomCode={room.code}
playerName={playerName}
team={myTeam}
sendTeamMessage={handleSendTeamMessage}
teamMessages={teamMessages}
/>
)}
</div>
)
}

View File

@@ -6,6 +6,8 @@ const navItems = [
{ path: '/admin/dashboard', label: 'Dashboard', icon: '📊' },
{ path: '/admin/questions', label: 'Preguntas', icon: '❓' },
{ path: '/admin/calendar', label: 'Calendario', icon: '📅' },
{ path: '/admin/monitor', label: 'Monitor', icon: '🖥️' },
{ path: '/admin/settings', label: 'Configuracion', icon: '⚙️' },
]
export default function AdminLayout() {

View File

@@ -0,0 +1,375 @@
import { useEffect, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAdminStore } from '../../stores/adminStore'
import { getActiveRooms, closeRoom, ActiveRoom } from '../../services/adminApi'
// Helper para formatear tiempo restante
const formatTTL = (seconds: number): string => {
if (seconds <= 0) return 'Expirando...'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
// Badge de estado
const StatusBadge = ({ status }: { status: string }) => {
const colors: Record<string, string> = {
waiting: 'bg-yellow-500/20 text-yellow-400 border-yellow-500',
playing: 'bg-green-500/20 text-green-400 border-green-500',
finished: 'bg-gray-500/20 text-gray-400 border-gray-500',
unknown: 'bg-red-500/20 text-red-400 border-red-500'
}
const labels: Record<string, string> = {
waiting: 'Esperando',
playing: 'Jugando',
finished: 'Finalizado',
unknown: 'Desconocido'
}
return (
<span className={`px-2 py-1 text-xs rounded border ${colors[status] || colors.unknown}`}>
{labels[status] || status}
</span>
)
}
// Modal de confirmacion
const ConfirmModal = ({
isOpen,
onClose,
onConfirm,
roomCode,
playersCount
}: {
isOpen: boolean
onClose: () => void
onConfirm: () => void
roomCode: string
playersCount: number
}) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
>
<h3 className="text-xl font-bold text-white mb-4">
Cerrar Sala
</h3>
<p className="text-gray-300 mb-2">
Estas a punto de cerrar la sala <span className="font-mono font-bold text-yellow-400">{roomCode}</span>.
</p>
<p className="text-gray-400 text-sm mb-6">
Esta accion desconectara a {playersCount} jugador{playersCount !== 1 ? 'es' : ''} y eliminara todos los datos de la partida.
</p>
<div className="flex gap-4 justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-white transition-colors"
>
Cancelar
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Cerrar Sala
</button>
</div>
</motion.div>
</div>
)
}
export default function Monitor() {
const { token } = useAdminStore()
const [rooms, setRooms] = useState<ActiveRoom[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
// Modal state
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean
roomCode: string
playersCount: number
}>({
isOpen: false,
roomCode: '',
playersCount: 0
})
// Closing state para mostrar loading en boton
const [closingRoom, setClosingRoom] = useState<string | null>(null)
const fetchRooms = useCallback(async () => {
if (!token) return
try {
const data = await getActiveRooms(token)
setRooms(data.rooms)
setLastUpdate(new Date())
setError(null)
} catch (err) {
setError('Error al cargar salas activas')
console.error('Error fetching rooms:', err)
} finally {
setLoading(false)
}
}, [token])
// Fetch inicial y auto-refresh cada 5 segundos
useEffect(() => {
fetchRooms()
const interval = setInterval(fetchRooms, 5000)
return () => clearInterval(interval)
}, [fetchRooms])
const handleCloseRoom = (room: ActiveRoom) => {
setConfirmModal({
isOpen: true,
roomCode: room.room_code,
playersCount: room.players_count
})
}
const confirmCloseRoom = async () => {
if (!token || !confirmModal.roomCode) return
setClosingRoom(confirmModal.roomCode)
setConfirmModal({ isOpen: false, roomCode: '', playersCount: 0 })
try {
await closeRoom(token, confirmModal.roomCode)
// Refrescar lista
await fetchRooms()
} catch (err) {
setError('Error al cerrar la sala')
console.error('Error closing room:', err)
} finally {
setClosingRoom(null)
}
}
// Stats summary
const totalPlayers = rooms.reduce((sum, r) => sum + r.players_count, 0)
const playingRooms = rooms.filter(r => r.status === 'playing').length
const waitingRooms = rooms.filter(r => r.status === 'waiting').length
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Monitor de Salas</h1>
<p className="text-gray-400 text-sm mt-1">
Ultima actualizacion: {lastUpdate.toLocaleTimeString()}
</p>
</div>
<button
onClick={fetchRooms}
disabled={loading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded transition-colors flex items-center gap-2"
>
<svg
className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Actualizar
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 bg-gray-800 rounded-lg border-l-4 border-blue-500"
>
<p className="text-gray-400 text-sm">Total Salas</p>
<p className="text-2xl font-bold text-white">{rooms.length}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-4 bg-gray-800 rounded-lg border-l-4 border-green-500"
>
<p className="text-gray-400 text-sm">Jugando</p>
<p className="text-2xl font-bold text-green-400">{playingRooms}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="p-4 bg-gray-800 rounded-lg border-l-4 border-yellow-500"
>
<p className="text-gray-400 text-sm">Esperando</p>
<p className="text-2xl font-bold text-yellow-400">{waitingRooms}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="p-4 bg-gray-800 rounded-lg border-l-4 border-purple-500"
>
<p className="text-gray-400 text-sm">Jugadores Activos</p>
<p className="text-2xl font-bold text-purple-400">{totalPlayers}</p>
</motion.div>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-4 bg-red-500/20 border border-red-500 rounded-lg text-red-400">
{error}
</div>
)}
{/* Table */}
<div className="bg-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Codigo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Jugadores
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Equipo A
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Equipo B
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Tiempo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
<AnimatePresence mode="popLayout">
{loading && rooms.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
Cargando salas...
</td>
</tr>
) : rooms.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
No hay salas activas en este momento
</td>
</tr>
) : (
rooms.map((room) => (
<motion.tr
key={room.room_code}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, x: -20 }}
className="hover:bg-gray-700/50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono font-bold text-white">
{room.room_code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
{room.players_count}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-blue-400">
{room.teams.A}
{room.status === 'playing' && (
<span className="text-gray-500 ml-1">
({room.scores.A} pts)
</span>
)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-red-400">
{room.teams.B}
{room.status === 'playing' && (
<span className="text-gray-500 ml-1">
({room.scores.B} pts)
</span>
)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={room.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
{formatTTL(room.ttl_seconds)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
{room.host}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleCloseRoom(room)}
disabled={closingRoom === room.room_code}
className="px-3 py-1 bg-red-600/20 hover:bg-red-600/40 text-red-400 hover:text-red-300 rounded text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{closingRoom === room.room_code ? 'Cerrando...' : 'Cerrar Sala'}
</button>
</td>
</motion.tr>
))
)}
</AnimatePresence>
</tbody>
</table>
</div>
</div>
{/* Auto-refresh indicator */}
<p className="mt-4 text-gray-500 text-xs text-center">
Los datos se actualizan automaticamente cada 5 segundos
</p>
{/* Confirm Modal */}
<AnimatePresence>
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal({ isOpen: false, roomCode: '', playersCount: 0 })}
onConfirm={confirmCloseRoom}
roomCode={confirmModal.roomCode}
playersCount={confirmModal.playersCount}
/>
</AnimatePresence>
</div>
)
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAdminStore } from '../../stores/adminStore'
import {
getQuestions, getCategories, createQuestion, updateQuestion,
deleteQuestion, generateQuestions, approveQuestion, rejectQuestion
deleteQuestion, generateQuestions, approveQuestion, rejectQuestion,
exportQuestions, importQuestions, ImportResult
} from '../../services/adminApi'
import type { Category } from '../../types'
@@ -54,6 +55,13 @@ export default function Questions() {
})
const [generating, setGenerating] = useState(false)
// Import/Export state
const [showImportModal, setShowImportModal] = useState(false)
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState(false)
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetchData()
}, [token, filterCategory, filterStatus])
@@ -168,6 +176,54 @@ export default function Questions() {
}
}
const handleExport = async () => {
if (!token) return
setExporting(true)
try {
await exportQuestions(token, {
categoryId: filterCategory || undefined,
status: filterStatus || undefined
})
} catch (error) {
console.error('Error exporting:', error)
alert('Error al exportar preguntas')
} finally {
setExporting(false)
}
}
const handleImportClick = () => {
setImportResult(null)
setShowImportModal(true)
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !token) return
setImporting(true)
try {
const result = await importQuestions(token, file)
setImportResult(result)
if (result.imported > 0) {
setFilterStatus('pending')
fetchData()
}
} catch (error) {
console.error('Error importing:', error)
setImportResult({
imported: 0,
errors: [{ row: 0, error: error instanceof Error ? error.message : 'Error desconocido' }]
})
} finally {
setImporting(false)
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const getCategoryName = (id: number) => categories.find(c => c.id === id)?.name || 'Unknown'
const getStatusBadge = (status: string) => {
switch (status) {
@@ -183,6 +239,19 @@ export default function Questions() {
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-white">Preguntas</h1>
<div className="flex gap-2">
<button
onClick={handleExport}
disabled={exporting}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
>
{exporting ? 'Exportando...' : 'Exportar CSV'}
</button>
<button
onClick={handleImportClick}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Importar CSV
</button>
<button
onClick={() => setShowGenerateModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
@@ -474,6 +543,115 @@ export default function Questions() {
</motion.div>
)}
</AnimatePresence>
{/* Import Modal */}
<AnimatePresence>
{showImportModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
className="w-full max-w-lg bg-gray-800 rounded-lg p-6"
>
<h2 className="text-xl font-bold text-white mb-4">
Importar Preguntas desde CSV
</h2>
{!importResult ? (
<div className="space-y-4">
<div className="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
disabled={importing}
className="hidden"
id="csv-file-input"
/>
<label
htmlFor="csv-file-input"
className={`cursor-pointer block ${importing ? 'opacity-50' : ''}`}
>
<div className="text-4xl mb-2">📄</div>
<p className="text-white mb-2">
{importing ? 'Importando...' : 'Haz clic para seleccionar archivo CSV'}
</p>
<p className="text-gray-400 text-sm">
o arrastra y suelta aqui
</p>
</label>
</div>
<div className="bg-gray-700 rounded p-4">
<p className="text-gray-300 text-sm font-medium mb-2">Formato esperado:</p>
<code className="text-xs text-gray-400 block whitespace-pre-wrap">
category,question,correct_answer,alt_answers,difficulty,fun_fact{'\n'}
Nintendo,Quien es el protagonista de Zelda?,Link,El heroe|El elegido,2,Link no es Zelda
</code>
<p className="text-gray-400 text-xs mt-2">
* alt_answers separados por | (pipe)
</p>
</div>
</div>
) : (
<div className="space-y-4">
{/* Import Results */}
<div className={`p-4 rounded-lg ${importResult.imported > 0 ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
<p className={`text-lg font-bold ${importResult.imported > 0 ? 'text-green-400' : 'text-red-400'}`}>
{importResult.imported > 0
? `Se importaron ${importResult.imported} preguntas`
: 'No se importaron preguntas'}
</p>
{importResult.errors.length > 0 && (
<p className="text-yellow-400 text-sm mt-1">
{importResult.errors.length} error(es) encontrado(s)
</p>
)}
</div>
{/* Error List */}
{importResult.errors.length > 0 && (
<div className="bg-gray-700 rounded-lg p-4 max-h-60 overflow-y-auto">
<p className="text-gray-300 text-sm font-medium mb-2">Errores:</p>
<ul className="space-y-1">
{importResult.errors.map((err, idx) => (
<li key={idx} className="text-red-400 text-sm">
{err.row > 0 ? `Fila ${err.row}: ` : ''}{err.error}
</li>
))}
</ul>
</div>
)}
<button
onClick={() => setImportResult(null)}
className="w-full px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-500"
>
Importar otro archivo
</button>
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowImportModal(false)}
className="px-4 py-2 text-gray-400 hover:text-white"
disabled={importing}
>
Cerrar
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,315 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { useAdminStore } from '../../stores/adminStore'
import { getSettings, updateSettings } from '../../services/adminApi'
interface GameSettings {
points_by_difficulty: Record<string, number>
times_by_difficulty: Record<string, number>
steal_penalty_percent: number
max_players_per_team: number
steal_time_percent: number
}
const defaultSettings: GameSettings = {
points_by_difficulty: { "1": 100, "2": 200, "3": 300, "4": 400, "5": 500 },
times_by_difficulty: { "1": 15, "2": 20, "3": 25, "4": 35, "5": 45 },
steal_penalty_percent: 50,
max_players_per_team: 4,
steal_time_percent: 50
}
const difficultyLabels: Record<string, string> = {
"1": "Muy Facil",
"2": "Facil",
"3": "Media",
"4": "Dificil",
"5": "Muy Dificil"
}
export default function Settings() {
const { token } = useAdminStore()
const [settings, setSettings] = useState<GameSettings>(defaultSettings)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
useEffect(() => {
fetchSettings()
}, [token])
const fetchSettings = async () => {
if (!token) return
setLoading(true)
try {
const data = await getSettings(token)
setSettings(data)
} catch (error) {
console.error('Error fetching settings:', error)
setMessage({ type: 'error', text: 'Error al cargar la configuracion' })
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!token) return
setSaving(true)
setMessage(null)
try {
await updateSettings(token, settings)
setMessage({ type: 'success', text: 'Configuracion guardada exitosamente' })
} catch (error) {
console.error('Error saving settings:', error)
setMessage({ type: 'error', text: 'Error al guardar la configuracion' })
} finally {
setSaving(false)
}
}
const updatePointsForDifficulty = (difficulty: string, value: number) => {
setSettings(prev => ({
...prev,
points_by_difficulty: {
...prev.points_by_difficulty,
[difficulty]: value
}
}))
}
const updateTimesForDifficulty = (difficulty: string, value: number) => {
setSettings(prev => ({
...prev,
times_by_difficulty: {
...prev.times_by_difficulty,
[difficulty]: value
}
}))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-400">Cargando configuracion...</p>
</div>
)
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-white">Configuracion del Juego</h1>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Guardando...' : 'Guardar Cambios'}
</button>
</div>
{/* Feedback Message */}
{message && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`mb-6 p-4 rounded ${
message.type === 'success'
? 'bg-green-600/20 border border-green-500 text-green-400'
: 'bg-red-600/20 border border-red-500 text-red-400'
}`}
>
{message.text}
</motion.div>
)}
<div className="grid gap-6">
{/* Points by Difficulty */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gray-800 rounded-lg p-6"
>
<h2 className="text-lg font-semibold text-white mb-4">Puntos por Dificultad</h2>
<p className="text-gray-400 text-sm mb-4">
Define cuantos puntos otorga cada nivel de dificultad al responder correctamente.
</p>
<div className="grid grid-cols-5 gap-4">
{["1", "2", "3", "4", "5"].map(diff => (
<div key={`points-${diff}`}>
<label className="block text-gray-400 text-sm mb-1">
{difficultyLabels[diff]}
</label>
<input
type="number"
value={settings.points_by_difficulty[diff] || 0}
onChange={(e) => updatePointsForDifficulty(diff, parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
min="0"
step="50"
/>
</div>
))}
</div>
</motion.div>
{/* Times by Difficulty */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-gray-800 rounded-lg p-6"
>
<h2 className="text-lg font-semibold text-white mb-4">Tiempo por Dificultad (segundos)</h2>
<p className="text-gray-400 text-sm mb-4">
Define cuantos segundos tiene el equipo para responder segun la dificultad.
</p>
<div className="grid grid-cols-5 gap-4">
{["1", "2", "3", "4", "5"].map(diff => (
<div key={`time-${diff}`}>
<label className="block text-gray-400 text-sm mb-1">
{difficultyLabels[diff]}
</label>
<input
type="number"
value={settings.times_by_difficulty[diff] || 0}
onChange={(e) => updateTimesForDifficulty(diff, parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
min="5"
max="120"
step="5"
/>
</div>
))}
</div>
</motion.div>
{/* Steal Settings */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-gray-800 rounded-lg p-6"
>
<h2 className="text-lg font-semibold text-white mb-4">Mecanica de Robo</h2>
<p className="text-gray-400 text-sm mb-4">
Configuracion para cuando un equipo intenta robar puntos despues de una respuesta incorrecta.
</p>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-gray-400 text-sm mb-1">
Penalizacion de Robo (%)
</label>
<p className="text-gray-500 text-xs mb-2">
Porcentaje de puntos que se obtienen al robar (ej: 50% = mitad de puntos)
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={settings.steal_penalty_percent}
onChange={(e) => setSettings(prev => ({ ...prev, steal_penalty_percent: parseInt(e.target.value) }))}
className="flex-1"
min="10"
max="100"
step="10"
/>
<span className="text-white font-medium w-12 text-right">
{settings.steal_penalty_percent}%
</span>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-1">
Tiempo de Robo (%)
</label>
<p className="text-gray-500 text-xs mb-2">
Porcentaje del tiempo original para intentar el robo
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={settings.steal_time_percent}
onChange={(e) => setSettings(prev => ({ ...prev, steal_time_percent: parseInt(e.target.value) }))}
className="flex-1"
min="10"
max="100"
step="10"
/>
<span className="text-white font-medium w-12 text-right">
{settings.steal_time_percent}%
</span>
</div>
</div>
</div>
</motion.div>
{/* Team Settings */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-gray-800 rounded-lg p-6"
>
<h2 className="text-lg font-semibold text-white mb-4">Configuracion de Equipos</h2>
<p className="text-gray-400 text-sm mb-4">
Limites y reglas para los equipos en el juego.
</p>
<div className="max-w-xs">
<label className="block text-gray-400 text-sm mb-1">
Maximo de Jugadores por Equipo
</label>
<input
type="number"
value={settings.max_players_per_team}
onChange={(e) => setSettings(prev => ({ ...prev, max_players_per_team: parseInt(e.target.value) || 1 }))}
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
min="1"
max="10"
/>
</div>
</motion.div>
</div>
{/* Preview Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-6 bg-gray-800/50 rounded-lg p-6 border border-gray-700"
>
<h2 className="text-lg font-semibold text-white mb-4">Vista Previa</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 text-gray-400">Dificultad</th>
<th className="text-center py-2 text-gray-400">Puntos</th>
<th className="text-center py-2 text-gray-400">Tiempo</th>
<th className="text-center py-2 text-gray-400">Robo (pts)</th>
<th className="text-center py-2 text-gray-400">Robo (tiempo)</th>
</tr>
</thead>
<tbody>
{["1", "2", "3", "4", "5"].map(diff => {
const points = settings.points_by_difficulty[diff] || 0
const time = settings.times_by_difficulty[diff] || 0
const stealPoints = Math.round(points * settings.steal_penalty_percent / 100)
const stealTime = Math.round(time * settings.steal_time_percent / 100)
return (
<tr key={diff} className="border-b border-gray-700/50">
<td className="py-2 text-white">{difficultyLabels[diff]}</td>
<td className="py-2 text-center text-green-400">{points} pts</td>
<td className="py-2 text-center text-blue-400">{time}s</td>
<td className="py-2 text-center text-yellow-400">{stealPoints} pts</td>
<td className="py-2 text-center text-purple-400">{stealTime}s</td>
</tr>
)
})}
</tbody>
</table>
</div>
</motion.div>
</div>
)
}

View File

@@ -3,3 +3,5 @@ export { default as AdminLayout } from './AdminLayout'
export { default as Dashboard } from './Dashboard'
export { default as Questions } from './Questions'
export { default as Calendar } from './Calendar'
export { default as Settings } from './Settings'
export { default as Monitor } from './Monitor'

View File

@@ -138,3 +138,151 @@ export const getCategories = async (token: string) => {
if (!response.ok) throw new Error('Failed to fetch categories')
return response.json()
}
// CSV Import/Export
export const exportQuestions = async (
token: string,
filters?: { categoryId?: number; status?: string }
) => {
const params = new URLSearchParams()
if (filters?.categoryId) params.append('category_id', String(filters.categoryId))
if (filters?.status) params.append('status', filters.status)
const response = await fetch(`${API_URL}/api/admin/questions/export?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!response.ok) throw new Error('Failed to export questions')
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition')
let filename = 'questions_export.csv'
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/)
if (match) filename = match[1]
}
// Get blob and trigger download
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
export interface ImportResult {
imported: number
errors: Array<{ row: number; error: string }>
}
export const importQuestions = async (token: string, file: File): Promise<ImportResult> => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/api/admin/questions/import`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to import questions')
}
return response.json()
}
// Room Monitor
export interface ActiveRoom {
room_code: string
players_count: number
teams: {
A: number
B: number
}
status: 'waiting' | 'playing' | 'finished'
host: string
ttl_seconds: number
scores: {
A: number
B: number
}
}
export interface ActiveRoomsResponse {
rooms: ActiveRoom[]
total: number
}
export const getActiveRooms = async (token: string): Promise<ActiveRoomsResponse> => {
const response = await fetch(`${API_URL}/api/admin/rooms/active`, {
headers: getAuthHeaders(token)
})
if (!response.ok) throw new Error('Failed to fetch active rooms')
return response.json()
}
export interface CloseRoomResponse {
status: string
room_code: string
players_affected: number
}
export const closeRoom = async (token: string, roomCode: string): Promise<CloseRoomResponse> => {
const response = await fetch(`${API_URL}/api/admin/rooms/${roomCode}`, {
method: 'DELETE',
headers: getAuthHeaders(token)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to close room')
}
return response.json()
}
// Game Settings
export interface GameSettings {
points_by_difficulty: Record<string, number>
times_by_difficulty: Record<string, number>
steal_penalty_percent: number
max_players_per_team: number
steal_time_percent: number
}
export const getSettings = async (token: string): Promise<GameSettings> => {
const response = await fetch(`${API_URL}/api/admin/settings`, {
headers: getAuthHeaders(token)
})
if (!response.ok) throw new Error('Failed to fetch settings')
return response.json()
}
export const updateSettings = async (token: string, settings: GameSettings): Promise<GameSettings> => {
const response = await fetch(`${API_URL}/api/admin/settings`, {
method: 'PUT',
headers: getAuthHeaders(token),
body: JSON.stringify(settings)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update settings')
}
return response.json()
}

View File

@@ -1,6 +1,23 @@
import { create } from 'zustand'
import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
export interface Reaction {
id: string
player_name: string
team: 'A' | 'B'
emoji: string
timestamp: string
}
export interface TeamMessage {
player_name: string
team: 'A' | 'B'
message: string
timestamp: string
}
const MAX_TEAM_MESSAGES = 50
interface GameState {
// Room state
room: GameRoom | null
@@ -23,6 +40,11 @@ interface GameState {
addMessage: (message: ChatMessage) => void
clearMessages: () => void
// Team chat messages
teamMessages: TeamMessage[]
addTeamMessage: (message: TeamMessage) => void
clearTeamMessages: () => void
// Achievements
achievements: Achievement[]
setAchievements: (achievements: Achievement[]) => void
@@ -44,6 +66,12 @@ interface GameState {
showStealPrompt: boolean
setShowStealPrompt: (show: boolean) => void
// Reactions
reactions: Reaction[]
addReaction: (reaction: Omit<Reaction, 'id'>) => void
removeReaction: (id: string) => void
clearReactions: () => void
// Game result
gameResult: {
winner: 'A' | 'B' | null
@@ -88,6 +116,13 @@ export const useGameStore = create<GameState>((set) => ({
set((state) => ({ messages: [...state.messages, message].slice(-100) })),
clearMessages: () => set({ messages: [] }),
teamMessages: [],
addTeamMessage: (message) =>
set((state) => ({
teamMessages: [...state.teamMessages, message].slice(-MAX_TEAM_MESSAGES),
})),
clearTeamMessages: () => set({ teamMessages: [] }),
achievements: [],
setAchievements: (achievements) => set({ achievements }),
unlockAchievement: (id) =>
@@ -105,6 +140,20 @@ export const useGameStore = create<GameState>((set) => ({
showStealPrompt: false,
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
reactions: [],
addReaction: (reaction) =>
set((state) => ({
reactions: [
...state.reactions,
{ ...reaction, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` },
],
})),
removeReaction: (id) =>
set((state) => ({
reactions: state.reactions.filter((r) => r.id !== id),
})),
clearReactions: () => set({ reactions: [] }),
gameResult: null,
setGameResult: (gameResult) => set({ gameResult }),
@@ -114,6 +163,8 @@ export const useGameStore = create<GameState>((set) => ({
currentQuestion: null,
timerEnd: null,
messages: [],
teamMessages: [],
reactions: [],
stats: initialStats,
showStealPrompt: false,
gameResult: null,

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ThemeName } from '../types'
type SoundEffect =
export type SoundEffect =
| 'correct'
| 'incorrect'
| 'steal'
@@ -15,9 +15,13 @@ type SoundEffect =
interface SoundState {
volume: number
muted: boolean
soundsLoaded: boolean
currentLoadedTheme: ThemeName | null
setVolume: (volume: number) => void
setMuted: (muted: boolean) => void
toggleMute: () => void
setSoundsLoaded: (loaded: boolean) => void
setCurrentLoadedTheme: (theme: ThemeName | null) => void
}
export const useSoundStore = create<SoundState>()(
@@ -25,17 +29,23 @@ export const useSoundStore = create<SoundState>()(
(set) => ({
volume: 0.7,
muted: false,
setVolume: (volume) => set({ volume }),
soundsLoaded: false,
currentLoadedTheme: null,
setVolume: (volume) => set({ volume: Math.max(0, Math.min(1, volume)) }),
setMuted: (muted) => set({ muted }),
toggleMute: () => set((state) => ({ muted: !state.muted })),
setSoundsLoaded: (soundsLoaded) => set({ soundsLoaded }),
setCurrentLoadedTheme: (currentLoadedTheme) => set({ currentLoadedTheme }),
}),
{
name: 'trivia-sound',
partialize: (state) => ({ volume: state.volume, muted: state.muted }),
}
)
)
// Sound file paths per theme
// All themes share the same base sounds but can be customized per theme
export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
drrr: {
correct: '/sounds/drrr/correct.mp3',
@@ -88,3 +98,16 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
select: '/sounds/anime/select.mp3',
},
}
// Fallback sounds using Web Audio API generated tones
// These are used when actual sound files are not available
export const fallbackSoundConfigs: Record<SoundEffect, { frequency: number; duration: number; type: OscillatorType }> = {
correct: { frequency: 880, duration: 0.15, type: 'sine' },
incorrect: { frequency: 220, duration: 0.3, type: 'square' },
steal: { frequency: 660, duration: 0.2, type: 'sawtooth' },
timer_tick: { frequency: 440, duration: 0.05, type: 'sine' },
timer_urgent: { frequency: 880, duration: 0.1, type: 'square' },
victory: { frequency: 523, duration: 0.5, type: 'sine' },
defeat: { frequency: 196, duration: 0.5, type: 'sine' },
select: { frequency: 600, duration: 0.08, type: 'sine' },
}