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:
67
frontend/public/sounds/README.md
Normal file
67
frontend/public/sounds/README.md
Normal 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
|
||||
@@ -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>
|
||||
|
||||
89
frontend/src/components/chat/EmojiReactions.tsx
Normal file
89
frontend/src/components/chat/EmojiReactions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
341
frontend/src/components/chat/TeamChat.tsx
Normal file
341
frontend/src/components/chat/TeamChat.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/ui/SoundControl.tsx
Normal file
199
frontend/src/components/ui/SoundControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/ui/index.ts
Normal file
1
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SoundControl } from './SoundControl'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
375
frontend/src/pages/admin/Monitor.tsx
Normal file
375
frontend/src/pages/admin/Monitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
315
frontend/src/pages/admin/Settings.tsx
Normal file
315
frontend/src/pages/admin/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user