From 720432702f72786951d67d2e14ee6f2a333aea7f Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 26 Jan 2026 08:58:33 +0000 Subject: [PATCH] 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 --- backend/app/api/admin.py | 354 ++++++++++++++++- backend/app/data/config.json | 19 + backend/app/schemas/game_config.py | 22 + backend/app/services/game_config.py | 78 ++++ backend/app/sockets/game_events.py | 82 +++- frontend/public/sounds/README.md | 67 ++++ frontend/src/App.tsx | 4 +- .../src/components/chat/EmojiReactions.tsx | 89 +++++ .../src/components/chat/ReactionOverlay.tsx | 116 ++++++ frontend/src/components/chat/TeamChat.tsx | 341 ++++++++++++++++ frontend/src/components/ui/SoundControl.tsx | 199 ++++++++++ frontend/src/components/ui/index.ts | 1 + frontend/src/hooks/useSocket.ts | 88 +++- frontend/src/hooks/useSound.ts | 180 ++++++++- frontend/src/pages/Game.tsx | 62 ++- frontend/src/pages/admin/AdminLayout.tsx | 2 + frontend/src/pages/admin/Monitor.tsx | 375 ++++++++++++++++++ frontend/src/pages/admin/Questions.tsx | 182 ++++++++- frontend/src/pages/admin/Settings.tsx | 315 +++++++++++++++ frontend/src/pages/admin/index.ts | 2 + frontend/src/services/adminApi.ts | 148 +++++++ frontend/src/stores/gameStore.ts | 51 +++ frontend/src/stores/soundStore.ts | 27 +- 23 files changed, 2753 insertions(+), 51 deletions(-) create mode 100644 backend/app/data/config.json create mode 100644 backend/app/schemas/game_config.py create mode 100644 backend/app/services/game_config.py create mode 100644 frontend/public/sounds/README.md create mode 100644 frontend/src/components/chat/EmojiReactions.tsx create mode 100644 frontend/src/components/chat/ReactionOverlay.tsx create mode 100644 frontend/src/components/chat/TeamChat.tsx create mode 100644 frontend/src/components/ui/SoundControl.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/pages/admin/Monitor.tsx create mode 100644 frontend/src/pages/admin/Settings.tsx diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 2018d32..c7aaa6d 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext from typing import List +import csv +import json +from io import StringIO from app.models.base import get_db from app.models.admin import Admin @@ -17,6 +21,9 @@ from app.schemas.question import ( AIGenerateRequest ) 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 router = APIRouter() @@ -294,3 +301,348 @@ async def create_category( await db.commit() await db.refresh(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) + } diff --git a/backend/app/data/config.json b/backend/app/data/config.json new file mode 100644 index 0000000..5c24c06 --- /dev/null +++ b/backend/app/data/config.json @@ -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 +} diff --git a/backend/app/schemas/game_config.py b/backend/app/schemas/game_config.py new file mode 100644 index 0000000..a239ad4 --- /dev/null +++ b/backend/app/schemas/game_config.py @@ -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 + } + } diff --git a/backend/app/services/game_config.py b/backend/app/services/game_config.py new file mode 100644 index 0000000..1581a87 --- /dev/null +++ b/backend/app/services/game_config.py @@ -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"] diff --git a/backend/app/sockets/game_events.py b/backend/app/sockets/game_events.py index 662907c..db512d1 100644 --- a/backend/app/sockets/game_events.py +++ b/backend/app/sockets/game_events.py @@ -1,4 +1,5 @@ import socketio +import time from datetime import datetime from app.services.room_manager import room_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 +# 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(): """Helper para obtener sesion de BD en contexto de socket.""" AsyncSessionLocal = get_async_session() @@ -353,13 +359,56 @@ def register_socket_events(sio: socketio.AsyncServer): ) @sio.event - async def emoji_reaction(sid, data): - """Send an emoji reaction visible to all.""" + async def team_message(sid, data): + """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) if not player: 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", "") # Validate emoji @@ -367,16 +416,37 @@ def register_socket_events(sio: socketio.AsyncServer): if emoji not in allowed_emojis: 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( - "emoji_reaction", + "receive_reaction", { - "player_name": player["name"], + "player_name": player_name, "team": player["team"], - "emoji": emoji + "emoji": emoji, + "timestamp": datetime.utcnow().isoformat() }, 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 async def timer_expired(sid, data): """Handle timer expiration.""" diff --git a/frontend/public/sounds/README.md b/frontend/public/sounds/README.md new file mode 100644 index 0000000..c54d205 --- /dev/null +++ b/frontend/public/sounds/README.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 133e222..6e04725 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import Lobby from './pages/Lobby' import Game from './pages/Game' import Results from './pages/Results' import Replay from './pages/Replay' -import { AdminLayout, Login, Dashboard, Questions, Calendar } from './pages/admin' +import { AdminLayout, Login, Dashboard, Questions, Calendar, Settings, Monitor } from './pages/admin' function App() { return ( @@ -24,6 +24,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/chat/EmojiReactions.tsx b/frontend/src/components/chat/EmojiReactions.tsx new file mode 100644 index 0000000..66a45fc --- /dev/null +++ b/frontend/src/components/chat/EmojiReactions.tsx @@ -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 ( + + {EMOJIS.map((emoji) => ( + 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} + + ))} + + {/* Cooldown indicator */} + {isDisabled && ( + + )} + + ) +} diff --git a/frontend/src/components/chat/ReactionOverlay.tsx b/frontend/src/components/chat/ReactionOverlay.tsx new file mode 100644 index 0000000..0b0952d --- /dev/null +++ b/frontend/src/components/chat/ReactionOverlay.tsx @@ -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>>(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() + reactions.forEach((reaction) => { + if (!positions.has(reaction.id)) { + positions.set(reaction.id, 10 + Math.random() * 80) + } + }) + return positions + }, [reactions]) + + return ( +
+ + {reactions.map((reaction) => { + // Use pre-calculated random horizontal position (10% to 90% of screen width) + const randomX = reactionPositions.get(reaction.id) || 50 + + return ( + + {/* Emoji */} + + {reaction.emoji} + + + {/* Player name badge */} + + {reaction.player_name} + + + ) + })} + +
+ ) +} diff --git a/frontend/src/components/chat/TeamChat.tsx b/frontend/src/components/chat/TeamChat.tsx new file mode 100644 index 0000000..9d1b2fe --- /dev/null +++ b/frontend/src/components/chat/TeamChat.tsx @@ -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([]) + const messagesEndRef = useRef(null) + const inputRef = useRef(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) => { + 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 */} + 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'} + > + + + + {localMessages.length > 0 && !isOpen && ( + + {localMessages.length > 9 ? '9+' : localMessages.length} + + )} + + + {/* Chat Panel */} + + {isOpen && ( + + {/* Header */} +
+
+ + Chat Equipo {team} + + + {roomCode} + +
+ +
+ + {/* Messages List */} +
+ {localMessages.length === 0 ? ( +
+ No hay mensajes aun. +
+ Escribe algo para tu equipo. +
+ ) : ( + localMessages.map((msg, index) => ( + +
+ + {msg.player_name === playerName ? 'Tu' : msg.player_name} + + + {formatTimestamp(msg.timestamp)} + +
+

+ {msg.message} +

+
+ )) + )} +
+
+ + {/* Input Area */} +
+
+ 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`, + }} + /> + +
+
+ {inputMessage.length}/500 +
+
+ + )} + + + {/* Backdrop overlay when chat is open */} + + {isOpen && ( + setIsOpen(false)} + className="fixed inset-0 bg-black/30 z-40 md:hidden" + /> + )} + + + ) +} diff --git a/frontend/src/components/ui/SoundControl.tsx b/frontend/src/components/ui/SoundControl.tsx new file mode 100644 index 0000000..6a59195 --- /dev/null +++ b/frontend/src/components/ui/SoundControl.tsx @@ -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(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 ( + + + + ) + } + if (volume < 0.33) { + return ( + + + + ) + } + if (volume < 0.66) { + return ( + + + + + ) + } + return ( + + + + + + ) + } + + 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 ( +
+ + 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)}%`} + /> + + {Math.round((muted ? 0 : volume) * 100)}% + +
+ ) + } + + return ( +
+ + + + {isOpen && ( + +
+ + 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%)`, + }} + /> + + {Math.round((muted ? 0 : volume) * 100)}% + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..4d8663c --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1 @@ +export { default as SoundControl } from './SoundControl' diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index d9f8eea..4e6b46c 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -1,15 +1,31 @@ import { useEffect, useRef, useCallback } from 'react' import { io, Socket } from 'socket.io-client' import { useGameStore } from '../stores/gameStore' +import { soundPlayer } from './useSound' +import { useThemeStore } from '../stores/themeStore' +import { useSoundStore } from '../stores/soundStore' import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types' +import type { Reaction } from '../stores/gameStore' const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000' +// Team message type +export interface TeamMessage { + player_name: string + team: 'A' | 'B' + message: string + timestamp: string +} + export function useSocket() { const socketRef = useRef(null) - const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } = + const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } = useGameStore() + // Initialize sound player with current theme + const currentTheme = useThemeStore.getState().currentTheme + soundPlayer.loadTheme(currentTheme) + useEffect(() => { // Create socket connection socketRef.current = io(SOCKET_URL, { @@ -61,14 +77,27 @@ export function useSocket() { socket.on('answer_result', (data: AnswerResult) => { setRoom(data.room) + + // Play appropriate sound based on answer result + const volume = useSoundStore.getState().volume + if (data.valid) { + soundPlayer.play('correct', volume) + } else { + soundPlayer.play('incorrect', volume) + } + if (!data.valid && !data.was_steal && data.room.can_steal) { setShowStealPrompt(true) } }) - socket.on('steal_attempted', (data: { room: GameRoom }) => { + socket.on('steal_attempted', (data: { room: GameRoom; success?: boolean }) => { setRoom(data.room) setShowStealPrompt(false) + + // Play steal sound when a steal is attempted + const volume = useSoundStore.getState().volume + soundPlayer.play('steal', volume) }) socket.on('steal_passed', (data: { room: GameRoom }) => { @@ -91,8 +120,23 @@ export function useSocket() { }) socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => { - // Handle emoji reaction display - console.log(`${data.player_name} reacted with ${data.emoji}`) + // Legacy handler - redirect to new reaction system + addReaction({ + player_name: data.player_name, + team: data.team as 'A' | 'B', + emoji: data.emoji, + timestamp: new Date().toISOString(), + }) + }) + + socket.on('receive_reaction', (data: Omit) => { + // Add reaction to the store for display in overlay + addReaction(data) + }) + + // Team chat events + socket.on('receive_team_message', (data: TeamMessage) => { + addTeamMessage(data) }) socket.on('game_finished', (data: { @@ -107,6 +151,18 @@ export function useSocket() { }> }) => { setRoom(data.room) + + // Determine if current player is on the winning team + const currentPlayerName = useGameStore.getState().playerName + const myTeam = data.room.teams.A.find(p => p.name === currentPlayerName) ? 'A' : 'B' + const volume = useSoundStore.getState().volume + + if (data.winner === myTeam) { + soundPlayer.play('victory', volume) + } else if (data.winner !== null) { + soundPlayer.play('defeat', volume) + } + setGameResult({ winner: data.winner, finalScores: data.final_scores, @@ -122,7 +178,7 @@ export function useSocket() { return () => { socket.disconnect() } - }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult]) + }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage]) // Socket methods const createRoom = useCallback((playerName: string) => { @@ -179,6 +235,26 @@ export function useSocket() { socketRef.current?.emit('emoji_reaction', { emoji }) }, []) + const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => { + socketRef.current?.emit('send_reaction', { + emoji, + room_code: roomCode, + player_name: playerName, + }) + }, []) + + const sendTeamMessage = useCallback( + (message: string, roomCode: string, team: 'A' | 'B', playerName: string) => { + socketRef.current?.emit('team_message', { + room_code: roomCode, + team, + player_name: playerName, + message, + }) + }, + [] + ) + const notifyTimerExpired = useCallback(() => { socketRef.current?.emit('timer_expired', {}) }, []) @@ -194,6 +270,8 @@ export function useSocket() { stealDecision, sendChatMessage, sendEmojiReaction, + sendReaction, + sendTeamMessage, notifyTimerExpired, } } diff --git a/frontend/src/hooks/useSound.ts b/frontend/src/hooks/useSound.ts index 952061c..e68af18 100644 --- a/frontend/src/hooks/useSound.ts +++ b/frontend/src/hooks/useSound.ts @@ -1,22 +1,63 @@ import { useCallback, useEffect, useRef } from 'react' import { Howl } from 'howler' -import { useSoundStore, soundPaths } from '../stores/soundStore' +import { useSoundStore, soundPaths, fallbackSoundConfigs, type SoundEffect } from '../stores/soundStore' import { useThemeStore } from '../stores/themeStore' -type SoundEffect = - | 'correct' - | 'incorrect' - | 'steal' - | 'timer_tick' - | 'timer_urgent' - | 'victory' - | 'defeat' - | 'select' +// Re-export SoundEffect type for convenience +export type { SoundEffect } + +// Audio context for fallback sounds +let audioContext: AudioContext | null = null + +function getAudioContext(): AudioContext { + if (!audioContext) { + audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + } + return audioContext +} + +// Play a fallback sound using Web Audio API +function playFallbackSound(effect: SoundEffect, volume: number): void { + try { + const ctx = getAudioContext() + const config = fallbackSoundConfigs[effect] + + const oscillator = ctx.createOscillator() + const gainNode = ctx.createGain() + + oscillator.type = config.type + oscillator.frequency.setValueAtTime(config.frequency, ctx.currentTime) + + // Victory and defeat have melody-like patterns + if (effect === 'victory') { + oscillator.frequency.setValueAtTime(523, ctx.currentTime) + oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.15) + oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.3) + } else if (effect === 'defeat') { + oscillator.frequency.setValueAtTime(392, ctx.currentTime) + oscillator.frequency.setValueAtTime(330, ctx.currentTime + 0.15) + oscillator.frequency.setValueAtTime(262, ctx.currentTime + 0.3) + } + + gainNode.gain.setValueAtTime(volume * 0.3, ctx.currentTime) + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + config.duration) + + oscillator.connect(gainNode) + gainNode.connect(ctx.destination) + + oscillator.start(ctx.currentTime) + oscillator.stop(ctx.currentTime + config.duration) + } catch (error) { + console.warn('Failed to play fallback sound:', error) + } +} export function useSound() { - const { volume, muted } = useSoundStore() + const { volume, muted, setSoundsLoaded, setCurrentLoadedTheme } = useSoundStore() const { currentTheme } = useThemeStore() const soundsRef = useRef>(new Map()) + const loadedCountRef = useRef(0) + const failedSoundsRef = useRef>(new Set()) // Preload sounds for current theme useEffect(() => { @@ -26,15 +67,36 @@ export function useSound() { // Clear old sounds soundsRef.current.forEach((sound) => sound.unload()) soundsRef.current.clear() + failedSoundsRef.current.clear() + loadedCountRef.current = 0 + setSoundsLoaded(false) + setCurrentLoadedTheme(null) + + const soundEntries = Object.entries(themeSounds) + const totalSounds = soundEntries.length // Load new sounds - Object.entries(themeSounds).forEach(([key, path]) => { + soundEntries.forEach(([key, path]) => { const sound = new Howl({ src: [path], volume: volume, preload: true, - onloaderror: () => { - console.warn(`Failed to load sound: ${path}`) + onload: () => { + loadedCountRef.current++ + if (loadedCountRef.current >= totalSounds - failedSoundsRef.current.size) { + setSoundsLoaded(true) + setCurrentLoadedTheme(currentTheme) + } + }, + onloaderror: (_id, error) => { + console.warn(`Failed to load sound: ${path}`, error) + failedSoundsRef.current.add(key) + loadedCountRef.current++ + // Still mark as loaded even with failures (will use fallback) + if (loadedCountRef.current >= totalSounds) { + setSoundsLoaded(true) + setCurrentLoadedTheme(currentTheme) + } }, }) soundsRef.current.set(key, sound) @@ -43,7 +105,7 @@ export function useSound() { return () => { soundsRef.current.forEach((sound) => sound.unload()) } - }, [currentTheme]) + }, [currentTheme, setSoundsLoaded, setCurrentLoadedTheme]) // Update volume when it changes useEffect(() => { @@ -57,11 +119,16 @@ export function useSound() { if (muted) return const sound = soundsRef.current.get(effect) - if (sound) { + + // If sound loaded successfully, play it + if (sound && sound.state() === 'loaded') { sound.play() + } else if (failedSoundsRef.current.has(effect) || !sound || sound.state() !== 'loaded') { + // Use fallback Web Audio API sound + playFallbackSound(effect, volume) } }, - [muted] + [muted, volume] ) const stop = useCallback((effect: SoundEffect) => { @@ -75,9 +142,88 @@ export function useSound() { soundsRef.current.forEach((sound) => sound.stop()) }, []) + // Convenience method for playing tick sounds (every second) + const playTick = useCallback( + (timeRemaining: number, urgentThreshold: number = 5) => { + if (muted) return + + if (timeRemaining <= urgentThreshold && timeRemaining > 0) { + play('timer_urgent') + } else if (timeRemaining > urgentThreshold) { + play('timer_tick') + } + }, + [muted, play] + ) + return { play, stop, stopAll, + playTick, + volume, + muted, } } + +// Singleton sound player for use outside of React components +// Useful for playing sounds from socket event handlers +class SoundPlayer { + private static instance: SoundPlayer | null = null + private sounds: Map = new Map() + private currentTheme: string | null = null + private failedSounds: Set = 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() diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx index f3c4193..f468527 100644 --- a/frontend/src/pages/Game.tsx +++ b/frontend/src/pages/Game.tsx @@ -5,6 +5,10 @@ import { useSocket } from '../hooks/useSocket' import { useSound } from '../hooks/useSound' import { useGameStore } from '../stores/gameStore' import { useThemeStyles } from '../themes/ThemeProvider' +import EmojiReactions from '../components/chat/EmojiReactions' +import ReactionOverlay from '../components/chat/ReactionOverlay' +import TeamChat from '../components/chat/TeamChat' +import SoundControl from '../components/ui/SoundControl' import type { Question } from '../types' const categories = [ @@ -21,9 +25,9 @@ const categories = [ export default function Game() { useParams<{ roomCode: string }>() const navigate = useNavigate() - const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket() + const { selectQuestion, submitAnswer, stealDecision, sendTeamMessage } = useSocket() const { play } = useSound() - const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore() + const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt, teamMessages } = useGameStore() const { config, styles } = useThemeStyles() const [answer, setAnswer] = useState('') @@ -37,7 +41,7 @@ export default function Game() { } }, [room?.status, room?.code, navigate]) - // Timer logic + // Timer logic with sound effects useEffect(() => { if (!currentQuestion || !showingQuestion) return @@ -48,7 +52,13 @@ export default function Game() { clearInterval(interval) return 0 } - if (prev === 6) play('timer_urgent') + // Play urgent sound when time is running low (5 seconds or less) + if (prev <= 6 && prev > 1) { + play('timer_urgent') + } else if (prev > 6) { + // Play tick sound for normal countdown + play('timer_tick') + } return prev - 1 }) }, 1000) @@ -95,7 +105,15 @@ export default function Game() { setShowStealPrompt(false) } - const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔'] + // Handler for sending team messages + const handleSendTeamMessage = (message: string) => { + if (room && playerName && myTeam) { + sendTeamMessage(message, room.code, myTeam, playerName) + } + } + + // Determine if the game is active (playing status) + const isGameActive = room.status === 'playing' return (
@@ -316,20 +334,30 @@ export default function Game() { )} - {/* Emoji Reactions */} -
- {emojis.map((emoji) => ( - - ))} + {/* Emoji Reactions Bar - Fixed at bottom */} +
+ +
+ + {/* Sound Control - Fixed at top right */} +
+
+ + {/* Reaction Overlay - Full screen overlay for floating reactions */} + + + {/* Team Chat - Only visible during the game */} + {isGameActive && ( + + )}
) } diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 44ecd33..3578c11 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -6,6 +6,8 @@ const navItems = [ { path: '/admin/dashboard', label: 'Dashboard', icon: '📊' }, { path: '/admin/questions', label: 'Preguntas', icon: '❓' }, { path: '/admin/calendar', label: 'Calendario', icon: '📅' }, + { path: '/admin/monitor', label: 'Monitor', icon: '🖥️' }, + { path: '/admin/settings', label: 'Configuracion', icon: '⚙️' }, ] export default function AdminLayout() { diff --git a/frontend/src/pages/admin/Monitor.tsx b/frontend/src/pages/admin/Monitor.tsx new file mode 100644 index 0000000..1b0f02a --- /dev/null +++ b/frontend/src/pages/admin/Monitor.tsx @@ -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 = { + 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 = { + waiting: 'Esperando', + playing: 'Jugando', + finished: 'Finalizado', + unknown: 'Desconocido' + } + + return ( + + {labels[status] || status} + + ) +} + +// 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 ( +
+ +

+ Cerrar Sala +

+

+ Estas a punto de cerrar la sala {roomCode}. +

+

+ Esta accion desconectara a {playersCount} jugador{playersCount !== 1 ? 'es' : ''} y eliminara todos los datos de la partida. +

+
+ + +
+
+
+ ) +} + +export default function Monitor() { + const { token } = useAdminStore() + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [lastUpdate, setLastUpdate] = useState(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(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 ( +
+
+
+

Monitor de Salas

+

+ Ultima actualizacion: {lastUpdate.toLocaleTimeString()} +

+
+ +
+ + {/* Stats Cards */} +
+ +

Total Salas

+

{rooms.length}

+
+ + +

Jugando

+

{playingRooms}

+
+ + +

Esperando

+

{waitingRooms}

+
+ + +

Jugadores Activos

+

{totalPlayers}

+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+
+ + + + + + + + + + + + + + + + {loading && rooms.length === 0 ? ( + + + + ) : rooms.length === 0 ? ( + + + + ) : ( + rooms.map((room) => ( + + + + + + + + + + + )) + )} + + +
+ Codigo + + Jugadores + + Equipo A + + Equipo B + + Estado + + Tiempo + + Host + + Acciones +
+ Cargando salas... +
+ No hay salas activas en este momento +
+ + {room.room_code} + + + {room.players_count} + + + {room.teams.A} + {room.status === 'playing' && ( + + ({room.scores.A} pts) + + )} + + + + {room.teams.B} + {room.status === 'playing' && ( + + ({room.scores.B} pts) + + )} + + + + + {formatTTL(room.ttl_seconds)} + + {room.host} + + +
+
+
+ + {/* Auto-refresh indicator */} +

+ Los datos se actualizan automaticamente cada 5 segundos +

+ + {/* Confirm Modal */} + + setConfirmModal({ isOpen: false, roomCode: '', playersCount: 0 })} + onConfirm={confirmCloseRoom} + roomCode={confirmModal.roomCode} + playersCount={confirmModal.playersCount} + /> + +
+ ) +} diff --git a/frontend/src/pages/admin/Questions.tsx b/frontend/src/pages/admin/Questions.tsx index 4c3b524..8ad348b 100644 --- a/frontend/src/pages/admin/Questions.tsx +++ b/frontend/src/pages/admin/Questions.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useAdminStore } from '../../stores/adminStore' import { getQuestions, getCategories, createQuestion, updateQuestion, - deleteQuestion, generateQuestions, approveQuestion, rejectQuestion + deleteQuestion, generateQuestions, approveQuestion, rejectQuestion, + exportQuestions, importQuestions, ImportResult } from '../../services/adminApi' import type { Category } from '../../types' @@ -54,6 +55,13 @@ export default function Questions() { }) const [generating, setGenerating] = useState(false) + // Import/Export state + const [showImportModal, setShowImportModal] = useState(false) + const [importing, setImporting] = useState(false) + const [exporting, setExporting] = useState(false) + const [importResult, setImportResult] = useState(null) + const fileInputRef = useRef(null) + useEffect(() => { fetchData() }, [token, filterCategory, filterStatus]) @@ -168,6 +176,54 @@ export default function Questions() { } } + const handleExport = async () => { + if (!token) return + setExporting(true) + try { + await exportQuestions(token, { + categoryId: filterCategory || undefined, + status: filterStatus || undefined + }) + } catch (error) { + console.error('Error exporting:', error) + alert('Error al exportar preguntas') + } finally { + setExporting(false) + } + } + + const handleImportClick = () => { + setImportResult(null) + setShowImportModal(true) + } + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file || !token) return + + setImporting(true) + try { + const result = await importQuestions(token, file) + setImportResult(result) + if (result.imported > 0) { + setFilterStatus('pending') + fetchData() + } + } catch (error) { + console.error('Error importing:', error) + setImportResult({ + imported: 0, + errors: [{ row: 0, error: error instanceof Error ? error.message : 'Error desconocido' }] + }) + } finally { + setImporting(false) + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + const getCategoryName = (id: number) => categories.find(c => c.id === id)?.name || 'Unknown' const getStatusBadge = (status: string) => { switch (status) { @@ -183,6 +239,19 @@ export default function Questions() {

Preguntas

+ + +
+ )} + +
+ +
+ + + )} +
) } diff --git a/frontend/src/pages/admin/Settings.tsx b/frontend/src/pages/admin/Settings.tsx new file mode 100644 index 0000000..3b5956e --- /dev/null +++ b/frontend/src/pages/admin/Settings.tsx @@ -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 + times_by_difficulty: Record + 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 = { + "1": "Muy Facil", + "2": "Facil", + "3": "Media", + "4": "Dificil", + "5": "Muy Dificil" +} + +export default function Settings() { + const { token } = useAdminStore() + const [settings, setSettings] = useState(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 ( +
+

Cargando configuracion...

+
+ ) + } + + return ( +
+
+

Configuracion del Juego

+ +
+ + {/* Feedback Message */} + {message && ( + + {message.text} + + )} + +
+ {/* Points by Difficulty */} + +

Puntos por Dificultad

+

+ Define cuantos puntos otorga cada nivel de dificultad al responder correctamente. +

+
+ {["1", "2", "3", "4", "5"].map(diff => ( +
+ + 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" + /> +
+ ))} +
+
+ + {/* Times by Difficulty */} + +

Tiempo por Dificultad (segundos)

+

+ Define cuantos segundos tiene el equipo para responder segun la dificultad. +

+
+ {["1", "2", "3", "4", "5"].map(diff => ( +
+ + 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" + /> +
+ ))} +
+
+ + {/* Steal Settings */} + +

Mecanica de Robo

+

+ Configuracion para cuando un equipo intenta robar puntos despues de una respuesta incorrecta. +

+
+
+ +

+ Porcentaje de puntos que se obtienen al robar (ej: 50% = mitad de puntos) +

+
+ setSettings(prev => ({ ...prev, steal_penalty_percent: parseInt(e.target.value) }))} + className="flex-1" + min="10" + max="100" + step="10" + /> + + {settings.steal_penalty_percent}% + +
+
+
+ +

+ Porcentaje del tiempo original para intentar el robo +

+
+ setSettings(prev => ({ ...prev, steal_time_percent: parseInt(e.target.value) }))} + className="flex-1" + min="10" + max="100" + step="10" + /> + + {settings.steal_time_percent}% + +
+
+
+
+ + {/* Team Settings */} + +

Configuracion de Equipos

+

+ Limites y reglas para los equipos en el juego. +

+
+ + 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" + /> +
+
+
+ + {/* Preview Section */} + +

Vista Previa

+
+ + + + + + + + + + + + {["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 ( + + + + + + + + ) + })} + +
DificultadPuntosTiempoRobo (pts)Robo (tiempo)
{difficultyLabels[diff]}{points} pts{time}s{stealPoints} pts{stealTime}s
+
+
+
+ ) +} diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index e99adb1..d0a6efc 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -3,3 +3,5 @@ export { default as AdminLayout } from './AdminLayout' export { default as Dashboard } from './Dashboard' export { default as Questions } from './Questions' export { default as Calendar } from './Calendar' +export { default as Settings } from './Settings' +export { default as Monitor } from './Monitor' diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 135005c..a54b0b5 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -138,3 +138,151 @@ export const getCategories = async (token: string) => { if (!response.ok) throw new Error('Failed to fetch categories') return response.json() } + +// CSV Import/Export +export const exportQuestions = async ( + token: string, + filters?: { categoryId?: number; status?: string } +) => { + const params = new URLSearchParams() + if (filters?.categoryId) params.append('category_id', String(filters.categoryId)) + if (filters?.status) params.append('status', filters.status) + + const response = await fetch(`${API_URL}/api/admin/questions/export?${params}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (!response.ok) throw new Error('Failed to export questions') + + // Get filename from Content-Disposition header or use default + const contentDisposition = response.headers.get('Content-Disposition') + let filename = 'questions_export.csv' + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^"]+)"?/) + if (match) filename = match[1] + } + + // Get blob and trigger download + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) +} + +export interface ImportResult { + imported: number + errors: Array<{ row: number; error: string }> +} + +export const importQuestions = async (token: string, file: File): Promise => { + 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 => { + 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 => { + 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 + times_by_difficulty: Record + steal_penalty_percent: number + max_players_per_team: number + steal_time_percent: number +} + +export const getSettings = async (token: string): Promise => { + 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 => { + 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() +} diff --git a/frontend/src/stores/gameStore.ts b/frontend/src/stores/gameStore.ts index 6679064..1253e46 100644 --- a/frontend/src/stores/gameStore.ts +++ b/frontend/src/stores/gameStore.ts @@ -1,6 +1,23 @@ import { create } from 'zustand' import type { GameRoom, Question, ChatMessage, Achievement } from '../types' +export interface Reaction { + id: string + player_name: string + team: 'A' | 'B' + emoji: string + timestamp: string +} + +export interface TeamMessage { + player_name: string + team: 'A' | 'B' + message: string + timestamp: string +} + +const MAX_TEAM_MESSAGES = 50 + interface GameState { // Room state room: GameRoom | null @@ -23,6 +40,11 @@ interface GameState { addMessage: (message: ChatMessage) => void clearMessages: () => void + // Team chat messages + teamMessages: TeamMessage[] + addTeamMessage: (message: TeamMessage) => void + clearTeamMessages: () => void + // Achievements achievements: Achievement[] setAchievements: (achievements: Achievement[]) => void @@ -44,6 +66,12 @@ interface GameState { showStealPrompt: boolean setShowStealPrompt: (show: boolean) => void + // Reactions + reactions: Reaction[] + addReaction: (reaction: Omit) => void + removeReaction: (id: string) => void + clearReactions: () => void + // Game result gameResult: { winner: 'A' | 'B' | null @@ -88,6 +116,13 @@ export const useGameStore = create((set) => ({ set((state) => ({ messages: [...state.messages, message].slice(-100) })), clearMessages: () => set({ messages: [] }), + teamMessages: [], + addTeamMessage: (message) => + set((state) => ({ + teamMessages: [...state.teamMessages, message].slice(-MAX_TEAM_MESSAGES), + })), + clearTeamMessages: () => set({ teamMessages: [] }), + achievements: [], setAchievements: (achievements) => set({ achievements }), unlockAchievement: (id) => @@ -105,6 +140,20 @@ export const useGameStore = create((set) => ({ showStealPrompt: false, setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }), + reactions: [], + addReaction: (reaction) => + set((state) => ({ + reactions: [ + ...state.reactions, + { ...reaction, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` }, + ], + })), + removeReaction: (id) => + set((state) => ({ + reactions: state.reactions.filter((r) => r.id !== id), + })), + clearReactions: () => set({ reactions: [] }), + gameResult: null, setGameResult: (gameResult) => set({ gameResult }), @@ -114,6 +163,8 @@ export const useGameStore = create((set) => ({ currentQuestion: null, timerEnd: null, messages: [], + teamMessages: [], + reactions: [], stats: initialStats, showStealPrompt: false, gameResult: null, diff --git a/frontend/src/stores/soundStore.ts b/frontend/src/stores/soundStore.ts index d343fe3..66687b6 100644 --- a/frontend/src/stores/soundStore.ts +++ b/frontend/src/stores/soundStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { ThemeName } from '../types' -type SoundEffect = +export type SoundEffect = | 'correct' | 'incorrect' | 'steal' @@ -15,9 +15,13 @@ type SoundEffect = interface SoundState { volume: number muted: boolean + soundsLoaded: boolean + currentLoadedTheme: ThemeName | null setVolume: (volume: number) => void setMuted: (muted: boolean) => void toggleMute: () => void + setSoundsLoaded: (loaded: boolean) => void + setCurrentLoadedTheme: (theme: ThemeName | null) => void } export const useSoundStore = create()( @@ -25,17 +29,23 @@ export const useSoundStore = create()( (set) => ({ volume: 0.7, muted: false, - setVolume: (volume) => set({ volume }), + soundsLoaded: false, + currentLoadedTheme: null, + setVolume: (volume) => set({ volume: Math.max(0, Math.min(1, volume)) }), setMuted: (muted) => set({ muted }), toggleMute: () => set((state) => ({ muted: !state.muted })), + setSoundsLoaded: (soundsLoaded) => set({ soundsLoaded }), + setCurrentLoadedTheme: (currentLoadedTheme) => set({ currentLoadedTheme }), }), { name: 'trivia-sound', + partialize: (state) => ({ volume: state.volume, muted: state.muted }), } ) ) // Sound file paths per theme +// All themes share the same base sounds but can be customized per theme export const soundPaths: Record> = { drrr: { correct: '/sounds/drrr/correct.mp3', @@ -88,3 +98,16 @@ export const soundPaths: Record> = { 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 = { + 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' }, +}