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:
@@ -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)
|
||||
}
|
||||
|
||||
19
backend/app/data/config.json
Normal file
19
backend/app/data/config.json
Normal 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
|
||||
}
|
||||
22
backend/app/schemas/game_config.py
Normal file
22
backend/app/schemas/game_config.py
Normal 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
|
||||
}
|
||||
}
|
||||
78
backend/app/services/game_config.py
Normal file
78
backend/app/services/game_config.py
Normal 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"]
|
||||
@@ -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."""
|
||||
|
||||
67
frontend/public/sounds/README.md
Normal file
67
frontend/public/sounds/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Sound Assets for WebTriviasMulti
|
||||
|
||||
This directory contains theme-specific sound effects for the trivia game.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
sounds/
|
||||
drrr/ # DRRR (Dollars) theme - cyberpunk/urban style
|
||||
retro/ # Retro Arcade theme - 8-bit style sounds
|
||||
minimal/ # Minimal theme - subtle, clean sounds
|
||||
rgb/ # Gaming RGB theme - electronic/synthwave
|
||||
anime/ # Anime 90s theme - kawaii/bright sounds
|
||||
```
|
||||
|
||||
## Required Sound Files
|
||||
|
||||
Each theme directory should contain the following MP3 files:
|
||||
|
||||
| File | Purpose | Duration | Notes |
|
||||
|------|---------|----------|-------|
|
||||
| `correct.mp3` | Played when answer is correct | ~0.5s | Positive, rewarding tone |
|
||||
| `incorrect.mp3` | Played when answer is wrong | ~0.5s | Negative but not harsh |
|
||||
| `steal.mp3` | Played when steal opportunity arises | ~0.5s | Tense, exciting |
|
||||
| `tick.mp3` | Timer countdown tick | ~0.1s | Subtle, not annoying |
|
||||
| `urgent.mp3` | Timer warning (last 5 seconds) | ~0.2s | More urgent than tick |
|
||||
| `victory.mp3` | Game win | ~1-2s | Celebratory fanfare |
|
||||
| `defeat.mp3` | Game loss | ~1-2s | Sympathetic but not depressing |
|
||||
| `select.mp3` | Question selection | ~0.2s | Subtle click/select |
|
||||
|
||||
## Fallback System
|
||||
|
||||
If sound files are not available, the application will use Web Audio API generated tones as fallback. These provide basic audio feedback while allowing the game to function without external audio files.
|
||||
|
||||
## Recommended Sources for Free Sounds
|
||||
|
||||
- [Freesound.org](https://freesound.org) - Creative Commons sounds
|
||||
- [Mixkit](https://mixkit.co/free-sound-effects/) - Free sound effects
|
||||
- [Pixabay](https://pixabay.com/sound-effects/) - Royalty-free sounds
|
||||
- [OpenGameArt](https://opengameart.org) - Game-specific sounds
|
||||
|
||||
## Theme Sound Guidelines
|
||||
|
||||
### DRRR (Dollars)
|
||||
- Cyberpunk/urban aesthetic
|
||||
- Digital, glitchy sounds
|
||||
- City ambiance influence
|
||||
|
||||
### Retro Arcade
|
||||
- 8-bit chiptune style
|
||||
- Classic arcade game sounds
|
||||
- Nostalgic NES/SNES era
|
||||
|
||||
### Minimal
|
||||
- Clean, subtle sounds
|
||||
- Modern UI feedback tones
|
||||
- Non-intrusive clicks
|
||||
|
||||
### Gaming RGB
|
||||
- Electronic/synthwave
|
||||
- Bass-heavy, modern
|
||||
- Esports broadcast style
|
||||
|
||||
### Anime 90s
|
||||
- Kawaii, bright sounds
|
||||
- J-pop influenced
|
||||
- Sparkle and shine effects
|
||||
@@ -4,7 +4,7 @@ import Lobby from './pages/Lobby'
|
||||
import Game from './pages/Game'
|
||||
import Results from './pages/Results'
|
||||
import Replay from './pages/Replay'
|
||||
import { AdminLayout, Login, Dashboard, Questions, Calendar } from './pages/admin'
|
||||
import { AdminLayout, Login, Dashboard, Questions, Calendar, Settings, Monitor } from './pages/admin'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -24,6 +24,8 @@ function App() {
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="questions" element={<Questions />} />
|
||||
<Route path="calendar" element={<Calendar />} />
|
||||
<Route path="monitor" element={<Monitor />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
89
frontend/src/components/chat/EmojiReactions.tsx
Normal file
89
frontend/src/components/chat/EmojiReactions.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSocket } from '../../hooks/useSocket'
|
||||
import { useGameStore } from '../../stores/gameStore'
|
||||
import { useThemeStyles } from '../../themes/ThemeProvider'
|
||||
|
||||
const EMOJIS = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
|
||||
const COOLDOWN_MS = 3000 // 3 seconds cooldown
|
||||
|
||||
export default function EmojiReactions() {
|
||||
const { sendReaction } = useSocket()
|
||||
const { room, playerName } = useGameStore()
|
||||
const { config } = useThemeStyles()
|
||||
const [isDisabled, setIsDisabled] = useState(false)
|
||||
const [cooldownRemaining, setCooldownRemaining] = useState(0)
|
||||
|
||||
// Handle cooldown timer display
|
||||
useEffect(() => {
|
||||
if (!isDisabled) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCooldownRemaining((prev) => {
|
||||
if (prev <= 100) {
|
||||
setIsDisabled(false)
|
||||
return 0
|
||||
}
|
||||
return prev - 100
|
||||
})
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isDisabled])
|
||||
|
||||
const handleEmojiClick = useCallback(
|
||||
(emoji: string) => {
|
||||
if (isDisabled || !room?.code) return
|
||||
|
||||
// Send the reaction via socket
|
||||
sendReaction(emoji, room.code, playerName)
|
||||
|
||||
// Enable cooldown
|
||||
setIsDisabled(true)
|
||||
setCooldownRemaining(COOLDOWN_MS)
|
||||
},
|
||||
[isDisabled, room?.code, playerName, sendReaction]
|
||||
)
|
||||
|
||||
if (!room) return null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${config.colors.bg}CC`,
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${config.colors.primary}40`,
|
||||
}}
|
||||
>
|
||||
{EMOJIS.map((emoji) => (
|
||||
<motion.button
|
||||
key={emoji}
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
disabled={isDisabled}
|
||||
whileHover={!isDisabled ? { scale: 1.2 } : {}}
|
||||
whileTap={!isDisabled ? { scale: 0.9 } : {}}
|
||||
className={`text-2xl p-2 rounded-lg transition-all ${
|
||||
isDisabled ? 'opacity-40 cursor-not-allowed grayscale' : 'cursor-pointer hover:bg-white/10'
|
||||
}`}
|
||||
title={isDisabled ? `Espera ${Math.ceil(cooldownRemaining / 1000)}s` : emoji}
|
||||
>
|
||||
{emoji}
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
{/* Cooldown indicator */}
|
||||
{isDisabled && (
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%' }}
|
||||
transition={{ duration: COOLDOWN_MS / 1000, ease: 'linear' }}
|
||||
className="absolute bottom-0 left-0 h-1 rounded-full"
|
||||
style={{ backgroundColor: config.colors.primary }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/chat/ReactionOverlay.tsx
Normal file
116
frontend/src/components/chat/ReactionOverlay.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useGameStore } from '../../stores/gameStore'
|
||||
import { useThemeStyles } from '../../themes/ThemeProvider'
|
||||
|
||||
const REACTION_DURATION_MS = 2000 // Auto-remove after 2 seconds
|
||||
|
||||
export default function ReactionOverlay() {
|
||||
const { reactions, removeReaction } = useGameStore()
|
||||
const { config } = useThemeStyles()
|
||||
const timeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
|
||||
// Auto-remove reactions after duration
|
||||
useEffect(() => {
|
||||
reactions.forEach((reaction) => {
|
||||
// Only set timeout if we haven't already set one for this reaction
|
||||
if (!timeoutsRef.current.has(reaction.id)) {
|
||||
const timeout = setTimeout(() => {
|
||||
removeReaction(reaction.id)
|
||||
timeoutsRef.current.delete(reaction.id)
|
||||
}, REACTION_DURATION_MS)
|
||||
timeoutsRef.current.set(reaction.id, timeout)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup removed reactions
|
||||
return () => {
|
||||
timeoutsRef.current.forEach((timeout) => clearTimeout(timeout))
|
||||
}
|
||||
}, [reactions, removeReaction])
|
||||
|
||||
// Pre-calculate random positions for each reaction to avoid recalculation on re-render
|
||||
const reactionPositions = useMemo(() => {
|
||||
const positions = new Map<string, number>()
|
||||
reactions.forEach((reaction) => {
|
||||
if (!positions.has(reaction.id)) {
|
||||
positions.set(reaction.id, 10 + Math.random() * 80)
|
||||
}
|
||||
})
|
||||
return positions
|
||||
}, [reactions])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-40">
|
||||
<AnimatePresence>
|
||||
{reactions.map((reaction) => {
|
||||
// Use pre-calculated random horizontal position (10% to 90% of screen width)
|
||||
const randomX = reactionPositions.get(reaction.id) || 50
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={reaction.id}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: '100vh',
|
||||
x: `${randomX}vw`,
|
||||
scale: 0.5,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: '20vh',
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: '-10vh',
|
||||
scale: 0.8,
|
||||
}}
|
||||
transition={{
|
||||
duration: REACTION_DURATION_MS / 1000,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className="absolute flex flex-col items-center"
|
||||
style={{
|
||||
transform: `translateX(-50%)`,
|
||||
}}
|
||||
>
|
||||
{/* Emoji */}
|
||||
<motion.span
|
||||
className="text-5xl"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
repeat: 2,
|
||||
repeatType: 'reverse',
|
||||
}}
|
||||
>
|
||||
{reaction.emoji}
|
||||
</motion.span>
|
||||
|
||||
{/* Player name badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="mt-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor:
|
||||
reaction.team === 'A'
|
||||
? `${config.colors.primary}CC`
|
||||
: `${config.colors.secondary}CC`,
|
||||
color: config.colors.bg,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
{reaction.player_name}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
341
frontend/src/components/chat/TeamChat.tsx
Normal file
341
frontend/src/components/chat/TeamChat.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useThemeStyles } from '../../themes/ThemeProvider'
|
||||
|
||||
interface TeamMessage {
|
||||
player_name: string
|
||||
team: 'A' | 'B'
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface TeamChatProps {
|
||||
roomCode: string
|
||||
playerName: string
|
||||
team: 'A' | 'B'
|
||||
sendTeamMessage: (message: string) => void
|
||||
teamMessages: TeamMessage[]
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 50
|
||||
|
||||
export default function TeamChat({
|
||||
roomCode,
|
||||
playerName,
|
||||
team,
|
||||
sendTeamMessage,
|
||||
teamMessages,
|
||||
}: TeamChatProps) {
|
||||
const { config } = useThemeStyles()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [inputMessage, setInputMessage] = useState('')
|
||||
const [localMessages, setLocalMessages] = useState<TeamMessage[]>([])
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync external messages with local state, limit to MAX_MESSAGES
|
||||
useEffect(() => {
|
||||
setLocalMessages((prev) => {
|
||||
const combined = [...prev]
|
||||
teamMessages.forEach((msg) => {
|
||||
// Avoid duplicates by checking timestamp and player
|
||||
const exists = combined.some(
|
||||
(m) =>
|
||||
m.timestamp === msg.timestamp &&
|
||||
m.player_name === msg.player_name &&
|
||||
m.message === msg.message
|
||||
)
|
||||
if (!exists) {
|
||||
combined.push(msg)
|
||||
}
|
||||
})
|
||||
// Keep only the last MAX_MESSAGES
|
||||
return combined.slice(-MAX_MESSAGES)
|
||||
})
|
||||
}, [teamMessages])
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (isOpen && messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [localMessages, isOpen])
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
const trimmedMessage = inputMessage.trim()
|
||||
if (!trimmedMessage) return
|
||||
|
||||
sendTeamMessage(trimmedMessage)
|
||||
setInputMessage('')
|
||||
}, [inputMessage, sendTeamMessage])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
},
|
||||
[handleSendMessage]
|
||||
)
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const teamColor = team === 'A' ? config.colors.primary : config.colors.secondary
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button */}
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="fixed right-4 top-1/2 -translate-y-1/2 z-40 p-3 rounded-l-lg shadow-lg"
|
||||
style={{
|
||||
backgroundColor: teamColor,
|
||||
color: config.colors.bg,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label={isOpen ? 'Cerrar chat de equipo' : 'Abrir chat de equipo'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
{localMessages.length > 0 && !isOpen && (
|
||||
<span
|
||||
className="absolute -top-1 -left-1 w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
|
||||
style={{
|
||||
backgroundColor: config.colors.accent,
|
||||
color: config.colors.bg,
|
||||
}}
|
||||
>
|
||||
{localMessages.length > 9 ? '9+' : localMessages.length}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ x: '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 h-full w-80 z-50 flex flex-col shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
borderLeft: `3px solid ${teamColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4"
|
||||
style={{
|
||||
backgroundColor: teamColor + '20',
|
||||
borderBottom: `2px solid ${teamColor}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold" style={{ color: teamColor }}>
|
||||
Chat Equipo {team}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
style={{
|
||||
backgroundColor: teamColor,
|
||||
color: config.colors.bg,
|
||||
}}
|
||||
>
|
||||
{roomCode}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 rounded hover:opacity-80 transition-opacity"
|
||||
style={{ color: config.colors.text }}
|
||||
aria-label="Cerrar chat"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-3 space-y-2"
|
||||
style={{ backgroundColor: config.colors.bg }}
|
||||
>
|
||||
{localMessages.length === 0 ? (
|
||||
<div
|
||||
className="text-center py-8 text-sm"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
>
|
||||
No hay mensajes aun.
|
||||
<br />
|
||||
Escribe algo para tu equipo.
|
||||
</div>
|
||||
) : (
|
||||
localMessages.map((msg, index) => (
|
||||
<motion.div
|
||||
key={`${msg.timestamp}-${index}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-2 rounded-lg ${
|
||||
msg.player_name === playerName ? 'ml-4' : 'mr-4'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
msg.player_name === playerName
|
||||
? teamColor + '30'
|
||||
: config.colors.bg === '#000000'
|
||||
? '#1a1a1a'
|
||||
: '#f0f0f0',
|
||||
border: `1px solid ${
|
||||
msg.player_name === playerName ? teamColor : 'transparent'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{
|
||||
color:
|
||||
msg.player_name === playerName
|
||||
? teamColor
|
||||
: config.colors.text,
|
||||
}}
|
||||
>
|
||||
{msg.player_name === playerName ? 'Tu' : msg.player_name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
>
|
||||
{formatTimestamp(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm break-words"
|
||||
style={{ color: config.colors.text }}
|
||||
>
|
||||
{msg.message}
|
||||
</p>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: teamColor + '10',
|
||||
borderTop: `2px solid ${teamColor}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un mensaje..."
|
||||
maxLength={500}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
color: config.colors.text,
|
||||
border: `2px solid ${teamColor}50`,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputMessage.trim()}
|
||||
className="px-4 py-2 rounded-lg font-semibold transition-all hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: teamColor,
|
||||
color: config.colors.bg,
|
||||
}}
|
||||
aria-label="Enviar mensaje"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-1 text-right"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
>
|
||||
{inputMessage.length}/500
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Backdrop overlay when chat is open */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed inset-0 bg-black/30 z-40 md:hidden"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/ui/SoundControl.tsx
Normal file
199
frontend/src/components/ui/SoundControl.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useSoundStore } from '../../stores/soundStore'
|
||||
import { useSound } from '../../hooks/useSound'
|
||||
import { useThemeStyles } from '../../themes/ThemeProvider'
|
||||
|
||||
interface SoundControlProps {
|
||||
/** Compact mode shows just the icon, expanded shows slider */
|
||||
compact?: boolean
|
||||
/** Position for the popup menu when in compact mode */
|
||||
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
|
||||
/** Custom class name */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SoundControl({
|
||||
compact = false,
|
||||
popupPosition = 'top',
|
||||
className = '',
|
||||
}: SoundControlProps) {
|
||||
const { volume, muted, setVolume, toggleMute } = useSoundStore()
|
||||
const { play } = useSound()
|
||||
const { config } = useThemeStyles()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close popup when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
setVolume(newVolume)
|
||||
// Play a test sound when adjusting volume
|
||||
if (!muted && newVolume > 0) {
|
||||
play('select')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleMute = () => {
|
||||
toggleMute()
|
||||
// Play a sound when unmuting
|
||||
if (muted) {
|
||||
setTimeout(() => play('select'), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const getVolumeIcon = () => {
|
||||
if (muted || volume === 0) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM17.78 9.22a.75.75 0 10-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 001.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 101.06-1.06L20.56 12l1.72-1.72a.75.75 0 00-1.06-1.06l-1.72 1.72-1.72-1.72z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (volume < 0.33) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (volume < 0.66) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6">
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
|
||||
<path d="M17.995 10.404a.75.75 0 011.06 0 3 3 0 010 4.243.75.75 0 01-1.06-1.061 1.5 1.5 0 000-2.122.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const getPopupStyles = () => {
|
||||
switch (popupPosition) {
|
||||
case 'top':
|
||||
return 'bottom-full mb-2 left-1/2 -translate-x-1/2'
|
||||
case 'bottom':
|
||||
return 'top-full mt-2 left-1/2 -translate-x-1/2'
|
||||
case 'left':
|
||||
return 'right-full mr-2 top-1/2 -translate-y-1/2'
|
||||
case 'right':
|
||||
return 'left-full ml-2 top-1/2 -translate-y-1/2'
|
||||
}
|
||||
}
|
||||
|
||||
if (!compact) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg ${className}`}
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
border: `1px solid ${config.colors.primary}30`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggleMute}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{ color: config.colors.primary }}
|
||||
title={muted ? 'Activar sonido' : 'Silenciar'}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-24 h-2 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
|
||||
}}
|
||||
title={`Volumen: ${Math.round((muted ? 0 : volume) * 100)}%`}
|
||||
/>
|
||||
<span
|
||||
className="text-sm w-10 text-center"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
>
|
||||
{Math.round((muted ? 0 : volume) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{
|
||||
color: config.colors.primary,
|
||||
backgroundColor: config.colors.bg,
|
||||
border: `1px solid ${config.colors.primary}30`,
|
||||
}}
|
||||
title="Control de sonido"
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className={`absolute ${getPopupStyles()} p-3 rounded-lg shadow-lg z-50`}
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
border: `1px solid ${config.colors.primary}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 min-w-[120px]">
|
||||
<button
|
||||
onClick={handleToggleMute}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{ color: config.colors.primary }}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
>
|
||||
{Math.round((muted ? 0 : volume) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/ui/index.ts
Normal file
1
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SoundControl } from './SoundControl'
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { soundPlayer } from './useSound'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { useSoundStore } from '../stores/soundStore'
|
||||
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
|
||||
import type { Reaction } from '../stores/gameStore'
|
||||
|
||||
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
||||
|
||||
// Team message type
|
||||
export interface TeamMessage {
|
||||
player_name: string
|
||||
team: 'A' | 'B'
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } =
|
||||
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
|
||||
useGameStore()
|
||||
|
||||
// Initialize sound player with current theme
|
||||
const currentTheme = useThemeStore.getState().currentTheme
|
||||
soundPlayer.loadTheme(currentTheme)
|
||||
|
||||
useEffect(() => {
|
||||
// Create socket connection
|
||||
socketRef.current = io(SOCKET_URL, {
|
||||
@@ -61,14 +77,27 @@ export function useSocket() {
|
||||
|
||||
socket.on('answer_result', (data: AnswerResult) => {
|
||||
setRoom(data.room)
|
||||
|
||||
// Play appropriate sound based on answer result
|
||||
const volume = useSoundStore.getState().volume
|
||||
if (data.valid) {
|
||||
soundPlayer.play('correct', volume)
|
||||
} else {
|
||||
soundPlayer.play('incorrect', volume)
|
||||
}
|
||||
|
||||
if (!data.valid && !data.was_steal && data.room.can_steal) {
|
||||
setShowStealPrompt(true)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('steal_attempted', (data: { room: GameRoom }) => {
|
||||
socket.on('steal_attempted', (data: { room: GameRoom; success?: boolean }) => {
|
||||
setRoom(data.room)
|
||||
setShowStealPrompt(false)
|
||||
|
||||
// Play steal sound when a steal is attempted
|
||||
const volume = useSoundStore.getState().volume
|
||||
soundPlayer.play('steal', volume)
|
||||
})
|
||||
|
||||
socket.on('steal_passed', (data: { room: GameRoom }) => {
|
||||
@@ -91,8 +120,23 @@ export function useSocket() {
|
||||
})
|
||||
|
||||
socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => {
|
||||
// Handle emoji reaction display
|
||||
console.log(`${data.player_name} reacted with ${data.emoji}`)
|
||||
// Legacy handler - redirect to new reaction system
|
||||
addReaction({
|
||||
player_name: data.player_name,
|
||||
team: data.team as 'A' | 'B',
|
||||
emoji: data.emoji,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('receive_reaction', (data: Omit<Reaction, 'id'>) => {
|
||||
// Add reaction to the store for display in overlay
|
||||
addReaction(data)
|
||||
})
|
||||
|
||||
// Team chat events
|
||||
socket.on('receive_team_message', (data: TeamMessage) => {
|
||||
addTeamMessage(data)
|
||||
})
|
||||
|
||||
socket.on('game_finished', (data: {
|
||||
@@ -107,6 +151,18 @@ export function useSocket() {
|
||||
}>
|
||||
}) => {
|
||||
setRoom(data.room)
|
||||
|
||||
// Determine if current player is on the winning team
|
||||
const currentPlayerName = useGameStore.getState().playerName
|
||||
const myTeam = data.room.teams.A.find(p => p.name === currentPlayerName) ? 'A' : 'B'
|
||||
const volume = useSoundStore.getState().volume
|
||||
|
||||
if (data.winner === myTeam) {
|
||||
soundPlayer.play('victory', volume)
|
||||
} else if (data.winner !== null) {
|
||||
soundPlayer.play('defeat', volume)
|
||||
}
|
||||
|
||||
setGameResult({
|
||||
winner: data.winner,
|
||||
finalScores: data.final_scores,
|
||||
@@ -122,7 +178,7 @@ export function useSocket() {
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult])
|
||||
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
|
||||
|
||||
// Socket methods
|
||||
const createRoom = useCallback((playerName: string) => {
|
||||
@@ -179,6 +235,26 @@ export function useSocket() {
|
||||
socketRef.current?.emit('emoji_reaction', { emoji })
|
||||
}, [])
|
||||
|
||||
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
|
||||
socketRef.current?.emit('send_reaction', {
|
||||
emoji,
|
||||
room_code: roomCode,
|
||||
player_name: playerName,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const sendTeamMessage = useCallback(
|
||||
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
|
||||
socketRef.current?.emit('team_message', {
|
||||
room_code: roomCode,
|
||||
team,
|
||||
player_name: playerName,
|
||||
message,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const notifyTimerExpired = useCallback(() => {
|
||||
socketRef.current?.emit('timer_expired', {})
|
||||
}, [])
|
||||
@@ -194,6 +270,8 @@ export function useSocket() {
|
||||
stealDecision,
|
||||
sendChatMessage,
|
||||
sendEmojiReaction,
|
||||
sendReaction,
|
||||
sendTeamMessage,
|
||||
notifyTimerExpired,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,63 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Howl } from 'howler'
|
||||
import { useSoundStore, soundPaths } from '../stores/soundStore'
|
||||
import { useSoundStore, soundPaths, fallbackSoundConfigs, type SoundEffect } from '../stores/soundStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
|
||||
type SoundEffect =
|
||||
| 'correct'
|
||||
| 'incorrect'
|
||||
| 'steal'
|
||||
| 'timer_tick'
|
||||
| 'timer_urgent'
|
||||
| 'victory'
|
||||
| 'defeat'
|
||||
| 'select'
|
||||
// Re-export SoundEffect type for convenience
|
||||
export type { SoundEffect }
|
||||
|
||||
// Audio context for fallback sounds
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
}
|
||||
return audioContext
|
||||
}
|
||||
|
||||
// Play a fallback sound using Web Audio API
|
||||
function playFallbackSound(effect: SoundEffect, volume: number): void {
|
||||
try {
|
||||
const ctx = getAudioContext()
|
||||
const config = fallbackSoundConfigs[effect]
|
||||
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gainNode = ctx.createGain()
|
||||
|
||||
oscillator.type = config.type
|
||||
oscillator.frequency.setValueAtTime(config.frequency, ctx.currentTime)
|
||||
|
||||
// Victory and defeat have melody-like patterns
|
||||
if (effect === 'victory') {
|
||||
oscillator.frequency.setValueAtTime(523, ctx.currentTime)
|
||||
oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.15)
|
||||
oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.3)
|
||||
} else if (effect === 'defeat') {
|
||||
oscillator.frequency.setValueAtTime(392, ctx.currentTime)
|
||||
oscillator.frequency.setValueAtTime(330, ctx.currentTime + 0.15)
|
||||
oscillator.frequency.setValueAtTime(262, ctx.currentTime + 0.3)
|
||||
}
|
||||
|
||||
gainNode.gain.setValueAtTime(volume * 0.3, ctx.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + config.duration)
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
oscillator.start(ctx.currentTime)
|
||||
oscillator.stop(ctx.currentTime + config.duration)
|
||||
} catch (error) {
|
||||
console.warn('Failed to play fallback sound:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function useSound() {
|
||||
const { volume, muted } = useSoundStore()
|
||||
const { volume, muted, setSoundsLoaded, setCurrentLoadedTheme } = useSoundStore()
|
||||
const { currentTheme } = useThemeStore()
|
||||
const soundsRef = useRef<Map<string, Howl>>(new Map())
|
||||
const loadedCountRef = useRef(0)
|
||||
const failedSoundsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Preload sounds for current theme
|
||||
useEffect(() => {
|
||||
@@ -26,15 +67,36 @@ export function useSound() {
|
||||
// Clear old sounds
|
||||
soundsRef.current.forEach((sound) => sound.unload())
|
||||
soundsRef.current.clear()
|
||||
failedSoundsRef.current.clear()
|
||||
loadedCountRef.current = 0
|
||||
setSoundsLoaded(false)
|
||||
setCurrentLoadedTheme(null)
|
||||
|
||||
const soundEntries = Object.entries(themeSounds)
|
||||
const totalSounds = soundEntries.length
|
||||
|
||||
// Load new sounds
|
||||
Object.entries(themeSounds).forEach(([key, path]) => {
|
||||
soundEntries.forEach(([key, path]) => {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
volume: volume,
|
||||
preload: true,
|
||||
onloaderror: () => {
|
||||
console.warn(`Failed to load sound: ${path}`)
|
||||
onload: () => {
|
||||
loadedCountRef.current++
|
||||
if (loadedCountRef.current >= totalSounds - failedSoundsRef.current.size) {
|
||||
setSoundsLoaded(true)
|
||||
setCurrentLoadedTheme(currentTheme)
|
||||
}
|
||||
},
|
||||
onloaderror: (_id, error) => {
|
||||
console.warn(`Failed to load sound: ${path}`, error)
|
||||
failedSoundsRef.current.add(key)
|
||||
loadedCountRef.current++
|
||||
// Still mark as loaded even with failures (will use fallback)
|
||||
if (loadedCountRef.current >= totalSounds) {
|
||||
setSoundsLoaded(true)
|
||||
setCurrentLoadedTheme(currentTheme)
|
||||
}
|
||||
},
|
||||
})
|
||||
soundsRef.current.set(key, sound)
|
||||
@@ -43,7 +105,7 @@ export function useSound() {
|
||||
return () => {
|
||||
soundsRef.current.forEach((sound) => sound.unload())
|
||||
}
|
||||
}, [currentTheme])
|
||||
}, [currentTheme, setSoundsLoaded, setCurrentLoadedTheme])
|
||||
|
||||
// Update volume when it changes
|
||||
useEffect(() => {
|
||||
@@ -57,11 +119,16 @@ export function useSound() {
|
||||
if (muted) return
|
||||
|
||||
const sound = soundsRef.current.get(effect)
|
||||
if (sound) {
|
||||
|
||||
// If sound loaded successfully, play it
|
||||
if (sound && sound.state() === 'loaded') {
|
||||
sound.play()
|
||||
} else if (failedSoundsRef.current.has(effect) || !sound || sound.state() !== 'loaded') {
|
||||
// Use fallback Web Audio API sound
|
||||
playFallbackSound(effect, volume)
|
||||
}
|
||||
},
|
||||
[muted]
|
||||
[muted, volume]
|
||||
)
|
||||
|
||||
const stop = useCallback((effect: SoundEffect) => {
|
||||
@@ -75,9 +142,88 @@ export function useSound() {
|
||||
soundsRef.current.forEach((sound) => sound.stop())
|
||||
}, [])
|
||||
|
||||
// Convenience method for playing tick sounds (every second)
|
||||
const playTick = useCallback(
|
||||
(timeRemaining: number, urgentThreshold: number = 5) => {
|
||||
if (muted) return
|
||||
|
||||
if (timeRemaining <= urgentThreshold && timeRemaining > 0) {
|
||||
play('timer_urgent')
|
||||
} else if (timeRemaining > urgentThreshold) {
|
||||
play('timer_tick')
|
||||
}
|
||||
},
|
||||
[muted, play]
|
||||
)
|
||||
|
||||
return {
|
||||
play,
|
||||
stop,
|
||||
stopAll,
|
||||
playTick,
|
||||
volume,
|
||||
muted,
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton sound player for use outside of React components
|
||||
// Useful for playing sounds from socket event handlers
|
||||
class SoundPlayer {
|
||||
private static instance: SoundPlayer | null = null
|
||||
private sounds: Map<string, Howl> = new Map()
|
||||
private currentTheme: string | null = null
|
||||
private failedSounds: Set<string> = new Set()
|
||||
|
||||
static getInstance(): SoundPlayer {
|
||||
if (!SoundPlayer.instance) {
|
||||
SoundPlayer.instance = new SoundPlayer()
|
||||
}
|
||||
return SoundPlayer.instance
|
||||
}
|
||||
|
||||
loadTheme(theme: string): void {
|
||||
if (this.currentTheme === theme) return
|
||||
|
||||
// Unload previous sounds
|
||||
this.sounds.forEach((sound) => sound.unload())
|
||||
this.sounds.clear()
|
||||
this.failedSounds.clear()
|
||||
this.currentTheme = theme
|
||||
|
||||
const themeSounds = soundPaths[theme as keyof typeof soundPaths]
|
||||
if (!themeSounds) return
|
||||
|
||||
Object.entries(themeSounds).forEach(([key, path]) => {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
preload: true,
|
||||
onloaderror: () => {
|
||||
this.failedSounds.add(key)
|
||||
},
|
||||
})
|
||||
this.sounds.set(key, sound)
|
||||
})
|
||||
}
|
||||
|
||||
play(effect: SoundEffect, volume: number = 0.7): void {
|
||||
const { muted } = useSoundStore.getState()
|
||||
if (muted) return
|
||||
|
||||
const sound = this.sounds.get(effect)
|
||||
|
||||
if (sound && sound.state() === 'loaded') {
|
||||
sound.volume(volume)
|
||||
sound.play()
|
||||
} else {
|
||||
playFallbackSound(effect, volume)
|
||||
}
|
||||
}
|
||||
|
||||
updateVolume(volume: number): void {
|
||||
this.sounds.forEach((sound) => {
|
||||
sound.volume(volume)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const soundPlayer = SoundPlayer.getInstance()
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useSocket } from '../hooks/useSocket'
|
||||
import { useSound } from '../hooks/useSound'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||
import EmojiReactions from '../components/chat/EmojiReactions'
|
||||
import ReactionOverlay from '../components/chat/ReactionOverlay'
|
||||
import TeamChat from '../components/chat/TeamChat'
|
||||
import SoundControl from '../components/ui/SoundControl'
|
||||
import type { Question } from '../types'
|
||||
|
||||
const categories = [
|
||||
@@ -21,9 +25,9 @@ const categories = [
|
||||
export default function Game() {
|
||||
useParams<{ roomCode: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
|
||||
const { selectQuestion, submitAnswer, stealDecision, sendTeamMessage } = useSocket()
|
||||
const { play } = useSound()
|
||||
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore()
|
||||
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt, teamMessages } = useGameStore()
|
||||
const { config, styles } = useThemeStyles()
|
||||
|
||||
const [answer, setAnswer] = useState('')
|
||||
@@ -37,7 +41,7 @@ export default function Game() {
|
||||
}
|
||||
}, [room?.status, room?.code, navigate])
|
||||
|
||||
// Timer logic
|
||||
// Timer logic with sound effects
|
||||
useEffect(() => {
|
||||
if (!currentQuestion || !showingQuestion) return
|
||||
|
||||
@@ -48,7 +52,13 @@ export default function Game() {
|
||||
clearInterval(interval)
|
||||
return 0
|
||||
}
|
||||
if (prev === 6) play('timer_urgent')
|
||||
// Play urgent sound when time is running low (5 seconds or less)
|
||||
if (prev <= 6 && prev > 1) {
|
||||
play('timer_urgent')
|
||||
} else if (prev > 6) {
|
||||
// Play tick sound for normal countdown
|
||||
play('timer_tick')
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
@@ -95,7 +105,15 @@ export default function Game() {
|
||||
setShowStealPrompt(false)
|
||||
}
|
||||
|
||||
const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
|
||||
// Handler for sending team messages
|
||||
const handleSendTeamMessage = (message: string) => {
|
||||
if (room && playerName && myTeam) {
|
||||
sendTeamMessage(message, room.code, myTeam, playerName)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if the game is active (playing status)
|
||||
const isGameActive = room.status === 'playing'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
||||
@@ -316,20 +334,30 @@ export default function Game() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Emoji Reactions */}
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
{emojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => sendEmojiReaction(emoji)}
|
||||
className="text-2xl p-2 rounded-lg transition-transform hover:scale-125"
|
||||
style={{ backgroundColor: config.colors.bg + '80' }}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
{/* Emoji Reactions Bar - Fixed at bottom */}
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
||||
<EmojiReactions />
|
||||
</div>
|
||||
|
||||
{/* Sound Control - Fixed at top right */}
|
||||
<div className="fixed top-4 right-4 z-30">
|
||||
<SoundControl compact popupPosition="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reaction Overlay - Full screen overlay for floating reactions */}
|
||||
<ReactionOverlay />
|
||||
|
||||
{/* Team Chat - Only visible during the game */}
|
||||
{isGameActive && (
|
||||
<TeamChat
|
||||
roomCode={room.code}
|
||||
playerName={playerName}
|
||||
team={myTeam}
|
||||
sendTeamMessage={handleSendTeamMessage}
|
||||
teamMessages={teamMessages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ const navItems = [
|
||||
{ path: '/admin/dashboard', label: 'Dashboard', icon: '📊' },
|
||||
{ path: '/admin/questions', label: 'Preguntas', icon: '❓' },
|
||||
{ path: '/admin/calendar', label: 'Calendario', icon: '📅' },
|
||||
{ path: '/admin/monitor', label: 'Monitor', icon: '🖥️' },
|
||||
{ path: '/admin/settings', label: 'Configuracion', icon: '⚙️' },
|
||||
]
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
375
frontend/src/pages/admin/Monitor.tsx
Normal file
375
frontend/src/pages/admin/Monitor.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAdminStore } from '../../stores/adminStore'
|
||||
import { getActiveRooms, closeRoom, ActiveRoom } from '../../services/adminApi'
|
||||
|
||||
// Helper para formatear tiempo restante
|
||||
const formatTTL = (seconds: number): string => {
|
||||
if (seconds <= 0) return 'Expirando...'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
// Badge de estado
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
const colors: Record<string, string> = {
|
||||
waiting: 'bg-yellow-500/20 text-yellow-400 border-yellow-500',
|
||||
playing: 'bg-green-500/20 text-green-400 border-green-500',
|
||||
finished: 'bg-gray-500/20 text-gray-400 border-gray-500',
|
||||
unknown: 'bg-red-500/20 text-red-400 border-red-500'
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
waiting: 'Esperando',
|
||||
playing: 'Jugando',
|
||||
finished: 'Finalizado',
|
||||
unknown: 'Desconocido'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded border ${colors[status] || colors.unknown}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Modal de confirmacion
|
||||
const ConfirmModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
roomCode,
|
||||
playersCount
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
roomCode: string
|
||||
playersCount: number
|
||||
}) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-4">
|
||||
Cerrar Sala
|
||||
</h3>
|
||||
<p className="text-gray-300 mb-2">
|
||||
Estas a punto de cerrar la sala <span className="font-mono font-bold text-yellow-400">{roomCode}</span>.
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
Esta accion desconectara a {playersCount} jugador{playersCount !== 1 ? 'es' : ''} y eliminara todos los datos de la partida.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Cerrar Sala
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Monitor() {
|
||||
const { token } = useAdminStore()
|
||||
const [rooms, setRooms] = useState<ActiveRoom[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
||||
|
||||
// Modal state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean
|
||||
roomCode: string
|
||||
playersCount: number
|
||||
}>({
|
||||
isOpen: false,
|
||||
roomCode: '',
|
||||
playersCount: 0
|
||||
})
|
||||
|
||||
// Closing state para mostrar loading en boton
|
||||
const [closingRoom, setClosingRoom] = useState<string | null>(null)
|
||||
|
||||
const fetchRooms = useCallback(async () => {
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const data = await getActiveRooms(token)
|
||||
setRooms(data.rooms)
|
||||
setLastUpdate(new Date())
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Error al cargar salas activas')
|
||||
console.error('Error fetching rooms:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// Fetch inicial y auto-refresh cada 5 segundos
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
|
||||
const interval = setInterval(fetchRooms, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchRooms])
|
||||
|
||||
const handleCloseRoom = (room: ActiveRoom) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
roomCode: room.room_code,
|
||||
playersCount: room.players_count
|
||||
})
|
||||
}
|
||||
|
||||
const confirmCloseRoom = async () => {
|
||||
if (!token || !confirmModal.roomCode) return
|
||||
|
||||
setClosingRoom(confirmModal.roomCode)
|
||||
setConfirmModal({ isOpen: false, roomCode: '', playersCount: 0 })
|
||||
|
||||
try {
|
||||
await closeRoom(token, confirmModal.roomCode)
|
||||
// Refrescar lista
|
||||
await fetchRooms()
|
||||
} catch (err) {
|
||||
setError('Error al cerrar la sala')
|
||||
console.error('Error closing room:', err)
|
||||
} finally {
|
||||
setClosingRoom(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Stats summary
|
||||
const totalPlayers = rooms.reduce((sum, r) => sum + r.players_count, 0)
|
||||
const playingRooms = rooms.filter(r => r.status === 'playing').length
|
||||
const waitingRooms = rooms.filter(r => r.status === 'waiting').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Monitor de Salas</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Ultima actualizacion: {lastUpdate.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRooms}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-gray-800 rounded-lg border-l-4 border-blue-500"
|
||||
>
|
||||
<p className="text-gray-400 text-sm">Total Salas</p>
|
||||
<p className="text-2xl font-bold text-white">{rooms.length}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-4 bg-gray-800 rounded-lg border-l-4 border-green-500"
|
||||
>
|
||||
<p className="text-gray-400 text-sm">Jugando</p>
|
||||
<p className="text-2xl font-bold text-green-400">{playingRooms}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="p-4 bg-gray-800 rounded-lg border-l-4 border-yellow-500"
|
||||
>
|
||||
<p className="text-gray-400 text-sm">Esperando</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">{waitingRooms}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="p-4 bg-gray-800 rounded-lg border-l-4 border-purple-500"
|
||||
>
|
||||
<p className="text-gray-400 text-sm">Jugadores Activos</p>
|
||||
<p className="text-2xl font-bold text-purple-400">{totalPlayers}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Codigo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Jugadores
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Equipo A
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Equipo B
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Tiempo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading && rooms.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
|
||||
Cargando salas...
|
||||
</td>
|
||||
</tr>
|
||||
) : rooms.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
|
||||
No hay salas activas en este momento
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rooms.map((room) => (
|
||||
<motion.tr
|
||||
key={room.room_code}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-mono font-bold text-white">
|
||||
{room.room_code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
|
||||
{room.players_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-blue-400">
|
||||
{room.teams.A}
|
||||
{room.status === 'playing' && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
({room.scores.A} pts)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-red-400">
|
||||
{room.teams.B}
|
||||
{room.status === 'playing' && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
({room.scores.B} pts)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={room.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
|
||||
{formatTTL(room.ttl_seconds)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-300">
|
||||
{room.host}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleCloseRoom(room)}
|
||||
disabled={closingRoom === room.room_code}
|
||||
className="px-3 py-1 bg-red-600/20 hover:bg-red-600/40 text-red-400 hover:text-red-300 rounded text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{closingRoom === room.room_code ? 'Cerrando...' : 'Cerrar Sala'}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh indicator */}
|
||||
<p className="mt-4 text-gray-500 text-xs text-center">
|
||||
Los datos se actualizan automaticamente cada 5 segundos
|
||||
</p>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
<AnimatePresence>
|
||||
<ConfirmModal
|
||||
isOpen={confirmModal.isOpen}
|
||||
onClose={() => setConfirmModal({ isOpen: false, roomCode: '', playersCount: 0 })}
|
||||
onConfirm={confirmCloseRoom}
|
||||
roomCode={confirmModal.roomCode}
|
||||
playersCount={confirmModal.playersCount}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAdminStore } from '../../stores/adminStore'
|
||||
import {
|
||||
getQuestions, getCategories, createQuestion, updateQuestion,
|
||||
deleteQuestion, generateQuestions, approveQuestion, rejectQuestion
|
||||
deleteQuestion, generateQuestions, approveQuestion, rejectQuestion,
|
||||
exportQuestions, importQuestions, ImportResult
|
||||
} from '../../services/adminApi'
|
||||
import type { Category } from '../../types'
|
||||
|
||||
@@ -54,6 +55,13 @@ export default function Questions() {
|
||||
})
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
// Import/Export state
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [token, filterCategory, filterStatus])
|
||||
@@ -168,6 +176,54 @@ export default function Questions() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!token) return
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportQuestions(token, {
|
||||
categoryId: filterCategory || undefined,
|
||||
status: filterStatus || undefined
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error exporting:', error)
|
||||
alert('Error al exportar preguntas')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportClick = () => {
|
||||
setImportResult(null)
|
||||
setShowImportModal(true)
|
||||
}
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !token) return
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const result = await importQuestions(token, file)
|
||||
setImportResult(result)
|
||||
if (result.imported > 0) {
|
||||
setFilterStatus('pending')
|
||||
fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error importing:', error)
|
||||
setImportResult({
|
||||
imported: 0,
|
||||
errors: [{ row: 0, error: error instanceof Error ? error.message : 'Error desconocido' }]
|
||||
})
|
||||
} finally {
|
||||
setImporting(false)
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryName = (id: number) => categories.find(c => c.id === id)?.name || 'Unknown'
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -183,6 +239,19 @@ export default function Questions() {
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Preguntas</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportando...' : 'Exportar CSV'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportClick}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Importar CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
@@ -474,6 +543,115 @@ export default function Questions() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Import Modal */}
|
||||
<AnimatePresence>
|
||||
{showImportModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.9 }}
|
||||
className="w-full max-w-lg bg-gray-800 rounded-lg p-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
Importar Preguntas desde CSV
|
||||
</h2>
|
||||
|
||||
{!importResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
disabled={importing}
|
||||
className="hidden"
|
||||
id="csv-file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="csv-file-input"
|
||||
className={`cursor-pointer block ${importing ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<p className="text-white mb-2">
|
||||
{importing ? 'Importando...' : 'Haz clic para seleccionar archivo CSV'}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
o arrastra y suelta aqui
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700 rounded p-4">
|
||||
<p className="text-gray-300 text-sm font-medium mb-2">Formato esperado:</p>
|
||||
<code className="text-xs text-gray-400 block whitespace-pre-wrap">
|
||||
category,question,correct_answer,alt_answers,difficulty,fun_fact{'\n'}
|
||||
Nintendo,Quien es el protagonista de Zelda?,Link,El heroe|El elegido,2,Link no es Zelda
|
||||
</code>
|
||||
<p className="text-gray-400 text-xs mt-2">
|
||||
* alt_answers separados por | (pipe)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Import Results */}
|
||||
<div className={`p-4 rounded-lg ${importResult.imported > 0 ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
<p className={`text-lg font-bold ${importResult.imported > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{importResult.imported > 0
|
||||
? `Se importaron ${importResult.imported} preguntas`
|
||||
: 'No se importaron preguntas'}
|
||||
</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<p className="text-yellow-400 text-sm mt-1">
|
||||
{importResult.errors.length} error(es) encontrado(s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error List */}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="bg-gray-700 rounded-lg p-4 max-h-60 overflow-y-auto">
|
||||
<p className="text-gray-300 text-sm font-medium mb-2">Errores:</p>
|
||||
<ul className="space-y-1">
|
||||
{importResult.errors.map((err, idx) => (
|
||||
<li key={idx} className="text-red-400 text-sm">
|
||||
{err.row > 0 ? `Fila ${err.row}: ` : ''}{err.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setImportResult(null)}
|
||||
className="w-full px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-500"
|
||||
>
|
||||
Importar otro archivo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowImportModal(false)}
|
||||
className="px-4 py-2 text-gray-400 hover:text-white"
|
||||
disabled={importing}
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
315
frontend/src/pages/admin/Settings.tsx
Normal file
315
frontend/src/pages/admin/Settings.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAdminStore } from '../../stores/adminStore'
|
||||
import { getSettings, updateSettings } from '../../services/adminApi'
|
||||
|
||||
interface GameSettings {
|
||||
points_by_difficulty: Record<string, number>
|
||||
times_by_difficulty: Record<string, number>
|
||||
steal_penalty_percent: number
|
||||
max_players_per_team: number
|
||||
steal_time_percent: number
|
||||
}
|
||||
|
||||
const defaultSettings: GameSettings = {
|
||||
points_by_difficulty: { "1": 100, "2": 200, "3": 300, "4": 400, "5": 500 },
|
||||
times_by_difficulty: { "1": 15, "2": 20, "3": 25, "4": 35, "5": 45 },
|
||||
steal_penalty_percent: 50,
|
||||
max_players_per_team: 4,
|
||||
steal_time_percent: 50
|
||||
}
|
||||
|
||||
const difficultyLabels: Record<string, string> = {
|
||||
"1": "Muy Facil",
|
||||
"2": "Facil",
|
||||
"3": "Media",
|
||||
"4": "Dificil",
|
||||
"5": "Muy Dificil"
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { token } = useAdminStore()
|
||||
const [settings, setSettings] = useState<GameSettings>(defaultSettings)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [token])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
if (!token) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getSettings(token)
|
||||
setSettings(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error)
|
||||
setMessage({ type: 'error', text: 'Error al cargar la configuracion' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await updateSettings(token, settings)
|
||||
setMessage({ type: 'success', text: 'Configuracion guardada exitosamente' })
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error)
|
||||
setMessage({ type: 'error', text: 'Error al guardar la configuracion' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePointsForDifficulty = (difficulty: string, value: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
points_by_difficulty: {
|
||||
...prev.points_by_difficulty,
|
||||
[difficulty]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const updateTimesForDifficulty = (difficulty: string, value: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
times_by_difficulty: {
|
||||
...prev.times_by_difficulty,
|
||||
[difficulty]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-400">Cargando configuracion...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Configuracion del Juego</h1>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback Message */}
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`mb-6 p-4 rounded ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-600/20 border border-green-500 text-green-400'
|
||||
: 'bg-red-600/20 border border-red-500 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Points by Difficulty */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-800 rounded-lg p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Puntos por Dificultad</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Define cuantos puntos otorga cada nivel de dificultad al responder correctamente.
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{["1", "2", "3", "4", "5"].map(diff => (
|
||||
<div key={`points-${diff}`}>
|
||||
<label className="block text-gray-400 text-sm mb-1">
|
||||
{difficultyLabels[diff]}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.points_by_difficulty[diff] || 0}
|
||||
onChange={(e) => updatePointsForDifficulty(diff, parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
min="0"
|
||||
step="50"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Times by Difficulty */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-gray-800 rounded-lg p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Tiempo por Dificultad (segundos)</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Define cuantos segundos tiene el equipo para responder segun la dificultad.
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{["1", "2", "3", "4", "5"].map(diff => (
|
||||
<div key={`time-${diff}`}>
|
||||
<label className="block text-gray-400 text-sm mb-1">
|
||||
{difficultyLabels[diff]}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.times_by_difficulty[diff] || 0}
|
||||
onChange={(e) => updateTimesForDifficulty(diff, parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
min="5"
|
||||
max="120"
|
||||
step="5"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Steal Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-gray-800 rounded-lg p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Mecanica de Robo</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Configuracion para cuando un equipo intenta robar puntos despues de una respuesta incorrecta.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-1">
|
||||
Penalizacion de Robo (%)
|
||||
</label>
|
||||
<p className="text-gray-500 text-xs mb-2">
|
||||
Porcentaje de puntos que se obtienen al robar (ej: 50% = mitad de puntos)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={settings.steal_penalty_percent}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, steal_penalty_percent: parseInt(e.target.value) }))}
|
||||
className="flex-1"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
/>
|
||||
<span className="text-white font-medium w-12 text-right">
|
||||
{settings.steal_penalty_percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-1">
|
||||
Tiempo de Robo (%)
|
||||
</label>
|
||||
<p className="text-gray-500 text-xs mb-2">
|
||||
Porcentaje del tiempo original para intentar el robo
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={settings.steal_time_percent}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, steal_time_percent: parseInt(e.target.value) }))}
|
||||
className="flex-1"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
/>
|
||||
<span className="text-white font-medium w-12 text-right">
|
||||
{settings.steal_time_percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Team Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-gray-800 rounded-lg p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Configuracion de Equipos</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Limites y reglas para los equipos en el juego.
|
||||
</p>
|
||||
<div className="max-w-xs">
|
||||
<label className="block text-gray-400 text-sm mb-1">
|
||||
Maximo de Jugadores por Equipo
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.max_players_per_team}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, max_players_per_team: parseInt(e.target.value) || 1 }))}
|
||||
className="w-full px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Preview Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-6 bg-gray-800/50 rounded-lg p-6 border border-gray-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Vista Previa</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left py-2 text-gray-400">Dificultad</th>
|
||||
<th className="text-center py-2 text-gray-400">Puntos</th>
|
||||
<th className="text-center py-2 text-gray-400">Tiempo</th>
|
||||
<th className="text-center py-2 text-gray-400">Robo (pts)</th>
|
||||
<th className="text-center py-2 text-gray-400">Robo (tiempo)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{["1", "2", "3", "4", "5"].map(diff => {
|
||||
const points = settings.points_by_difficulty[diff] || 0
|
||||
const time = settings.times_by_difficulty[diff] || 0
|
||||
const stealPoints = Math.round(points * settings.steal_penalty_percent / 100)
|
||||
const stealTime = Math.round(time * settings.steal_time_percent / 100)
|
||||
|
||||
return (
|
||||
<tr key={diff} className="border-b border-gray-700/50">
|
||||
<td className="py-2 text-white">{difficultyLabels[diff]}</td>
|
||||
<td className="py-2 text-center text-green-400">{points} pts</td>
|
||||
<td className="py-2 text-center text-blue-400">{time}s</td>
|
||||
<td className="py-2 text-center text-yellow-400">{stealPoints} pts</td>
|
||||
<td className="py-2 text-center text-purple-400">{stealTime}s</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export { default as AdminLayout } from './AdminLayout'
|
||||
export { default as Dashboard } from './Dashboard'
|
||||
export { default as Questions } from './Questions'
|
||||
export { default as Calendar } from './Calendar'
|
||||
export { default as Settings } from './Settings'
|
||||
export { default as Monitor } from './Monitor'
|
||||
|
||||
@@ -138,3 +138,151 @@ export const getCategories = async (token: string) => {
|
||||
if (!response.ok) throw new Error('Failed to fetch categories')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// CSV Import/Export
|
||||
export const exportQuestions = async (
|
||||
token: string,
|
||||
filters?: { categoryId?: number; status?: string }
|
||||
) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.categoryId) params.append('category_id', String(filters.categoryId))
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/admin/questions/export?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to export questions')
|
||||
|
||||
// Get filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'questions_export.csv'
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/)
|
||||
if (match) filename = match[1]
|
||||
}
|
||||
|
||||
// Get blob and trigger download
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
errors: Array<{ row: number; error: string }>
|
||||
}
|
||||
|
||||
export const importQuestions = async (token: string, file: File): Promise<ImportResult> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/admin/questions/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Failed to import questions')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Room Monitor
|
||||
|
||||
export interface ActiveRoom {
|
||||
room_code: string
|
||||
players_count: number
|
||||
teams: {
|
||||
A: number
|
||||
B: number
|
||||
}
|
||||
status: 'waiting' | 'playing' | 'finished'
|
||||
host: string
|
||||
ttl_seconds: number
|
||||
scores: {
|
||||
A: number
|
||||
B: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveRoomsResponse {
|
||||
rooms: ActiveRoom[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const getActiveRooms = async (token: string): Promise<ActiveRoomsResponse> => {
|
||||
const response = await fetch(`${API_URL}/api/admin/rooms/active`, {
|
||||
headers: getAuthHeaders(token)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch active rooms')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export interface CloseRoomResponse {
|
||||
status: string
|
||||
room_code: string
|
||||
players_affected: number
|
||||
}
|
||||
|
||||
export const closeRoom = async (token: string, roomCode: string): Promise<CloseRoomResponse> => {
|
||||
const response = await fetch(`${API_URL}/api/admin/rooms/${roomCode}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(token)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Failed to close room')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Game Settings
|
||||
|
||||
export interface GameSettings {
|
||||
points_by_difficulty: Record<string, number>
|
||||
times_by_difficulty: Record<string, number>
|
||||
steal_penalty_percent: number
|
||||
max_players_per_team: number
|
||||
steal_time_percent: number
|
||||
}
|
||||
|
||||
export const getSettings = async (token: string): Promise<GameSettings> => {
|
||||
const response = await fetch(`${API_URL}/api/admin/settings`, {
|
||||
headers: getAuthHeaders(token)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch settings')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const updateSettings = async (token: string, settings: GameSettings): Promise<GameSettings> => {
|
||||
const response = await fetch(`${API_URL}/api/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify(settings)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Failed to update settings')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { create } from 'zustand'
|
||||
import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
|
||||
|
||||
export interface Reaction {
|
||||
id: string
|
||||
player_name: string
|
||||
team: 'A' | 'B'
|
||||
emoji: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface TeamMessage {
|
||||
player_name: string
|
||||
team: 'A' | 'B'
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const MAX_TEAM_MESSAGES = 50
|
||||
|
||||
interface GameState {
|
||||
// Room state
|
||||
room: GameRoom | null
|
||||
@@ -23,6 +40,11 @@ interface GameState {
|
||||
addMessage: (message: ChatMessage) => void
|
||||
clearMessages: () => void
|
||||
|
||||
// Team chat messages
|
||||
teamMessages: TeamMessage[]
|
||||
addTeamMessage: (message: TeamMessage) => void
|
||||
clearTeamMessages: () => void
|
||||
|
||||
// Achievements
|
||||
achievements: Achievement[]
|
||||
setAchievements: (achievements: Achievement[]) => void
|
||||
@@ -44,6 +66,12 @@ interface GameState {
|
||||
showStealPrompt: boolean
|
||||
setShowStealPrompt: (show: boolean) => void
|
||||
|
||||
// Reactions
|
||||
reactions: Reaction[]
|
||||
addReaction: (reaction: Omit<Reaction, 'id'>) => void
|
||||
removeReaction: (id: string) => void
|
||||
clearReactions: () => void
|
||||
|
||||
// Game result
|
||||
gameResult: {
|
||||
winner: 'A' | 'B' | null
|
||||
@@ -88,6 +116,13 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
set((state) => ({ messages: [...state.messages, message].slice(-100) })),
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
|
||||
teamMessages: [],
|
||||
addTeamMessage: (message) =>
|
||||
set((state) => ({
|
||||
teamMessages: [...state.teamMessages, message].slice(-MAX_TEAM_MESSAGES),
|
||||
})),
|
||||
clearTeamMessages: () => set({ teamMessages: [] }),
|
||||
|
||||
achievements: [],
|
||||
setAchievements: (achievements) => set({ achievements }),
|
||||
unlockAchievement: (id) =>
|
||||
@@ -105,6 +140,20 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
showStealPrompt: false,
|
||||
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
|
||||
|
||||
reactions: [],
|
||||
addReaction: (reaction) =>
|
||||
set((state) => ({
|
||||
reactions: [
|
||||
...state.reactions,
|
||||
{ ...reaction, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` },
|
||||
],
|
||||
})),
|
||||
removeReaction: (id) =>
|
||||
set((state) => ({
|
||||
reactions: state.reactions.filter((r) => r.id !== id),
|
||||
})),
|
||||
clearReactions: () => set({ reactions: [] }),
|
||||
|
||||
gameResult: null,
|
||||
setGameResult: (gameResult) => set({ gameResult }),
|
||||
|
||||
@@ -114,6 +163,8 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
currentQuestion: null,
|
||||
timerEnd: null,
|
||||
messages: [],
|
||||
teamMessages: [],
|
||||
reactions: [],
|
||||
stats: initialStats,
|
||||
showStealPrompt: false,
|
||||
gameResult: null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { ThemeName } from '../types'
|
||||
|
||||
type SoundEffect =
|
||||
export type SoundEffect =
|
||||
| 'correct'
|
||||
| 'incorrect'
|
||||
| 'steal'
|
||||
@@ -15,9 +15,13 @@ type SoundEffect =
|
||||
interface SoundState {
|
||||
volume: number
|
||||
muted: boolean
|
||||
soundsLoaded: boolean
|
||||
currentLoadedTheme: ThemeName | null
|
||||
setVolume: (volume: number) => void
|
||||
setMuted: (muted: boolean) => void
|
||||
toggleMute: () => void
|
||||
setSoundsLoaded: (loaded: boolean) => void
|
||||
setCurrentLoadedTheme: (theme: ThemeName | null) => void
|
||||
}
|
||||
|
||||
export const useSoundStore = create<SoundState>()(
|
||||
@@ -25,17 +29,23 @@ export const useSoundStore = create<SoundState>()(
|
||||
(set) => ({
|
||||
volume: 0.7,
|
||||
muted: false,
|
||||
setVolume: (volume) => set({ volume }),
|
||||
soundsLoaded: false,
|
||||
currentLoadedTheme: null,
|
||||
setVolume: (volume) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
||||
setMuted: (muted) => set({ muted }),
|
||||
toggleMute: () => set((state) => ({ muted: !state.muted })),
|
||||
setSoundsLoaded: (soundsLoaded) => set({ soundsLoaded }),
|
||||
setCurrentLoadedTheme: (currentLoadedTheme) => set({ currentLoadedTheme }),
|
||||
}),
|
||||
{
|
||||
name: 'trivia-sound',
|
||||
partialize: (state) => ({ volume: state.volume, muted: state.muted }),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Sound file paths per theme
|
||||
// All themes share the same base sounds but can be customized per theme
|
||||
export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
drrr: {
|
||||
correct: '/sounds/drrr/correct.mp3',
|
||||
@@ -88,3 +98,16 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
select: '/sounds/anime/select.mp3',
|
||||
},
|
||||
}
|
||||
|
||||
// Fallback sounds using Web Audio API generated tones
|
||||
// These are used when actual sound files are not available
|
||||
export const fallbackSoundConfigs: Record<SoundEffect, { frequency: number; duration: number; type: OscillatorType }> = {
|
||||
correct: { frequency: 880, duration: 0.15, type: 'sine' },
|
||||
incorrect: { frequency: 220, duration: 0.3, type: 'square' },
|
||||
steal: { frequency: 660, duration: 0.2, type: 'sawtooth' },
|
||||
timer_tick: { frequency: 440, duration: 0.05, type: 'sine' },
|
||||
timer_urgent: { frequency: 880, duration: 0.1, type: 'square' },
|
||||
victory: { frequency: 523, duration: 0.5, type: 'sine' },
|
||||
defeat: { frequency: 196, duration: 0.5, type: 'sine' },
|
||||
select: { frequency: 600, duration: 0.08, type: 'sine' },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user