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

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

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

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

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

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

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

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

View File

@@ -1,11 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timedelta from datetime import datetime, timedelta
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from typing import List from typing import List
import csv
import json
from io import StringIO
from app.models.base import get_db from app.models.base import get_db
from app.models.admin import Admin from app.models.admin import Admin
@@ -17,6 +21,9 @@ from app.schemas.question import (
AIGenerateRequest AIGenerateRequest
) )
from app.services.ai_generator import ai_generator from app.services.ai_generator import ai_generator
from app.services.room_manager import room_manager
from app.services.game_config import get_game_settings, update_game_settings
from app.schemas.game_config import GameSettingsSchema
from app.config import get_settings from app.config import get_settings
router = APIRouter() router = APIRouter()
@@ -294,3 +301,348 @@ async def create_category(
await db.commit() await db.commit()
await db.refresh(category) await db.refresh(category)
return category return category
# CSV Import/Export
@router.get("/questions/export")
async def export_questions(
category_id: int = None,
status: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
"""
Export questions to CSV format.
Query params: category_id (optional), status (optional)
Returns CSV file as download.
"""
# Build query with filters
query = select(Question, Category.name.label("category_name")).join(
Category, Question.category_id == Category.id
)
if category_id:
query = query.where(Question.category_id == category_id)
if status:
query = query.where(Question.status == status)
result = await db.execute(query.order_by(Question.created_at.desc()))
rows = result.all()
# Create CSV in memory
output = StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
"category", "question", "correct_answer", "alt_answers",
"difficulty", "fun_fact", "status", "date_active"
])
# Write data rows
for row in rows:
question = row[0]
category_name = row[1]
# Join alt_answers with pipe separator
alt_answers_str = "|".join(question.alt_answers) if question.alt_answers else ""
# Format date_active
date_active_str = question.date_active.isoformat() if question.date_active else ""
writer.writerow([
category_name,
question.question_text,
question.correct_answer,
alt_answers_str,
question.difficulty,
question.fun_fact or "",
question.status,
date_active_str
])
# Prepare response
output.seek(0)
# Generate filename with timestamp
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
filename = f"questions_export_{timestamp}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8"
}
)
@router.post("/questions/import")
async def import_questions(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
"""
Import questions from CSV file.
Expected columns: category, question, correct_answer, alt_answers, difficulty, fun_fact
alt_answers should be separated by pipe |
Returns: {imported: count, errors: [{row, error}]}
"""
# Validate file type
if not file.filename.endswith('.csv'):
raise HTTPException(
status_code=400,
detail="File must be a CSV"
)
# Read file content
try:
content = await file.read()
decoded_content = content.decode('utf-8')
except UnicodeDecodeError:
# Try with latin-1 encoding as fallback
try:
decoded_content = content.decode('latin-1')
except:
raise HTTPException(
status_code=400,
detail="Could not decode file. Please use UTF-8 encoding."
)
# Parse CSV
csv_reader = csv.DictReader(StringIO(decoded_content))
# Required columns
required_columns = {"category", "question", "correct_answer", "difficulty"}
# Validate headers
if not csv_reader.fieldnames:
raise HTTPException(
status_code=400,
detail="CSV file is empty or has no headers"
)
headers = set(csv_reader.fieldnames)
missing_columns = required_columns - headers
if missing_columns:
raise HTTPException(
status_code=400,
detail=f"Missing required columns: {', '.join(missing_columns)}"
)
# Get all categories for lookup
categories_result = await db.execute(select(Category))
categories = {cat.name.lower(): cat.id for cat in categories_result.scalars().all()}
imported_count = 0
errors = []
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (1 is header)
try:
# Get category
category_name = row.get("category", "").strip()
if not category_name:
errors.append({"row": row_num, "error": "Category is required"})
continue
category_id = categories.get(category_name.lower())
if not category_id:
errors.append({"row": row_num, "error": f"Category '{category_name}' not found"})
continue
# Get question text
question_text = row.get("question", "").strip()
if not question_text:
errors.append({"row": row_num, "error": "Question text is required"})
continue
# Get correct answer
correct_answer = row.get("correct_answer", "").strip()
if not correct_answer:
errors.append({"row": row_num, "error": "Correct answer is required"})
continue
# Get difficulty
try:
difficulty = int(row.get("difficulty", "3"))
if difficulty < 1 or difficulty > 5:
errors.append({"row": row_num, "error": "Difficulty must be between 1 and 5"})
continue
except ValueError:
errors.append({"row": row_num, "error": "Difficulty must be a number"})
continue
# Parse alt_answers (pipe separated)
alt_answers_str = row.get("alt_answers", "").strip()
alt_answers = [a.strip() for a in alt_answers_str.split("|") if a.strip()] if alt_answers_str else []
# Get fun_fact (optional)
fun_fact = row.get("fun_fact", "").strip() or None
# Calculate points and time based on difficulty
points = settings.default_points.get(difficulty, 300)
time_seconds = settings.default_times.get(difficulty, 25)
# Create question with pending status
question = Question(
category_id=category_id,
question_text=question_text,
correct_answer=correct_answer,
alt_answers=alt_answers,
difficulty=difficulty,
points=points,
time_seconds=time_seconds,
fun_fact=fun_fact,
status="pending"
)
db.add(question)
imported_count += 1
except Exception as e:
errors.append({"row": row_num, "error": str(e)})
# Commit all valid questions
if imported_count > 0:
await db.commit()
return {
"imported": imported_count,
"errors": errors
}
# Game Settings
@router.get("/settings")
async def get_settings_endpoint(
admin: Admin = Depends(get_current_admin)
):
"""
Get current game settings.
Returns configuration for points, times, steal mechanics, and team limits.
"""
return get_game_settings()
@router.put("/settings")
async def update_settings_endpoint(
settings_data: GameSettingsSchema,
admin: Admin = Depends(get_current_admin)
):
"""
Update game settings.
Expects a complete settings object with all fields.
"""
return update_game_settings(settings_data)
# Room Monitor
async def get_active_rooms_from_redis() -> List[dict]:
"""
Helper function to scan and retrieve all active rooms from Redis.
Returns list of room summaries with player counts and team info.
"""
await room_manager.connect()
rooms = []
cursor = 0
# Scan for all room:* keys
while True:
cursor, keys = await room_manager.redis.scan(cursor, match="room:*", count=100)
for key in keys:
try:
data = await room_manager.redis.get(key)
if data:
room_data = json.loads(data)
# Count players per team
team_a_count = len(room_data.get("teams", {}).get("A", []))
team_b_count = len(room_data.get("teams", {}).get("B", []))
total_players = team_a_count + team_b_count
# Get TTL for time remaining
ttl = await room_manager.redis.ttl(key)
rooms.append({
"room_code": room_data.get("code", ""),
"players_count": total_players,
"teams": {
"A": team_a_count,
"B": team_b_count
},
"status": room_data.get("status", "unknown"),
"host": room_data.get("host", ""),
"ttl_seconds": ttl if ttl > 0 else 0,
"scores": room_data.get("scores", {"A": 0, "B": 0})
})
except (json.JSONDecodeError, Exception):
# Skip malformed room data
continue
if cursor == 0:
break
return rooms
@router.get("/rooms/active")
async def get_active_rooms(
admin: Admin = Depends(get_current_admin)
):
"""
Get list of all active game rooms from Redis.
Returns: list of {room_code, players_count, teams: {A: count, B: count}, status, host, ttl_seconds}
"""
rooms = await get_active_rooms_from_redis()
return {"rooms": rooms, "total": len(rooms)}
@router.delete("/rooms/{room_code}")
async def close_room(
room_code: str,
admin: Admin = Depends(get_current_admin)
):
"""
Close a room by removing it from Redis.
Also removes all player mappings associated with the room.
Socket notifications should be handled by the caller through the socket server.
"""
await room_manager.connect()
# Check if room exists
room_data = await room_manager.get_room(room_code)
if not room_data:
raise HTTPException(status_code=404, detail="Room not found")
# Get all player socket IDs to clean up player mappings
player_sockets = []
for team in ["A", "B"]:
for player in room_data.get("teams", {}).get(team, []):
socket_id = player.get("socket_id")
if socket_id:
player_sockets.append(socket_id)
# Delete player mappings
for socket_id in player_sockets:
await room_manager.redis.delete(f"player:{socket_id}")
# Delete room stats for all players
for team in ["A", "B"]:
for player in room_data.get("teams", {}).get(team, []):
player_name = player.get("name")
if player_name:
await room_manager.redis.delete(f"stats:{room_code}:{player_name}")
# Delete the room itself
await room_manager.redis.delete(f"room:{room_code}")
return {
"status": "closed",
"room_code": room_code,
"players_affected": len(player_sockets)
}

View File

@@ -0,0 +1,19 @@
{
"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
}

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import Dict
class GameSettingsSchema(BaseModel):
"""Schema for game configuration settings."""
points_by_difficulty: Dict[str, int]
times_by_difficulty: Dict[str, int]
steal_penalty_percent: int
max_players_per_team: int
steal_time_percent: int
class Config:
json_schema_extra = {
"example": {
"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
}
}

View File

@@ -0,0 +1,78 @@
"""Service for managing dynamic game configuration."""
import json
import os
from pathlib import Path
from typing import Dict, Any
from app.schemas.game_config import GameSettingsSchema
# Path to the config file
CONFIG_FILE = Path(__file__).parent.parent / "data" / "config.json"
# Default configuration
DEFAULT_CONFIG: Dict[str, Any] = {
"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,
}
def _ensure_config_file():
"""Ensure the config file exists with default values."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
def get_game_settings() -> Dict[str, Any]:
"""Load game settings from JSON file."""
_ensure_config_file()
try:
with open(CONFIG_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
# Return defaults if file is corrupted
return DEFAULT_CONFIG.copy()
def update_game_settings(settings: GameSettingsSchema) -> Dict[str, Any]:
"""Update game settings in JSON file."""
_ensure_config_file()
config = settings.model_dump()
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
return config
def get_points_for_difficulty(difficulty: int) -> int:
"""Get points for a specific difficulty level."""
settings = get_game_settings()
return settings["points_by_difficulty"].get(str(difficulty), 300)
def get_time_for_difficulty(difficulty: int) -> int:
"""Get time (in seconds) for a specific difficulty level."""
settings = get_game_settings()
return settings["times_by_difficulty"].get(str(difficulty), 25)
def get_steal_penalty_multiplier() -> float:
"""Get steal penalty as a multiplier (0-1)."""
settings = get_game_settings()
return settings["steal_penalty_percent"] / 100.0
def get_steal_time_multiplier() -> float:
"""Get steal time as a multiplier (0-1)."""
settings = get_game_settings()
return settings["steal_time_percent"] / 100.0
def get_max_players_per_team() -> int:
"""Get maximum players allowed per team."""
settings = get_game_settings()
return settings["max_players_per_team"]

View File

@@ -1,4 +1,5 @@
import socketio import socketio
import time
from datetime import datetime from datetime import datetime
from app.services.room_manager import room_manager from app.services.room_manager import room_manager
from app.services.game_manager import game_manager from app.services.game_manager import game_manager
@@ -8,6 +9,11 @@ from app.schemas.achievement import PlayerStats
from app.models.base import get_async_session from app.models.base import get_async_session
# Rate limiting para reacciones: {room_code: {player_name: last_reaction_timestamp}}
reaction_rate_limits: dict[str, dict[str, float]] = {}
REACTION_COOLDOWN_SECONDS = 3
async def get_db_session(): async def get_db_session():
"""Helper para obtener sesion de BD en contexto de socket.""" """Helper para obtener sesion de BD en contexto de socket."""
AsyncSessionLocal = get_async_session() AsyncSessionLocal = get_async_session()
@@ -353,13 +359,56 @@ def register_socket_events(sio: socketio.AsyncServer):
) )
@sio.event @sio.event
async def emoji_reaction(sid, data): async def team_message(sid, data):
"""Send an emoji reaction visible to all.""" """Send a team chat message - only visible to teammates."""
room_code = data.get("room_code", "")
team = data.get("team", "")
player_name = data.get("player_name", "")
message = data.get("message", "")[:500] # Limit message length
if not all([room_code, team, player_name, message]):
return
# Validate player exists in room
player = await room_manager.get_player(sid)
if not player or player["room"] != room_code or player["team"] != team:
return
# Get room data to find team members
room = await room_manager.get_room(room_code)
if not room:
return
# Get socket IDs of team members
team_sockets = [
p["socket_id"] for p in room["teams"][team]
if p.get("socket_id")
]
# Emit only to team members
message_data = {
"player_name": player_name,
"team": team,
"message": message,
"timestamp": datetime.utcnow().isoformat()
}
for socket_id in team_sockets:
await sio.emit(
"receive_team_message",
message_data,
to=socket_id
)
@sio.event
async def send_reaction(sid, data):
"""Send an emoji reaction visible to all players in the room."""
player = await room_manager.get_player(sid) player = await room_manager.get_player(sid)
if not player: if not player:
return return
room_code = player["room"] room_code = data.get("room_code", player["room"])
player_name = data.get("player_name", player["name"])
emoji = data.get("emoji", "") emoji = data.get("emoji", "")
# Validate emoji # Validate emoji
@@ -367,16 +416,37 @@ def register_socket_events(sio: socketio.AsyncServer):
if emoji not in allowed_emojis: if emoji not in allowed_emojis:
return return
# Rate limiting: max 1 reaction every 3 seconds per player
current_time = time.time()
if room_code not in reaction_rate_limits:
reaction_rate_limits[room_code] = {}
last_reaction = reaction_rate_limits[room_code].get(player_name, 0)
if current_time - last_reaction < REACTION_COOLDOWN_SECONDS:
# Player is rate limited, ignore the reaction
return
# Update last reaction time
reaction_rate_limits[room_code][player_name] = current_time
# Emit to ALL players in the room (both teams)
await sio.emit( await sio.emit(
"emoji_reaction", "receive_reaction",
{ {
"player_name": player["name"], "player_name": player_name,
"team": player["team"], "team": player["team"],
"emoji": emoji "emoji": emoji,
"timestamp": datetime.utcnow().isoformat()
}, },
room=room_code room=room_code
) )
# Keep old event name for backwards compatibility
@sio.event
async def emoji_reaction(sid, data):
"""Alias for send_reaction (backwards compatibility)."""
await send_reaction(sid, data)
@sio.event @sio.event
async def timer_expired(sid, data): async def timer_expired(sid, data):
"""Handle timer expiration.""" """Handle timer expiration."""

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 Game from './pages/Game'
import Results from './pages/Results' import Results from './pages/Results'
import Replay from './pages/Replay' 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() { function App() {
return ( return (
@@ -24,6 +24,8 @@ function App() {
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="questions" element={<Questions />} /> <Route path="questions" element={<Questions />} />
<Route path="calendar" element={<Calendar />} /> <Route path="calendar" element={<Calendar />} />
<Route path="monitor" element={<Monitor />} />
<Route path="settings" element={<Settings />} />
</Route> </Route>
</Routes> </Routes>
</div> </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 { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { useGameStore } from '../stores/gameStore' 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 { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
import type { Reaction } from '../stores/gameStore'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000' 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() { export function useSocket() {
const socketRef = useRef<Socket | null>(null) const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } = const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
useGameStore() useGameStore()
// Initialize sound player with current theme
const currentTheme = useThemeStore.getState().currentTheme
soundPlayer.loadTheme(currentTheme)
useEffect(() => { useEffect(() => {
// Create socket connection // Create socket connection
socketRef.current = io(SOCKET_URL, { socketRef.current = io(SOCKET_URL, {
@@ -61,14 +77,27 @@ export function useSocket() {
socket.on('answer_result', (data: AnswerResult) => { socket.on('answer_result', (data: AnswerResult) => {
setRoom(data.room) 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) { if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true) setShowStealPrompt(true)
} }
}) })
socket.on('steal_attempted', (data: { room: GameRoom }) => { socket.on('steal_attempted', (data: { room: GameRoom; success?: boolean }) => {
setRoom(data.room) setRoom(data.room)
setShowStealPrompt(false) 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 }) => { 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 }) => { socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => {
// Handle emoji reaction display // Legacy handler - redirect to new reaction system
console.log(`${data.player_name} reacted with ${data.emoji}`) 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: { socket.on('game_finished', (data: {
@@ -107,6 +151,18 @@ export function useSocket() {
}> }>
}) => { }) => {
setRoom(data.room) 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({ setGameResult({
winner: data.winner, winner: data.winner,
finalScores: data.final_scores, finalScores: data.final_scores,
@@ -122,7 +178,7 @@ export function useSocket() {
return () => { return () => {
socket.disconnect() socket.disconnect()
} }
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult]) }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
// Socket methods // Socket methods
const createRoom = useCallback((playerName: string) => { const createRoom = useCallback((playerName: string) => {
@@ -179,6 +235,26 @@ export function useSocket() {
socketRef.current?.emit('emoji_reaction', { emoji }) 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(() => { const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {}) socketRef.current?.emit('timer_expired', {})
}, []) }, [])
@@ -194,6 +270,8 @@ export function useSocket() {
stealDecision, stealDecision,
sendChatMessage, sendChatMessage,
sendEmojiReaction, sendEmojiReaction,
sendReaction,
sendTeamMessage,
notifyTimerExpired, notifyTimerExpired,
} }
} }

View File

@@ -1,22 +1,63 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Howl } from 'howler' import { Howl } from 'howler'
import { useSoundStore, soundPaths } from '../stores/soundStore' import { useSoundStore, soundPaths, fallbackSoundConfigs, type SoundEffect } from '../stores/soundStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
type SoundEffect = // Re-export SoundEffect type for convenience
| 'correct' export type { SoundEffect }
| 'incorrect'
| 'steal' // Audio context for fallback sounds
| 'timer_tick' let audioContext: AudioContext | null = null
| 'timer_urgent'
| 'victory' function getAudioContext(): AudioContext {
| 'defeat' if (!audioContext) {
| 'select' 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() { export function useSound() {
const { volume, muted } = useSoundStore() const { volume, muted, setSoundsLoaded, setCurrentLoadedTheme } = useSoundStore()
const { currentTheme } = useThemeStore() const { currentTheme } = useThemeStore()
const soundsRef = useRef<Map<string, Howl>>(new Map()) const soundsRef = useRef<Map<string, Howl>>(new Map())
const loadedCountRef = useRef(0)
const failedSoundsRef = useRef<Set<string>>(new Set())
// Preload sounds for current theme // Preload sounds for current theme
useEffect(() => { useEffect(() => {
@@ -26,15 +67,36 @@ export function useSound() {
// Clear old sounds // Clear old sounds
soundsRef.current.forEach((sound) => sound.unload()) soundsRef.current.forEach((sound) => sound.unload())
soundsRef.current.clear() 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 // Load new sounds
Object.entries(themeSounds).forEach(([key, path]) => { soundEntries.forEach(([key, path]) => {
const sound = new Howl({ const sound = new Howl({
src: [path], src: [path],
volume: volume, volume: volume,
preload: true, preload: true,
onloaderror: () => { onload: () => {
console.warn(`Failed to load sound: ${path}`) 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) soundsRef.current.set(key, sound)
@@ -43,7 +105,7 @@ export function useSound() {
return () => { return () => {
soundsRef.current.forEach((sound) => sound.unload()) soundsRef.current.forEach((sound) => sound.unload())
} }
}, [currentTheme]) }, [currentTheme, setSoundsLoaded, setCurrentLoadedTheme])
// Update volume when it changes // Update volume when it changes
useEffect(() => { useEffect(() => {
@@ -57,11 +119,16 @@ export function useSound() {
if (muted) return if (muted) return
const sound = soundsRef.current.get(effect) const sound = soundsRef.current.get(effect)
if (sound) {
// If sound loaded successfully, play it
if (sound && sound.state() === 'loaded') {
sound.play() 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) => { const stop = useCallback((effect: SoundEffect) => {
@@ -75,9 +142,88 @@ export function useSound() {
soundsRef.current.forEach((sound) => sound.stop()) 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 { return {
play, play,
stop, stop,
stopAll, 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 { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore' import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider' 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' import type { Question } from '../types'
const categories = [ const categories = [
@@ -21,9 +25,9 @@ const categories = [
export default function Game() { export default function Game() {
useParams<{ roomCode: string }>() useParams<{ roomCode: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket() const { selectQuestion, submitAnswer, stealDecision, sendTeamMessage } = useSocket()
const { play } = useSound() const { play } = useSound()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore() const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt, teamMessages } = useGameStore()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
const [answer, setAnswer] = useState('') const [answer, setAnswer] = useState('')
@@ -37,7 +41,7 @@ export default function Game() {
} }
}, [room?.status, room?.code, navigate]) }, [room?.status, room?.code, navigate])
// Timer logic // Timer logic with sound effects
useEffect(() => { useEffect(() => {
if (!currentQuestion || !showingQuestion) return if (!currentQuestion || !showingQuestion) return
@@ -48,7 +52,13 @@ export default function Game() {
clearInterval(interval) clearInterval(interval)
return 0 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 return prev - 1
}) })
}, 1000) }, 1000)
@@ -95,7 +105,15 @@ export default function Game() {
setShowStealPrompt(false) 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 ( return (
<div className="min-h-screen p-4" style={styles.bgPrimary}> <div className="min-h-screen p-4" style={styles.bgPrimary}>
@@ -316,20 +334,30 @@ export default function Game() {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Emoji Reactions */} {/* Emoji Reactions Bar - Fixed at bottom */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex gap-2"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
{emojis.map((emoji) => ( <EmojiReactions />
<button </div>
key={emoji}
onClick={() => sendEmojiReaction(emoji)} {/* Sound Control - Fixed at top right */}
className="text-2xl p-2 rounded-lg transition-transform hover:scale-125" <div className="fixed top-4 right-4 z-30">
style={{ backgroundColor: config.colors.bg + '80' }} <SoundControl compact popupPosition="bottom" />
>
{emoji}
</button>
))}
</div> </div>
</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> </div>
) )
} }

View File

@@ -6,6 +6,8 @@ const navItems = [
{ path: '/admin/dashboard', label: 'Dashboard', icon: '📊' }, { path: '/admin/dashboard', label: 'Dashboard', icon: '📊' },
{ path: '/admin/questions', label: 'Preguntas', icon: '❓' }, { path: '/admin/questions', label: 'Preguntas', icon: '❓' },
{ path: '/admin/calendar', label: 'Calendario', icon: '📅' }, { path: '/admin/calendar', label: 'Calendario', icon: '📅' },
{ path: '/admin/monitor', label: 'Monitor', icon: '🖥️' },
{ path: '/admin/settings', label: 'Configuracion', icon: '⚙️' },
] ]
export default function AdminLayout() { 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 { motion, AnimatePresence } from 'framer-motion'
import { useAdminStore } from '../../stores/adminStore' import { useAdminStore } from '../../stores/adminStore'
import { import {
getQuestions, getCategories, createQuestion, updateQuestion, getQuestions, getCategories, createQuestion, updateQuestion,
deleteQuestion, generateQuestions, approveQuestion, rejectQuestion deleteQuestion, generateQuestions, approveQuestion, rejectQuestion,
exportQuestions, importQuestions, ImportResult
} from '../../services/adminApi' } from '../../services/adminApi'
import type { Category } from '../../types' import type { Category } from '../../types'
@@ -54,6 +55,13 @@ export default function Questions() {
}) })
const [generating, setGenerating] = useState(false) 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(() => { useEffect(() => {
fetchData() fetchData()
}, [token, filterCategory, filterStatus]) }, [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 getCategoryName = (id: number) => categories.find(c => c.id === id)?.name || 'Unknown'
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
@@ -183,6 +239,19 @@ export default function Questions() {
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-white">Preguntas</h1> <h1 className="text-2xl font-bold text-white">Preguntas</h1>
<div className="flex gap-2"> <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 <button
onClick={() => setShowGenerateModal(true)} onClick={() => setShowGenerateModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700" 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> </motion.div>
)} )}
</AnimatePresence> </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> </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 Dashboard } from './Dashboard'
export { default as Questions } from './Questions' export { default as Questions } from './Questions'
export { default as Calendar } from './Calendar' 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') if (!response.ok) throw new Error('Failed to fetch categories')
return response.json() 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 { create } from 'zustand'
import type { GameRoom, Question, ChatMessage, Achievement } from '../types' 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 { interface GameState {
// Room state // Room state
room: GameRoom | null room: GameRoom | null
@@ -23,6 +40,11 @@ interface GameState {
addMessage: (message: ChatMessage) => void addMessage: (message: ChatMessage) => void
clearMessages: () => void clearMessages: () => void
// Team chat messages
teamMessages: TeamMessage[]
addTeamMessage: (message: TeamMessage) => void
clearTeamMessages: () => void
// Achievements // Achievements
achievements: Achievement[] achievements: Achievement[]
setAchievements: (achievements: Achievement[]) => void setAchievements: (achievements: Achievement[]) => void
@@ -44,6 +66,12 @@ interface GameState {
showStealPrompt: boolean showStealPrompt: boolean
setShowStealPrompt: (show: boolean) => void setShowStealPrompt: (show: boolean) => void
// Reactions
reactions: Reaction[]
addReaction: (reaction: Omit<Reaction, 'id'>) => void
removeReaction: (id: string) => void
clearReactions: () => void
// Game result // Game result
gameResult: { gameResult: {
winner: 'A' | 'B' | null winner: 'A' | 'B' | null
@@ -88,6 +116,13 @@ export const useGameStore = create<GameState>((set) => ({
set((state) => ({ messages: [...state.messages, message].slice(-100) })), set((state) => ({ messages: [...state.messages, message].slice(-100) })),
clearMessages: () => set({ messages: [] }), clearMessages: () => set({ messages: [] }),
teamMessages: [],
addTeamMessage: (message) =>
set((state) => ({
teamMessages: [...state.teamMessages, message].slice(-MAX_TEAM_MESSAGES),
})),
clearTeamMessages: () => set({ teamMessages: [] }),
achievements: [], achievements: [],
setAchievements: (achievements) => set({ achievements }), setAchievements: (achievements) => set({ achievements }),
unlockAchievement: (id) => unlockAchievement: (id) =>
@@ -105,6 +140,20 @@ export const useGameStore = create<GameState>((set) => ({
showStealPrompt: false, showStealPrompt: false,
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }), 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, gameResult: null,
setGameResult: (gameResult) => set({ gameResult }), setGameResult: (gameResult) => set({ gameResult }),
@@ -114,6 +163,8 @@ export const useGameStore = create<GameState>((set) => ({
currentQuestion: null, currentQuestion: null,
timerEnd: null, timerEnd: null,
messages: [], messages: [],
teamMessages: [],
reactions: [],
stats: initialStats, stats: initialStats,
showStealPrompt: false, showStealPrompt: false,
gameResult: null, gameResult: null,

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import type { ThemeName } from '../types' import type { ThemeName } from '../types'
type SoundEffect = export type SoundEffect =
| 'correct' | 'correct'
| 'incorrect' | 'incorrect'
| 'steal' | 'steal'
@@ -15,9 +15,13 @@ type SoundEffect =
interface SoundState { interface SoundState {
volume: number volume: number
muted: boolean muted: boolean
soundsLoaded: boolean
currentLoadedTheme: ThemeName | null
setVolume: (volume: number) => void setVolume: (volume: number) => void
setMuted: (muted: boolean) => void setMuted: (muted: boolean) => void
toggleMute: () => void toggleMute: () => void
setSoundsLoaded: (loaded: boolean) => void
setCurrentLoadedTheme: (theme: ThemeName | null) => void
} }
export const useSoundStore = create<SoundState>()( export const useSoundStore = create<SoundState>()(
@@ -25,17 +29,23 @@ export const useSoundStore = create<SoundState>()(
(set) => ({ (set) => ({
volume: 0.7, volume: 0.7,
muted: false, 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 }), setMuted: (muted) => set({ muted }),
toggleMute: () => set((state) => ({ muted: !state.muted })), toggleMute: () => set((state) => ({ muted: !state.muted })),
setSoundsLoaded: (soundsLoaded) => set({ soundsLoaded }),
setCurrentLoadedTheme: (currentLoadedTheme) => set({ currentLoadedTheme }),
}), }),
{ {
name: 'trivia-sound', name: 'trivia-sound',
partialize: (state) => ({ volume: state.volume, muted: state.muted }),
} }
) )
) )
// Sound file paths per theme // 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>> = { export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
drrr: { drrr: {
correct: '/sounds/drrr/correct.mp3', correct: '/sounds/drrr/correct.mp3',
@@ -88,3 +98,16 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
select: '/sounds/anime/select.mp3', 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' },
}