feat: Initial project structure for WebTriviasMulti

- Backend: FastAPI + Python-SocketIO + SQLAlchemy
  - Models for categories, questions, game sessions, events
  - AI services for answer validation and question generation (Claude)
  - Room management with Redis
  - Game logic with stealing mechanics
  - Admin API for question management

- Frontend: React + Vite + TypeScript + Tailwind
  - 5 visual themes (DRRR, Retro, Minimal, RGB, Anime 90s)
  - Real-time game with Socket.IO
  - Achievement system
  - Replay functionality
  - Sound effects per theme

- Docker Compose for deployment
- Design documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 07:50:48 +00:00
commit 43021b9c3c
57 changed files with 5446 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Backend
DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
REDIS_URL=redis://redis:6379
ANTHROPIC_API_KEY=sk-ant-your-api-key
JWT_SECRET=your-super-secret-jwt-key
# Frontend
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
# Cloudflare Tunnel
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token

63
.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
ENV/
.eggs/
*.egg-info/
*.egg
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build
dist/
build/
*.egg-info/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
.docker/
# Logs
logs/
*.log
# Database
*.db
*.sqlite3
# Coverage
htmlcov/
.coverage
coverage.xml
# Testing
.pytest_cache/
.tox/
# Alembic
alembic/versions/*.pyc

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 8000
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# WebTriviasMulti Backend

View File

@@ -0,0 +1 @@
# API routers

296
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,296 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
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
from app.models.base import get_db
from app.models.admin import Admin
from app.models.question import Question
from app.models.category import Category
from app.schemas.admin import AdminCreate, Token, TokenData
from app.schemas.question import (
QuestionCreate, QuestionUpdate, QuestionResponse,
AIGenerateRequest
)
from app.services.ai_generator import ai_generator
from app.config import get_settings
router = APIRouter()
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
async def get_current_admin(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> Admin:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(Admin).where(Admin.username == username))
admin = result.scalar_one_or_none()
if admin is None:
raise credentials_exception
return admin
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Admin).where(Admin.username == form_data.username)
)
admin = result.scalar_one_or_none()
if not admin or not verify_password(form_data.password, admin.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": admin.username})
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register", response_model=Token)
async def register_admin(
admin_data: AdminCreate,
db: AsyncSession = Depends(get_db)
):
# Check if admin exists
result = await db.execute(
select(Admin).where(Admin.username == admin_data.username)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create admin
admin = Admin(
username=admin_data.username,
password_hash=get_password_hash(admin_data.password)
)
db.add(admin)
await db.commit()
access_token = create_access_token(data={"sub": admin.username})
return {"access_token": access_token, "token_type": "bearer"}
# Question Management
@router.get("/questions", response_model=List[QuestionResponse])
async def get_questions(
category_id: int = None,
status: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
query = select(Question)
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()))
return result.scalars().all()
@router.post("/questions", response_model=QuestionResponse)
async def create_question(
question_data: QuestionCreate,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
question = Question(
**question_data.model_dump(),
points=settings.default_points.get(question_data.difficulty, 300),
time_seconds=settings.default_times.get(question_data.difficulty, 25)
)
db.add(question)
await db.commit()
await db.refresh(question)
return question
@router.put("/questions/{question_id}", response_model=QuestionResponse)
async def update_question(
question_id: int,
question_data: QuestionUpdate,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
for key, value in question_data.model_dump(exclude_unset=True).items():
setattr(question, key, value)
await db.commit()
await db.refresh(question)
return question
@router.delete("/questions/{question_id}")
async def delete_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
await db.delete(question)
await db.commit()
return {"status": "deleted"}
@router.post("/questions/generate")
async def generate_questions(
request: AIGenerateRequest,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
# Get category name
result = await db.execute(
select(Category).where(Category.id == request.category_id)
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# Generate questions with AI
generated = await ai_generator.generate_questions(
category_name=category.name,
difficulty=request.difficulty,
count=request.count
)
# Save to database as pending
questions = []
for q_data in generated:
question = Question(
category_id=request.category_id,
question_text=q_data["question"],
correct_answer=q_data["correct_answer"],
alt_answers=q_data.get("alt_answers", []),
difficulty=q_data["difficulty"],
points=q_data["points"],
time_seconds=q_data["time_seconds"],
fun_fact=q_data.get("fun_fact"),
status="pending"
)
db.add(question)
questions.append(question)
await db.commit()
return {
"generated": len(questions),
"questions": [q.id for q in questions]
}
@router.post("/questions/{question_id}/approve")
async def approve_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
question.status = "approved"
await db.commit()
return {"status": "approved"}
@router.post("/questions/{question_id}/reject")
async def reject_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
await db.delete(question)
await db.commit()
return {"status": "rejected"}
# Categories
@router.get("/categories")
async def get_categories(
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Category))
return result.scalars().all()
@router.post("/categories")
async def create_category(
name: str,
icon: str = None,
color: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
category = Category(name=name, icon=icon, color=color)
db.add(category)
await db.commit()
await db.refresh(category)
return category

119
backend/app/api/game.py Normal file
View File

@@ -0,0 +1,119 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import date
from typing import Dict, List
from app.models.base import get_db
from app.models.question import Question
from app.models.category import Category
from app.schemas.game import RoomCreate, RoomJoin, GameState
router = APIRouter()
@router.get("/categories")
async def get_game_categories():
"""Get all categories for the game board."""
# Return hardcoded categories for now
# In production, these would come from the database
return [
{"id": 1, "name": "Nintendo", "icon": "🍄", "color": "#E60012"},
{"id": 2, "name": "Xbox", "icon": "🎮", "color": "#107C10"},
{"id": 3, "name": "PlayStation", "icon": "🎯", "color": "#003791"},
{"id": 4, "name": "Anime", "icon": "⛩️", "color": "#FF6B9D"},
{"id": 5, "name": "Música", "icon": "🎵", "color": "#1DB954"},
{"id": 6, "name": "Películas", "icon": "🎬", "color": "#F5C518"},
{"id": 7, "name": "Libros", "icon": "📚", "color": "#8B4513"},
{"id": 8, "name": "Historia-Cultura", "icon": "🏛️", "color": "#6B5B95"},
]
@router.get("/board/{room_code}")
async def get_game_board(room_code: str):
"""
Get the game board with questions for today.
Returns questions grouped by category.
"""
from app.services.room_manager import room_manager
room = await room_manager.get_room(room_code)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# If board already exists in room, return it
if room.get("board"):
return room["board"]
# Otherwise, this would load from database
# For now, return empty board structure
return {}
@router.get("/today-questions")
async def get_today_questions():
"""
Get all approved questions for today, grouped by category and difficulty.
This is used to build the game board.
"""
# This would query the database for questions with date_active = today
# For now, return sample structure
return {
"date": str(date.today()),
"categories": {
"1": { # Nintendo
"name": "Nintendo",
"questions": [
{"difficulty": 1, "id": 1, "points": 100},
{"difficulty": 2, "id": 2, "points": 200},
{"difficulty": 3, "id": 3, "points": 300},
{"difficulty": 4, "id": 4, "points": 400},
{"difficulty": 5, "id": 5, "points": 500},
]
}
# ... other categories
}
}
@router.get("/question/{question_id}")
async def get_question(question_id: int):
"""
Get a specific question (without the answer).
Used when a player selects a question.
"""
# This would query the database
# For now, return sample
return {
"id": question_id,
"question_text": "¿En qué año se lanzó la primera consola Nintendo Entertainment System (NES) en Japón?",
"difficulty": 3,
"points": 300,
"time_seconds": 25,
"category_id": 1
}
@router.get("/achievements")
async def get_achievements():
"""Get list of all available achievements."""
return [
{"id": 1, "name": "Primera Victoria", "description": "Ganar tu primera partida", "icon": "🏆"},
{"id": 2, "name": "Racha de 3", "description": "Responder 3 correctas seguidas", "icon": "🔥"},
{"id": 3, "name": "Racha de 5", "description": "Responder 5 correctas seguidas", "icon": "🔥🔥"},
{"id": 4, "name": "Ladrón Novato", "description": "Primer robo exitoso", "icon": "🦝"},
{"id": 5, "name": "Ladrón Maestro", "description": "5 robos exitosos en una partida", "icon": "🦝👑"},
{"id": 6, "name": "Especialista Nintendo", "description": "10 correctas en Nintendo", "icon": "🍄"},
{"id": 7, "name": "Especialista Xbox", "description": "10 correctas en Xbox", "icon": "🎮"},
{"id": 8, "name": "Especialista PlayStation", "description": "10 correctas en PlayStation", "icon": "🎯"},
{"id": 9, "name": "Especialista Anime", "description": "10 correctas en Anime", "icon": "⛩️"},
{"id": 10, "name": "Especialista Música", "description": "10 correctas en Música", "icon": "🎵"},
{"id": 11, "name": "Especialista Películas", "description": "10 correctas en Películas", "icon": "🎬"},
{"id": 12, "name": "Especialista Libros", "description": "10 correctas en Libros", "icon": "📚"},
{"id": 13, "name": "Especialista Historia", "description": "10 correctas en Historia-Cultura", "icon": "🏛️"},
{"id": 14, "name": "Invicto", "description": "Ganar sin fallar ninguna pregunta", "icon": ""},
{"id": 15, "name": "Velocista", "description": "Responder correctamente en menos de 3 segundos", "icon": ""},
{"id": 16, "name": "Comeback", "description": "Ganar estando 500+ puntos abajo", "icon": "🔄"},
{"id": 17, "name": "Dominio Total", "description": "Responder las 5 preguntas de una categoría", "icon": "👑"},
{"id": 18, "name": "Arriesgado", "description": "Responder correctamente 3 preguntas de 500 pts", "icon": "🎰"},
]

113
backend/app/api/replay.py Normal file
View File

@@ -0,0 +1,113 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.models.base import get_db
from app.models.game_session import GameSession
from app.models.game_event import GameEvent
router = APIRouter()
@router.get("/{session_id}")
async def get_replay(
session_id: int,
db: AsyncSession = Depends(get_db)
):
"""
Get replay data for a game session.
Returns all events in chronological order.
"""
# Get session
result = await db.execute(
select(GameSession).where(GameSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Get all events
events_result = await db.execute(
select(GameEvent)
.where(GameEvent.session_id == session_id)
.order_by(GameEvent.timestamp)
)
events = events_result.scalars().all()
return {
"session": {
"id": session.id,
"room_code": session.room_code,
"team_a_score": session.team_a_score,
"team_b_score": session.team_b_score,
"status": session.status,
"created_at": session.created_at,
"finished_at": session.finished_at
},
"events": [
{
"id": e.id,
"event_type": e.event_type,
"player_name": e.player_name,
"team": e.team,
"question_id": e.question_id,
"answer_given": e.answer_given,
"was_correct": e.was_correct,
"was_steal": e.was_steal,
"points_earned": e.points_earned,
"timestamp": e.timestamp
}
for e in events
]
}
@router.get("/code/{room_code}")
async def get_replay_by_code(
room_code: str,
db: AsyncSession = Depends(get_db)
):
"""
Get replay data by room code.
"""
result = await db.execute(
select(GameSession).where(GameSession.room_code == room_code)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return await get_replay(session.id, db)
@router.get("/")
async def list_replays(
limit: int = 20,
offset: int = 0,
db: AsyncSession = Depends(get_db)
):
"""
List recent finished game sessions.
"""
result = await db.execute(
select(GameSession)
.where(GameSession.status == "finished")
.order_by(GameSession.finished_at.desc())
.offset(offset)
.limit(limit)
)
sessions = result.scalars().all()
return [
{
"id": s.id,
"room_code": s.room_code,
"team_a_score": s.team_a_score,
"team_b_score": s.team_b_score,
"finished_at": s.finished_at
}
for s in sessions
]

47
backend/app/config.py Normal file
View File

@@ -0,0 +1,47 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql://trivia:trivia@localhost:5432/trivia"
# Redis
redis_url: str = "redis://localhost:6379"
# JWT
jwt_secret: str = "dev-secret-key-change-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440 # 24 hours
# Anthropic
anthropic_api_key: str = ""
# Game settings
default_times: dict = {
1: 15, # 100 pts
2: 20, # 200 pts
3: 25, # 300 pts
4: 35, # 400 pts
5: 45, # 500 pts
}
default_points: dict = {
1: 100,
2: 200,
3: 300,
4: 400,
5: 500,
}
steal_penalty_multiplier: float = 0.5
steal_time_multiplier: float = 0.5
class Config:
env_file = ".env"
extra = "ignore"
@lru_cache()
def get_settings() -> Settings:
return Settings()

76
backend/app/main.py Normal file
View File

@@ -0,0 +1,76 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import socketio
from contextlib import asynccontextmanager
from app.config import get_settings
from app.api import admin, game, replay
from app.sockets.game_events import register_socket_events
settings = get_settings()
# Socket.IO server
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins="*",
logger=True,
engineio_logger=True
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("Starting WebTriviasMulti server...")
yield
# Shutdown
print("Shutting down WebTriviasMulti server...")
# FastAPI app
app = FastAPI(
title="WebTriviasMulti API",
description="API para el juego de trivia multiplayer",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(game.router, prefix="/api/game", tags=["game"])
app.include_router(replay.router, prefix="/api/replay", tags=["replay"])
# Register Socket.IO events
register_socket_events(sio)
# Mount Socket.IO
socket_app = socketio.ASGIApp(sio, app)
@app.get("/")
async def root():
return {
"message": "WebTriviasMulti API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
# For running with uvicorn directly
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:socket_app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -0,0 +1,7 @@
from app.models.category import Category
from app.models.question import Question
from app.models.game_session import GameSession
from app.models.game_event import GameEvent
from app.models.admin import Admin
__all__ = ["Category", "Question", "GameSession", "GameEvent", "Admin"]

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from app.models.base import Base
class Admin(Base):
__tablename__ = "admins"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<Admin(id={self.id}, username='{self.username}')>"

View File

@@ -0,0 +1,27 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.config import get_settings
settings = get_settings()
# Convert postgresql:// to postgresql+asyncpg://
database_url = settings.database_url.replace(
"postgresql://", "postgresql+asyncpg://"
)
engine = create_async_engine(database_url, echo=True)
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from app.models.base import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, unique=True)
icon = Column(String(50))
color = Column(String(7)) # Hex color
# Relationships
questions = relationship("Question", back_populates="category")
def __repr__(self):
return f"<Category(id={self.id}, name='{self.name}')>"

View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import Base
class GameEvent(Base):
__tablename__ = "game_events"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False)
event_type = Column(String(50), nullable=False) # question_selected, answer_submitted, steal_attempted, etc.
player_name = Column(String(100))
team = Column(String(1)) # 'A' or 'B'
question_id = Column(Integer, ForeignKey("questions.id"))
answer_given = Column(Text)
was_correct = Column(Boolean)
was_steal = Column(Boolean, default=False)
points_earned = Column(Integer)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
session = relationship("GameSession", back_populates="events")
question = relationship("Question", back_populates="game_events")
def __repr__(self):
return f"<GameEvent(id={self.id}, type='{self.event_type}', player='{self.player_name}')>"

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, DateTime, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import Base
class GameSession(Base):
__tablename__ = "game_sessions"
id = Column(Integer, primary_key=True, index=True)
room_code = Column(String(6), unique=True, nullable=False, index=True)
status = Column(String(20), default="waiting") # waiting, playing, finished
team_a_score = Column(Integer, default=0)
team_b_score = Column(Integer, default=0)
current_team = Column(String(1)) # 'A' or 'B'
questions_used = Column(ARRAY(Integer), default=[])
created_at = Column(DateTime(timezone=True), server_default=func.now())
finished_at = Column(DateTime(timezone=True))
# Relationships
events = relationship("GameEvent", back_populates="session")
def __repr__(self):
return f"<GameSession(id={self.id}, room_code='{self.room_code}', status='{self.status}')>"

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import Base
class Question(Base):
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
question_text = Column(Text, nullable=False)
correct_answer = Column(String(500), nullable=False)
alt_answers = Column(ARRAY(String), default=[])
difficulty = Column(Integer, nullable=False) # 1-5
points = Column(Integer, nullable=False)
time_seconds = Column(Integer, nullable=False)
date_active = Column(Date, index=True)
status = Column(String(20), default="pending") # pending, approved, used
fun_fact = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
category = relationship("Category", back_populates="questions")
game_events = relationship("GameEvent", back_populates="question")
def __repr__(self):
return f"<Question(id={self.id}, difficulty={self.difficulty}, status='{self.status}')>"

View File

@@ -0,0 +1,17 @@
from app.schemas.question import QuestionCreate, QuestionUpdate, QuestionResponse
from app.schemas.game import (
RoomCreate,
RoomJoin,
PlayerInfo,
GameState,
AnswerSubmit,
StealAttempt
)
from app.schemas.admin import AdminCreate, AdminLogin, Token
__all__ = [
"QuestionCreate", "QuestionUpdate", "QuestionResponse",
"RoomCreate", "RoomJoin", "PlayerInfo", "GameState",
"AnswerSubmit", "StealAttempt",
"AdminCreate", "AdminLogin", "Token"
]

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
from datetime import datetime
class AdminBase(BaseModel):
username: str
class AdminCreate(AdminBase):
password: str
class AdminLogin(AdminBase):
password: str
class AdminResponse(AdminBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
username: str | None = None

View File

@@ -0,0 +1,70 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class PlayerInfo(BaseModel):
name: str
team: str # 'A' or 'B'
position: int
socket_id: Optional[str] = None
class RoomCreate(BaseModel):
player_name: str
class RoomJoin(BaseModel):
room_code: str
player_name: str
team: str # 'A' or 'B'
class TeamState(BaseModel):
players: List[PlayerInfo]
score: int
current_player_index: int
class QuestionState(BaseModel):
id: int
category_id: int
difficulty: int
points: int
answered: bool = False
class GameState(BaseModel):
room_code: str
status: str # waiting, playing, finished
team_a: TeamState
team_b: TeamState
current_team: Optional[str] = None
current_question: Optional[int] = None
can_steal: bool = False
board: Dict[int, List[QuestionState]] # category_id -> questions
timer_end: Optional[datetime] = None
class AnswerSubmit(BaseModel):
question_id: int
answer: str
class StealAttempt(BaseModel):
question_id: int
attempt: bool # True = try to steal, False = pass
answer: Optional[str] = None
class ChatMessage(BaseModel):
player_name: str
team: str
message: str
timestamp: datetime
class EmojiReaction(BaseModel):
player_name: str
team: str
emoji: str # One of: 👏 😮 😂 🔥 💀 🎉 😭 🤔

View File

@@ -0,0 +1,61 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import date, datetime
class QuestionBase(BaseModel):
question_text: str
correct_answer: str
alt_answers: List[str] = []
difficulty: int
fun_fact: Optional[str] = None
class QuestionCreate(QuestionBase):
category_id: int
class QuestionUpdate(BaseModel):
question_text: Optional[str] = None
correct_answer: Optional[str] = None
alt_answers: Optional[List[str]] = None
difficulty: Optional[int] = None
fun_fact: Optional[str] = None
status: Optional[str] = None
date_active: Optional[date] = None
class QuestionResponse(QuestionBase):
id: int
category_id: int
points: int
time_seconds: int
date_active: Optional[date]
status: str
created_at: datetime
class Config:
from_attributes = True
class QuestionForGame(BaseModel):
id: int
category_id: int
question_text: str
difficulty: int
points: int
time_seconds: int
class Config:
from_attributes = True
class AIGenerateRequest(BaseModel):
category_id: int
difficulty: int
count: int = 5
class AIValidateRequest(BaseModel):
question_id: int
player_answer: str

View File

@@ -0,0 +1,6 @@
from app.services.ai_validator import AIValidator
from app.services.ai_generator import AIGenerator
from app.services.game_manager import GameManager
from app.services.room_manager import RoomManager
__all__ = ["AIValidator", "AIGenerator", "GameManager", "RoomManager"]

View File

@@ -0,0 +1,97 @@
import json
from anthropic import Anthropic
from app.config import get_settings
settings = get_settings()
class AIGenerator:
def __init__(self):
self.client = Anthropic(api_key=settings.anthropic_api_key)
async def generate_questions(
self,
category_name: str,
difficulty: int,
count: int = 5
) -> list[dict]:
"""
Generate trivia questions using Claude AI.
Args:
category_name: Name of the category (e.g., "Nintendo", "Anime")
difficulty: 1-5 (1=very easy, 5=very hard)
count: Number of questions to generate
Returns:
list[dict]: List of question objects
"""
difficulty_descriptions = {
1: "muy fácil - conocimiento básico que la mayoría conoce",
2: "fácil - conocimiento común entre fans casuales",
3: "medio - requiere ser fan de la categoría",
4: "difícil - conocimiento profundo del tema",
5: "muy difícil - solo expertos conocerían esto"
}
prompt = f"""Genera {count} preguntas de trivia para la categoría "{category_name}".
Dificultad: {difficulty} ({difficulty_descriptions.get(difficulty, 'medio')})
Requisitos:
- Las preguntas deben ser verificables y precisas
- Evitar ambigüedades
- Las respuestas deben ser específicas y concisas
- Incluir variaciones comunes de la respuesta
- Para gaming: referencias a juegos, personajes, mecánicas, fechas de lanzamiento
- Para anime: personajes, series, estudios, seiyuus
- Para música: artistas, canciones, álbumes, letras famosas
- Para películas: actores, directores, frases icónicas, premios
- Para libros: autores, obras, personajes literarios
- Para historia-cultura: eventos, fechas, personajes históricos, arte
Formato JSON (array de objetos):
[
{{
"question": "texto de la pregunta",
"correct_answer": "respuesta principal",
"alt_answers": ["variación1", "variación2"],
"fun_fact": "dato curioso opcional sobre la respuesta"
}}
]
Responde SOLO con el JSON, sin texto adicional."""
try:
message = self.client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=2000,
messages=[
{"role": "user", "content": prompt}
]
)
response_text = message.content[0].text.strip()
# Parse JSON response
questions = json.loads(response_text)
# Add metadata to each question
for q in questions:
q["difficulty"] = difficulty
q["points"] = settings.default_points.get(difficulty, 300)
q["time_seconds"] = settings.default_times.get(difficulty, 25)
return questions
except json.JSONDecodeError as e:
print(f"Error parsing AI response: {e}")
print(f"Response was: {response_text}")
return []
except Exception as e:
print(f"Error generating questions: {e}")
return []
# Singleton instance
ai_generator = AIGenerator()

View File

@@ -0,0 +1,80 @@
import json
from anthropic import Anthropic
from app.config import get_settings
settings = get_settings()
class AIValidator:
def __init__(self):
self.client = Anthropic(api_key=settings.anthropic_api_key)
async def validate_answer(
self,
question: str,
correct_answer: str,
alt_answers: list[str],
player_answer: str
) -> dict:
"""
Validate if the player's answer is correct using Claude AI.
Returns:
dict: {"valid": bool, "reason": str}
"""
prompt = f"""Eres un validador de trivia. Determina si la respuesta del jugador
es correcta comparándola con la respuesta oficial.
Pregunta: {question}
Respuesta correcta: {correct_answer}
Respuestas alternativas válidas: {', '.join(alt_answers) if alt_answers else 'Ninguna'}
Respuesta del jugador: {player_answer}
Considera válido si:
- Es sinónimo o variación de la respuesta correcta
- Tiene errores menores de ortografía
- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild")
- Es conceptualmente equivalente
Responde SOLO con JSON: {{"valid": true/false, "reason": "breve explicación"}}"""
try:
message = self.client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=150,
messages=[
{"role": "user", "content": prompt}
]
)
response_text = message.content[0].text.strip()
# Parse JSON response
result = json.loads(response_text)
return result
except json.JSONDecodeError:
# If JSON parsing fails, try to extract the result
response_lower = response_text.lower()
if "true" in response_lower:
return {"valid": True, "reason": "Respuesta validada por IA"}
return {"valid": False, "reason": "No se pudo validar la respuesta"}
except Exception as e:
print(f"Error validating answer: {e}")
# Fallback to exact match
player_lower = player_answer.lower().strip()
correct_lower = correct_answer.lower().strip()
if player_lower == correct_lower:
return {"valid": True, "reason": "Coincidencia exacta"}
for alt in alt_answers:
if player_lower == alt.lower().strip():
return {"valid": True, "reason": "Coincide con respuesta alternativa"}
return {"valid": False, "reason": "Respuesta incorrecta"}
# Singleton instance
ai_validator = AIValidator()

View File

@@ -0,0 +1,204 @@
from typing import Optional
from datetime import datetime, timedelta
from app.services.room_manager import room_manager
from app.services.ai_validator import ai_validator
from app.config import get_settings
settings = get_settings()
class GameManager:
async def start_game(self, room_code: str, board: dict) -> Optional[dict]:
"""
Start a game in a room.
Args:
room_code: The room code
board: Dict of category_id -> list of questions
Returns:
Updated room state
"""
room = await room_manager.get_room(room_code)
if not room:
return None
# Check minimum players
if not room["teams"]["A"] or not room["teams"]["B"]:
return None
# Set up game state
room["status"] = "playing"
room["current_team"] = "A"
room["current_player_index"] = {"A": 0, "B": 0}
room["board"] = board
room["scores"] = {"A": 0, "B": 0}
await room_manager.update_room(room_code, room)
return room
async def select_question(
self,
room_code: str,
question_id: int,
category_id: int
) -> Optional[dict]:
"""Select a question from the board."""
room = await room_manager.get_room(room_code)
if not room or room["status"] != "playing":
return None
# Mark question as current
room["current_question"] = question_id
room["can_steal"] = False
# Find and mark question on board
if str(category_id) in room["board"]:
for q in room["board"][str(category_id)]:
if q["id"] == question_id:
q["selected"] = True
break
await room_manager.update_room(room_code, room)
return room
async def submit_answer(
self,
room_code: str,
question: dict,
player_answer: str,
is_steal: bool = False
) -> dict:
"""
Submit an answer for validation.
Returns:
dict with validation result and updated game state
"""
room = await room_manager.get_room(room_code)
if not room:
return {"error": "Room not found"}
# Validate answer with AI
result = await ai_validator.validate_answer(
question=question["question_text"],
correct_answer=question["correct_answer"],
alt_answers=question.get("alt_answers", []),
player_answer=player_answer
)
is_correct = result.get("valid", False)
points = question["points"]
if is_correct:
# Award points
current_team = room["current_team"]
room["scores"][current_team] += points
# Mark question as answered
category_id = str(question["category_id"])
if category_id in room["board"]:
for q in room["board"][category_id]:
if q["id"] == question["id"]:
q["answered"] = True
break
# Winner chooses next
room["current_question"] = None
room["can_steal"] = False
# Advance player rotation
team_players = room["teams"][current_team]
room["current_player_index"][current_team] = (
room["current_player_index"][current_team] + 1
) % len(team_players)
else:
if is_steal:
# Failed steal - penalize
stealing_team = room["current_team"]
penalty = int(points * settings.steal_penalty_multiplier)
room["scores"][stealing_team] = max(
0, room["scores"][stealing_team] - penalty
)
# Mark question as answered (nobody gets it)
category_id = str(question["category_id"])
if category_id in room["board"]:
for q in room["board"][category_id]:
if q["id"] == question["id"]:
q["answered"] = True
break
# Original team chooses next
room["current_team"] = "B" if stealing_team == "A" else "A"
room["current_question"] = None
room["can_steal"] = False
else:
# Original team failed - enable steal
room["can_steal"] = True
# Switch to other team for potential steal
room["current_team"] = "B" if room["current_team"] == "A" else "A"
# Check if game is over (all questions answered)
all_answered = all(
q["answered"]
for questions in room["board"].values()
for q in questions
)
if all_answered:
room["status"] = "finished"
await room_manager.update_room(room_code, room)
return {
"valid": is_correct,
"reason": result.get("reason", ""),
"points_earned": points if is_correct else 0,
"room": room
}
async def pass_steal(self, room_code: str, question_id: int) -> Optional[dict]:
"""Pass on stealing opportunity."""
room = await room_manager.get_room(room_code)
if not room:
return None
# Mark question as answered
for category_id, questions in room["board"].items():
for q in questions:
if q["id"] == question_id:
q["answered"] = True
break
# Switch back to original team for next selection
room["current_team"] = "B" if room["current_team"] == "A" else "A"
room["current_question"] = None
room["can_steal"] = False
await room_manager.update_room(room_code, room)
return room
async def get_current_player(self, room: dict) -> Optional[dict]:
"""Get the current player who should answer."""
team = room["current_team"]
if not team:
return None
players = room["teams"][team]
if not players:
return None
index = room["current_player_index"][team]
return players[index % len(players)]
def calculate_timer_end(self, time_seconds: int, is_steal: bool = False) -> datetime:
"""Calculate when the timer should end."""
if is_steal:
time_seconds = int(time_seconds * settings.steal_time_multiplier)
return datetime.utcnow() + timedelta(seconds=time_seconds)
# Singleton instance
game_manager = GameManager()

View File

@@ -0,0 +1,173 @@
import json
import random
import string
from typing import Optional
import redis.asyncio as redis
from app.config import get_settings
settings = get_settings()
class RoomManager:
def __init__(self):
self.redis: Optional[redis.Redis] = None
async def connect(self):
if not self.redis:
self.redis = await redis.from_url(settings.redis_url)
async def disconnect(self):
if self.redis:
await self.redis.close()
def _generate_room_code(self) -> str:
"""Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
async def create_room(self, player_name: str, socket_id: str) -> dict:
"""Create a new game room."""
await self.connect()
# Generate unique room code
room_code = self._generate_room_code()
while await self.redis.exists(f"room:{room_code}"):
room_code = self._generate_room_code()
# Create room state
room_state = {
"code": room_code,
"status": "waiting",
"host": player_name,
"teams": {
"A": [],
"B": []
},
"current_team": None,
"current_player_index": {"A": 0, "B": 0},
"current_question": None,
"can_steal": False,
"scores": {"A": 0, "B": 0},
"questions_used": [],
"board": {}
}
# Save room state
await self.redis.setex(
f"room:{room_code}",
3600 * 3, # 3 hours TTL
json.dumps(room_state)
)
# Add player to room
await self.add_player(room_code, player_name, "A", socket_id)
return room_state
async def get_room(self, room_code: str) -> Optional[dict]:
"""Get room state by code."""
await self.connect()
data = await self.redis.get(f"room:{room_code}")
if data:
return json.loads(data)
return None
async def update_room(self, room_code: str, room_state: dict) -> bool:
"""Update room state."""
await self.connect()
await self.redis.setex(
f"room:{room_code}",
3600 * 3,
json.dumps(room_state)
)
return True
async def add_player(
self,
room_code: str,
player_name: str,
team: str,
socket_id: str
) -> Optional[dict]:
"""Add a player to a room."""
room = await self.get_room(room_code)
if not room:
return None
# Check if team is full
if len(room["teams"][team]) >= 4:
return None
# Check if name is taken
for t in ["A", "B"]:
for p in room["teams"][t]:
if p["name"].lower() == player_name.lower():
return None
# Add player
player = {
"name": player_name,
"team": team,
"position": len(room["teams"][team]),
"socket_id": socket_id
}
room["teams"][team].append(player)
# Save player mapping
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": team})
)
await self.update_room(room_code, room)
return room
async def remove_player(self, socket_id: str) -> Optional[dict]:
"""Remove a player from their room."""
await self.connect()
# Get player info
player_data = await self.redis.get(f"player:{socket_id}")
if not player_data:
return None
player_info = json.loads(player_data)
room_code = player_info["room"]
team = player_info["team"]
# Get room
room = await self.get_room(room_code)
if not room:
return None
# Remove player from team
room["teams"][team] = [
p for p in room["teams"][team] if p["socket_id"] != socket_id
]
# Update positions
for i, p in enumerate(room["teams"][team]):
p["position"] = i
# Delete player mapping
await self.redis.delete(f"player:{socket_id}")
# If room is empty, delete it
if not room["teams"]["A"] and not room["teams"]["B"]:
await self.redis.delete(f"room:{room_code}")
return None
await self.update_room(room_code, room)
return room
async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID."""
await self.connect()
data = await self.redis.get(f"player:{socket_id}")
if data:
return json.loads(data)
return None
# Singleton instance
room_manager = RoomManager()

View File

@@ -0,0 +1 @@
# Socket.IO events

View File

@@ -0,0 +1,312 @@
import socketio
from datetime import datetime
from app.services.room_manager import room_manager
from app.services.game_manager import game_manager
def register_socket_events(sio: socketio.AsyncServer):
"""Register all Socket.IO event handlers."""
@sio.event
async def connect(sid, environ):
print(f"Client connected: {sid}")
@sio.event
async def disconnect(sid):
print(f"Client disconnected: {sid}")
# Remove player from room
room = await room_manager.remove_player(sid)
if room:
await sio.emit(
"player_left",
{"room": room},
room=room["code"]
)
@sio.event
async def create_room(sid, data):
"""Create a new game room."""
player_name = data.get("player_name", "Player")
room = await room_manager.create_room(player_name, sid)
# Join socket room
sio.enter_room(sid, room["code"])
await sio.emit("room_created", {"room": room}, to=sid)
@sio.event
async def join_room(sid, data):
"""Join an existing room."""
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player")
team = data.get("team", "A")
room = await room_manager.add_player(room_code, player_name, team, sid)
if not room:
await sio.emit(
"error",
{"message": "Could not join room. It may be full or the name is taken."},
to=sid
)
return
# Join socket room
sio.enter_room(sid, room_code)
# Notify all players
await sio.emit("player_joined", {"room": room}, room=room_code)
@sio.event
async def change_team(sid, data):
"""Switch player to another team."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
new_team = data.get("team")
room = await room_manager.get_room(room_code)
if not room or len(room["teams"][new_team]) >= 4:
await sio.emit(
"error",
{"message": "Cannot change team. It may be full."},
to=sid
)
return
# Remove from current team
current_team = player["team"]
room["teams"][current_team] = [
p for p in room["teams"][current_team] if p["socket_id"] != sid
]
# Add to new team
room["teams"][new_team].append({
"name": player["name"],
"team": new_team,
"position": len(room["teams"][new_team]),
"socket_id": sid
})
await room_manager.update_room(room_code, room)
await sio.emit("team_changed", {"room": room}, room=room_code)
@sio.event
async def start_game(sid, data):
"""Start the game (host only)."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
room = await room_manager.get_room(room_code)
if not room:
return
# Check if player is host
if room["host"] != player["name"]:
await sio.emit(
"error",
{"message": "Only the host can start the game."},
to=sid
)
return
# Check minimum players
if not room["teams"]["A"] or not room["teams"]["B"]:
await sio.emit(
"error",
{"message": "Both teams need at least one player."},
to=sid
)
return
# Get board from data or generate
board = data.get("board", {})
updated_room = await game_manager.start_game(room_code, board)
if updated_room:
await sio.emit("game_started", {"room": updated_room}, room=room_code)
@sio.event
async def select_question(sid, data):
"""Select a question from the board."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
question_id = data.get("question_id")
category_id = data.get("category_id")
room = await game_manager.select_question(room_code, question_id, category_id)
if room:
# Get current player info
current_player = await game_manager.get_current_player(room)
await sio.emit(
"question_selected",
{
"room": room,
"question_id": question_id,
"current_player": current_player
},
room=room_code
)
@sio.event
async def submit_answer(sid, data):
"""Submit an answer to the current question."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
answer = data.get("answer", "")
question = data.get("question", {})
is_steal = data.get("is_steal", False)
result = await game_manager.submit_answer(
room_code, question, answer, is_steal
)
if "error" in result:
await sio.emit("error", {"message": result["error"]}, to=sid)
return
await sio.emit(
"answer_result",
{
"player_name": player["name"],
"team": player["team"],
"answer": answer,
"valid": result["valid"],
"reason": result["reason"],
"points_earned": result["points_earned"],
"was_steal": is_steal,
"room": result["room"]
},
room=room_code
)
@sio.event
async def steal_decision(sid, data):
"""Decide whether to attempt stealing."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
attempt = data.get("attempt", False)
question_id = data.get("question_id")
if not attempt:
# Pass on steal
room = await game_manager.pass_steal(room_code, question_id)
if room:
await sio.emit(
"steal_passed",
{"room": room, "team": player["team"]},
room=room_code
)
else:
# Will attempt steal - just notify, answer comes separately
room = await room_manager.get_room(room_code)
await sio.emit(
"steal_attempted",
{
"team": player["team"],
"player_name": player["name"],
"room": room
},
room=room_code
)
@sio.event
async def chat_message(sid, data):
"""Send a chat message to team."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
message = data.get("message", "")[:500] # Limit message length
# Get all team members' socket IDs
room = await room_manager.get_room(room_code)
if not room:
return
team_sockets = [
p["socket_id"] for p in room["teams"][player["team"]]
]
# Send only to team members
for socket_id in team_sockets:
await sio.emit(
"chat_message",
{
"player_name": player["name"],
"team": player["team"],
"message": message,
"timestamp": datetime.utcnow().isoformat()
},
to=socket_id
)
@sio.event
async def emoji_reaction(sid, data):
"""Send an emoji reaction visible to all."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
emoji = data.get("emoji", "")
# Validate emoji
allowed_emojis = ["👏", "😮", "😂", "🔥", "💀", "🎉", "😭", "🤔"]
if emoji not in allowed_emojis:
return
await sio.emit(
"emoji_reaction",
{
"player_name": player["name"],
"team": player["team"],
"emoji": emoji
},
room=room_code
)
@sio.event
async def timer_expired(sid, data):
"""Handle timer expiration."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
room = await room_manager.get_room(room_code)
if not room:
return
# Treat as wrong answer
if room["can_steal"]:
# Steal timer expired - pass
question_id = room["current_question"]
room = await game_manager.pass_steal(room_code, question_id)
await sio.emit("time_up", {"room": room, "was_steal": True}, room=room_code)
else:
# Answer timer expired - enable steal
room["can_steal"] = True
room["current_team"] = "B" if room["current_team"] == "A" else "A"
await room_manager.update_room(room_code, room)
await sio.emit("time_up", {"room": room, "was_steal": False}, room=room_code)

37
backend/requirements.txt Normal file
View File

@@ -0,0 +1,37 @@
# FastAPI
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# WebSockets
python-socketio==5.11.0
# Database
sqlalchemy==2.0.25
asyncpg==0.29.0
alembic==1.13.1
psycopg2-binary==2.9.9
# Redis
redis==5.0.1
aioredis==2.0.1
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# AI
anthropic==0.18.1
# Utilities
pydantic==2.6.0
pydantic-settings==2.1.0
python-dotenv==1.0.0
# Scheduler
apscheduler==3.10.4
# Testing
pytest==8.0.0
pytest-asyncio==0.23.4
httpx==0.26.0

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
- VITE_WS_URL=${VITE_WS_URL:-ws://localhost:8000}
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql://trivia:trivia@db:5432/trivia}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- JWT_SECRET=${JWT_SECRET:-dev-secret-key}
depends_on:
- db
- redis
volumes:
- ./backend:/app
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=trivia
- POSTGRES_USER=trivia
- POSTGRES_PASSWORD=trivia
ports:
- "5432:5432"
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
ports:
- "6379:6379"
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- frontend
- backend
profiles:
- production
volumes:
postgres_data:
redis_data:

View File

@@ -0,0 +1,659 @@
# WebTriviasMulti - Documento de Diseño
**Fecha:** 2026-01-26
**Versión:** 1.0
**Estado:** Aprobado
---
## 1. Visión General
### 1.1 Descripción
WebTriviasMulti es una aplicación web de trivia multiplayer en tiempo real, inspirada en el formato de Jeopardy. Permite partidas entre 2 equipos de hasta 4 jugadores cada uno, con preguntas organizadas por categorías y niveles de dificultad.
### 1.2 Características Principales
- Partidas en tiempo real con WebSockets
- 8 categorías temáticas: Nintendo, Xbox, PlayStation, Anime, Música, Películas, Libros, Historia-Cultura
- Tablero estilo Jeopardy (5 niveles de dificultad por categoría)
- Sistema de "robo" de puntos entre equipos
- Validación de respuestas mediante IA (Claude)
- Generación automática de preguntas con aprobación de administrador
- 5 temas visuales intercambiables
- Sistema de logros
- Replays de partidas
- Sonidos temáticos
---
## 2. Mecánicas del Juego
### 2.1 Flujo de Partida
1. **Creación de sala**: Un jugador crea sala y recibe código de 6 caracteres
2. **Lobby**: Jugadores se unen con el código, eligen equipo (máx 4 por equipo), ingresan nombre
3. **Inicio**: El host inicia cuando hay al menos 1 jugador por equipo
4. **Tablero**: Se muestra el tablero con 8 categorías × 5 preguntas (100-500 pts)
5. **Turno**: El jugador en rotación del equipo activo selecciona una pregunta
6. **Respuesta**: Tiene X segundos (según dificultad) para escribir respuesta
7. **Validación IA**: Claude valida si la respuesta es correcta
8. **Robo opcional**: Si falla, equipo contrario decide si intenta robar
9. **Siguiente turno**: El equipo ganador elige siguiente pregunta
10. **Final**: Partida termina cuando se agotan las preguntas
### 2.2 Sistema de Turnos
- **Rotación obligatoria**: Cada pregunta la responde un miembro diferente del equipo
- **El que acierta elige**: El equipo que responde correctamente (o roba) elige la siguiente pregunta
### 2.3 Sistema de Puntos y Tiempo
| Dificultad | Puntos | Tiempo |
|------------|--------|--------|
| 1 (Fácil) | 100 | 15 seg |
| 2 | 200 | 20 seg |
| 3 (Media) | 300 | 25 seg |
| 4 | 400 | 35 seg |
| 5 (Difícil)| 500 | 45 seg |
### 2.4 Mecánica de Robo
- **Voluntario**: El equipo contrario decide si intenta robar o pasar
- **Penalización**: Si fallan el robo, pierden la mitad de los puntos de la pregunta
- **Tiempo reducido**: El equipo que roba tiene la mitad del tiempo original
### 2.5 Formato de Respuestas
- Respuesta abierta (el jugador escribe libremente)
- Validación semántica con IA (acepta sinónimos, variaciones, errores de ortografía menores)
---
## 3. Arquitectura Técnica
### 3.1 Stack Tecnológico
```
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
│ - Vite + React 18 + TypeScript │
│ - Tailwind CSS + Framer Motion (animaciones) │
│ - Socket.io-client (tiempo real) │
│ - Zustand (estado global) │
│ - 5 temas visuales intercambiables │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (Python FastAPI) │
│ - FastAPI + Uvicorn │
│ - python-socketio (WebSockets) │
│ - SQLAlchemy (ORM) │
│ - Anthropic SDK (validación IA) │
│ - APScheduler (tareas programadas) │
└─────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Redis │ │ Claude │
│(datos) │ │ (estado) │ │ (IA) │
└──────────┘ └──────────┘ └──────────┘
```
### 3.2 Responsabilidades por Componente
- **PostgreSQL**: Preguntas, categorías, historial de partidas, configuración, logros
- **Redis**: Estado de salas activas, turnos, temporizadores, sesiones WebSocket
- **Claude API**: Validar respuestas, generar preguntas nuevas
---
## 4. Modelo de Datos
### 4.1 PostgreSQL
```sql
-- Categorías
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
icon VARCHAR(50),
color VARCHAR(7)
);
-- Preguntas
CREATE TABLE questions (
id SERIAL PRIMARY KEY,
category_id INTEGER REFERENCES categories(id),
question_text TEXT NOT NULL,
correct_answer VARCHAR(500) NOT NULL,
alt_answers TEXT[], -- Respuestas alternativas válidas
difficulty INTEGER CHECK (difficulty BETWEEN 1 AND 5),
points INTEGER NOT NULL,
time_seconds INTEGER NOT NULL,
date_active DATE,
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, used
fun_fact TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Sesiones de juego
CREATE TABLE game_sessions (
id SERIAL PRIMARY KEY,
room_code VARCHAR(6) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'waiting', -- waiting, playing, finished
team_a_score INTEGER DEFAULT 0,
team_b_score INTEGER DEFAULT 0,
current_team VARCHAR(1), -- 'A' o 'B'
questions_used INTEGER[],
created_at TIMESTAMP DEFAULT NOW(),
finished_at TIMESTAMP
);
-- Eventos de juego (para replays)
CREATE TABLE game_events (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES game_sessions(id),
event_type VARCHAR(50) NOT NULL,
player_name VARCHAR(100),
team VARCHAR(1),
question_id INTEGER REFERENCES questions(id),
answer_given TEXT,
was_correct BOOLEAN,
was_steal BOOLEAN DEFAULT FALSE,
points_earned INTEGER,
timestamp TIMESTAMP DEFAULT NOW()
);
-- Administradores
CREATE TABLE admins (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.2 Redis (Estado Volátil)
```
room:{code} → {
players: [{name, team, position, socketId}],
teams: {A: [], B: []},
currentTeam: 'A',
currentPlayerIndex: {A: 0, B: 0},
status: 'waiting|playing|finished',
timer: timestamp,
canSteal: false,
currentQuestion: questionId
}
player:{socket_id} → {name, room, team, position}
```
---
## 5. Sistema de IA
### 5.1 Validación de Respuestas
```python
VALIDATION_PROMPT = """
Eres un validador de trivia. Determina si la respuesta del jugador
es correcta comparándola con la respuesta oficial.
Pregunta: {question}
Respuesta correcta: {correct_answer}
Respuestas alternativas válidas: {alt_answers}
Respuesta del jugador: {player_answer}
Considera válido si:
- Es sinónimo o variación de la respuesta correcta
- Tiene errores menores de ortografía
- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild")
- Es conceptualmente equivalente
Responde SOLO con JSON: {"valid": true/false, "reason": "breve explicación"}
"""
```
### 5.2 Generación de Preguntas
```python
GENERATION_PROMPT = """
Genera 5 preguntas de trivia para la categoría {category}.
Dificultad: {difficulty} (1=muy fácil, 5=muy difícil)
Formato JSON por pregunta:
{
"question": "texto de la pregunta",
"correct_answer": "respuesta principal",
"alt_answers": ["variación1", "variación2"],
"fun_fact": "dato curioso opcional"
}
Requisitos:
- Las preguntas deben ser verificables y precisas
- Evitar ambigüedades
- Ajustar complejidad según dificultad
- Para gaming: incluir referencias a juegos, personajes, mecánicas
- Para cultura: hechos históricos, arte, literatura
"""
```
### 5.3 Flujo de Aprobación
1. Admin solicita generar preguntas desde panel
2. Claude genera batch de preguntas
3. Quedan en estado `pending`
4. Admin revisa, edita si necesario, aprueba o rechaza
5. Preguntas aprobadas se asignan a fecha futura
---
## 6. Temas Visuales
### 6.1 Temas Disponibles
| Tema | Paleta Principal | Características |
|------|------------------|-----------------|
| **DRRR (Dollars)** | Negro, amarillo neón (#FFE135), cyan (#00FFFF) | Chat estilo Dollars, efectos glitch, tipografía urbana, bordes neón pulsantes |
| **Retro Arcade** | Púrpura (#9B59B6), rosa (#E91E63), cyan pixelado | Pixel art UI, tipografía 8-bit (Press Start 2P), scanlines, efectos CRT |
| **Moderno Minimalista** | Blanco (#FFFFFF), grises, acento azul (#3498DB) | Limpio, sombras suaves, tipografía sans-serif, transiciones elegantes |
| **Gaming RGB** | Negro (#0D0D0D) con gradientes RGB | Efectos de luz LED, gradientes animados, bordes brillantes, estilo "gamer" |
| **Anime Clásico 90s** | Pasteles, rosa (#FFB6C1), lavanda (#E6E6FA) | Estrellas brillantes, efectos sparkle, bordes redondeados, estilo shoujo |
### 6.2 Implementación
```
/src/themes/
├── ThemeProvider.tsx # Context para tema activo
├── index.ts # Exporta todos los temas
├── drrr/
│ ├── variables.css
│ ├── components.tsx
│ └── sounds/
├── retro-arcade/
├── minimal/
├── gaming-rgb/
└── anime-90s/
```
---
## 7. Sistema de Sonidos
### 7.1 Eventos con Sonido
| Evento | DRRR | Retro | Minimal | RGB | Anime 90s |
|--------|------|-------|---------|-----|-----------|
| Correcto | Glitch digital | 8-bit coin | Soft chime | Synth rise | Sparkle |
| Incorrecto | Static buzz | 8-bit fail | Low tone | Bass drop | Comedic |
| Robo | Suspense | Power-up | Click | Laser | Drama sting |
| Timer (tick) | Heartbeat | Beeps | Ticks | Pulse | Tension |
| Timer (urgente) | Fast heartbeat | Fast beeps | Fast ticks | Fast pulse | Panic |
| Victoria | Epic synth | Fanfare | Elegant | EDM drop | Anime victory |
| Derrota | Glitch fade | Game over | Soft close | Power down | Sad piano |
| Selección | Click neón | 8-bit select | Pop | RGB sweep | Cute pop |
### 7.2 Almacenamiento
- Archivos en formato WebM/OGG para compatibilidad
- Precargados al seleccionar tema
- Volumen configurable por usuario
---
## 8. Sistema de Logros
### 8.1 Lista de Logros
| ID | Nombre | Condición | Icono |
|----|--------|-----------|-------|
| 1 | Primera Victoria | Ganar tu primera partida | 🏆 |
| 2 | Racha de 3 | Responder 3 correctas seguidas | 🔥 |
| 3 | Racha de 5 | Responder 5 correctas seguidas | 🔥🔥 |
| 4 | Ladrón Novato | Primer robo exitoso | 🦝 |
| 5 | Ladrón Maestro | 5 robos exitosos en una partida | 🦝👑 |
| 6 | Especialista Nintendo | 10 correctas en Nintendo | 🍄 |
| 7 | Especialista Xbox | 10 correctas en Xbox | 🎮 |
| 8 | Especialista PlayStation | 10 correctas en PlayStation | 🎯 |
| 9 | Especialista Anime | 10 correctas en Anime | ⛩️ |
| 10 | Especialista Música | 10 correctas en Música | 🎵 |
| 11 | Especialista Películas | 10 correctas en Películas | 🎬 |
| 12 | Especialista Libros | 10 correctas en Libros | 📚 |
| 13 | Especialista Historia | 10 correctas en Historia-Cultura | 🏛️ |
| 14 | Invicto | Ganar sin fallar ninguna pregunta | ⭐ |
| 15 | Velocista | Responder correctamente en menos de 3 segundos | ⚡ |
| 16 | Comeback | Ganar estando 500+ puntos abajo | 🔄 |
| 17 | Dominio Total | Responder las 5 preguntas de una categoría correctamente | 👑 |
| 18 | Arriesgado | Responder correctamente 3 preguntas de 500 pts | 🎰 |
### 8.2 Almacenamiento
- localStorage por navegador (sin cuentas)
- Estructura: `{odooId: {achievements: [...], stats: {...}}}`
- Mostrados al final de cada partida (nuevos desbloqueados)
---
## 9. Sistema de Replays
### 9.1 Datos Capturados
Usando la tabla `game_events`, cada evento registra:
- Tipo de evento (question_selected, answer_submitted, steal_attempted, etc.)
- Jugador y equipo
- Pregunta seleccionada
- Respuesta dada
- Resultado (correcto/incorrecto)
- Puntos ganados/perdidos
- Timestamp preciso
### 9.2 Reproducción
1. Al finalizar partida, opción "Ver Replay"
2. Carga eventos ordenados por timestamp
3. Reproduce animación acelerada (x2, x4, x8)
4. Muestra:
- Tablero con preguntas revelándose
- Respuestas de jugadores
- Marcador actualizándose
- Momentos de robo
5. Controles: Play/Pause, velocidad, timeline scrubber
### 9.3 Compartir
- Código único de replay (basado en session_id)
- URL compartible: `/replay/{code}`
---
## 10. Panel de Administración
### 10.1 Funcionalidades
```
┌─────────────────────────────────────────────────────────────┐
│ PANEL ADMINISTRADOR │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📊 Dashboard │
│ - Partidas activas en tiempo real │
│ - Estadísticas del día │
│ - Preguntas pendientes de aprobación │
│ │
│ ❓ Gestión de Preguntas │
│ - CRUD de preguntas por categoría │
│ - Generar con IA (botón por categoría/dificultad) │
│ - Cola de aprobación │
│ - Asignar a fechas │
│ - Importar/exportar CSV │
│ │
│ 📅 Calendario │
│ - Vista mensual de preguntas programadas │
│ - Alertas de días sin contenido │
│ │
│ 🎮 Monitor │
│ - Salas activas │
│ - Cerrar salas problemáticas │
│ │
│ ⚙️ Configuración │
│ - Tiempos y puntos por dificultad │
│ - Penalización de robo │
│ - API keys │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 10.2 Autenticación
- Login con usuario/contraseña
- JWT para sesiones
- Rutas protegidas `/admin/*`
---
## 11. Comunicación en Partida
### 11.1 Chat de Equipo
- Visible solo para miembros del mismo equipo
- Mensajes en tiempo real via WebSocket
- Historial durante la partida
### 11.2 Reacciones Globales
- Emojis predefinidos que todos pueden ver
- Limitado para evitar spam (1 cada 3 segundos)
- Emojis disponibles: 👏 😮 😂 🔥 💀 🎉 😭 🤔
---
## 12. Estructura del Proyecto
```
WebTriviasMulti/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ ├── config.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── category.py
│ │ │ ├── question.py
│ │ │ ├── game_session.py
│ │ │ ├── game_event.py
│ │ │ └── admin.py
│ │ ├── schemas/
│ │ │ ├── __init__.py
│ │ │ ├── question.py
│ │ │ ├── game.py
│ │ │ └── admin.py
│ │ ├── services/
│ │ │ ├── __init__.py
│ │ │ ├── ai_validator.py
│ │ │ ├── ai_generator.py
│ │ │ ├── game_manager.py
│ │ │ └── room_manager.py
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── admin.py
│ │ │ ├── game.py
│ │ │ └── replay.py
│ │ └── sockets/
│ │ ├── __init__.py
│ │ └── game_events.py
│ ├── requirements.txt
│ ├── Dockerfile
│ ├── alembic.ini
│ └── alembic/
│ └── versions/
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── game/
│ │ │ │ ├── Board.tsx
│ │ │ │ ├── QuestionCard.tsx
│ │ │ │ ├── Timer.tsx
│ │ │ │ ├── ScoreBoard.tsx
│ │ │ │ └── AnswerInput.tsx
│ │ │ ├── lobby/
│ │ │ │ ├── CreateRoom.tsx
│ │ │ │ ├── JoinRoom.tsx
│ │ │ │ ├── TeamSelect.tsx
│ │ │ │ └── PlayerList.tsx
│ │ │ ├── chat/
│ │ │ │ ├── TeamChat.tsx
│ │ │ │ └── EmojiReactions.tsx
│ │ │ ├── replay/
│ │ │ │ ├── ReplayPlayer.tsx
│ │ │ │ └── ReplayControls.tsx
│ │ │ ├── achievements/
│ │ │ │ ├── AchievementPopup.tsx
│ │ │ │ └── AchievementList.tsx
│ │ │ └── ui/
│ │ │ ├── Button.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Input.tsx
│ │ │ └── Toast.tsx
│ │ ├── themes/
│ │ │ ├── ThemeProvider.tsx
│ │ │ ├── index.ts
│ │ │ ├── drrr/
│ │ │ ├── retro-arcade/
│ │ │ ├── minimal/
│ │ │ ├── gaming-rgb/
│ │ │ └── anime-90s/
│ │ ├── hooks/
│ │ │ ├── useSocket.ts
│ │ │ ├── useGame.ts
│ │ │ ├── useSound.ts
│ │ │ └── useAchievements.ts
│ │ ├── stores/
│ │ │ ├── gameStore.ts
│ │ │ ├── themeStore.ts
│ │ │ └── soundStore.ts
│ │ ├── pages/
│ │ │ ├── Home.tsx
│ │ │ ├── Lobby.tsx
│ │ │ ├── Game.tsx
│ │ │ ├── Replay.tsx
│ │ │ ├── Results.tsx
│ │ │ └── admin/
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Questions.tsx
│ │ │ ├── Calendar.tsx
│ │ │ ├── Monitor.tsx
│ │ │ └── Settings.tsx
│ │ ├── services/
│ │ │ ├── socket.ts
│ │ │ └── api.ts
│ │ ├── types/
│ │ │ └── index.ts
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── public/
│ │ └── sounds/
│ ├── package.json
│ ├── Dockerfile
│ ├── vite.config.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
├── docker-compose.yml
├── .env.example
├── .gitignore
├── README.md
└── docs/
└── plans/
└── 2026-01-26-webtriviasmulti-design.md
```
---
## 13. Despliegue
### 13.1 Docker Compose
```yaml
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- VITE_API_URL=http://localhost:8000
- VITE_WS_URL=ws://localhost:8000
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
- REDIS_URL=redis://redis:6379
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
- redis
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=trivia
- POSTGRES_USER=trivia
- POSTGRES_PASSWORD=trivia
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- frontend
- backend
volumes:
postgres_data:
redis_data:
```
### 13.2 Variables de Entorno
```env
# Backend
DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
REDIS_URL=redis://redis:6379
ANTHROPIC_API_KEY=sk-ant-...
JWT_SECRET=your-secret-key
# Frontend
VITE_API_URL=https://trivia.tudominio.com/api
VITE_WS_URL=wss://trivia.tudominio.com
# Cloudflare
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
```
---
## 14. Roadmap Futuro
### Fase 3 - Competitivo
- Ranking global (requiere cuentas opcionales)
- Torneos programados
- Temporadas con recompensas
### Fase 4 - Social
- Compartir resultados en redes
- Salas recurrentes
- Desafíos diarios
### Fase 5 - Contenido
- Categorías rotativas por eventos
- Preguntas de la comunidad
- Modo "Experto"
### Fase 6 - Técnico
- PWA instalable
- API pública
- Modo offline
---
## 15. Referencias
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [Socket.IO](https://socket.io/)
- [Anthropic Claude API](https://docs.anthropic.com/)
- [React](https://react.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.framer.com/motion/)
---
*Documento generado el 2026-01-26*

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source
COPY . .
# Expose port
EXPOSE 3000
# Development command
CMD ["npm", "run", "dev"]

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="WebTriviasMulti - Trivia multiplayer en tiempo real" />
<title>WebTriviasMulti</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "webtriviasmulti-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"socket.io-client": "^4.7.4",
"zustand": "^4.5.0",
"framer-motion": "^11.0.3",
"howler": "^2.2.4",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/howler": "^2.2.11",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

22
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import Lobby from './pages/Lobby'
import Game from './pages/Game'
import Results from './pages/Results'
import Replay from './pages/Replay'
function App() {
return (
<div className="min-h-screen">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/lobby/:roomCode" element={<Lobby />} />
<Route path="/game/:roomCode" element={<Game />} />
<Route path="/results/:roomCode" element={<Results />} />
<Route path="/replay/:sessionId" element={<Replay />} />
</Routes>
</div>
)
}
export default App

View File

@@ -0,0 +1,165 @@
import { useCallback, useEffect } from 'react'
import { useGameStore } from '../stores/gameStore'
import type { Achievement } from '../types'
const STORAGE_KEY = 'trivia-achievements'
// Achievement definitions
const achievementDefinitions: Achievement[] = [
{ id: 1, name: 'Primera Victoria', description: 'Ganar tu primera partida', icon: '🏆' },
{ id: 2, name: 'Racha de 3', description: 'Responder 3 correctas seguidas', icon: '🔥' },
{ id: 3, name: 'Racha de 5', description: 'Responder 5 correctas seguidas', icon: '🔥🔥' },
{ id: 4, name: 'Ladrón Novato', description: 'Primer robo exitoso', icon: '🦝' },
{ id: 5, name: 'Ladrón Maestro', description: '5 robos exitosos en una partida', icon: '🦝👑' },
{ id: 6, name: 'Especialista Nintendo', description: '10 correctas en Nintendo', icon: '🍄' },
{ id: 7, name: 'Especialista Xbox', description: '10 correctas en Xbox', icon: '🎮' },
{ id: 8, name: 'Especialista PlayStation', description: '10 correctas en PlayStation', icon: '🎯' },
{ id: 9, name: 'Especialista Anime', description: '10 correctas en Anime', icon: '⛩️' },
{ id: 10, name: 'Especialista Música', description: '10 correctas en Música', icon: '🎵' },
{ id: 11, name: 'Especialista Películas', description: '10 correctas en Películas', icon: '🎬' },
{ id: 12, name: 'Especialista Libros', description: '10 correctas en Libros', icon: '📚' },
{ id: 13, name: 'Especialista Historia', description: '10 correctas en Historia-Cultura', icon: '🏛️' },
{ id: 14, name: 'Invicto', description: 'Ganar sin fallar ninguna pregunta', icon: '⭐' },
{ id: 15, name: 'Velocista', description: 'Responder correctamente en menos de 3 segundos', icon: '⚡' },
{ id: 16, name: 'Comeback', description: 'Ganar estando 500+ puntos abajo', icon: '🔄' },
{ id: 17, name: 'Dominio Total', description: 'Responder las 5 preguntas de una categoría', icon: '👑' },
{ id: 18, name: 'Arriesgado', description: 'Responder correctamente 3 preguntas de 500 pts', icon: '🎰' },
]
export function useAchievements() {
const { achievements, setAchievements, unlockAchievement, stats } = useGameStore()
// Load achievements from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
// Merge with definitions to ensure all achievements exist
const merged = achievementDefinitions.map((def) => ({
...def,
unlocked: parsed.find((a: Achievement) => a.id === def.id)?.unlocked || false,
unlockedAt: parsed.find((a: Achievement) => a.id === def.id)?.unlockedAt,
}))
setAchievements(merged)
} catch {
setAchievements(achievementDefinitions)
}
} else {
setAchievements(achievementDefinitions)
}
}, [setAchievements])
// Save achievements to localStorage when they change
useEffect(() => {
if (achievements.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(achievements))
}
}, [achievements])
const checkAchievements = useCallback(
(context: {
won?: boolean
correctStreak?: number
stealSuccess?: boolean
categoryId?: number
answerTime?: number
deficit?: number
points?: number
neverFailed?: boolean
categoryComplete?: number
}) => {
const newUnlocks: number[] = []
// First Victory
if (context.won && !achievements.find((a) => a.id === 1)?.unlocked) {
unlockAchievement(1)
newUnlocks.push(1)
}
// Streaks
if (context.correctStreak && context.correctStreak >= 3) {
if (!achievements.find((a) => a.id === 2)?.unlocked) {
unlockAchievement(2)
newUnlocks.push(2)
}
}
if (context.correctStreak && context.correctStreak >= 5) {
if (!achievements.find((a) => a.id === 3)?.unlocked) {
unlockAchievement(3)
newUnlocks.push(3)
}
}
// Steals
if (context.stealSuccess) {
if (!achievements.find((a) => a.id === 4)?.unlocked) {
unlockAchievement(4)
newUnlocks.push(4)
}
if (stats.stealsSuccessful >= 5 && !achievements.find((a) => a.id === 5)?.unlocked) {
unlockAchievement(5)
newUnlocks.push(5)
}
}
// Category specialists (6-13)
const categoryAchievementMap: Record<number, number> = {
1: 6, // Nintendo
2: 7, // Xbox
3: 8, // PlayStation
4: 9, // Anime
5: 10, // Música
6: 11, // Películas
7: 12, // Libros
8: 13, // Historia-Cultura
}
if (context.categoryId) {
const achievementId = categoryAchievementMap[context.categoryId]
const categoryCount = stats.categoryCorrect[context.categoryId] || 0
if (categoryCount >= 10 && achievementId && !achievements.find((a) => a.id === achievementId)?.unlocked) {
unlockAchievement(achievementId)
newUnlocks.push(achievementId)
}
}
// Invicto
if (context.won && context.neverFailed && !achievements.find((a) => a.id === 14)?.unlocked) {
unlockAchievement(14)
newUnlocks.push(14)
}
// Velocista
if (context.answerTime && context.answerTime < 3 && !achievements.find((a) => a.id === 15)?.unlocked) {
unlockAchievement(15)
newUnlocks.push(15)
}
// Comeback
if (context.won && context.deficit && context.deficit >= 500 && !achievements.find((a) => a.id === 16)?.unlocked) {
unlockAchievement(16)
newUnlocks.push(16)
}
// Dominio Total
if (context.categoryComplete && !achievements.find((a) => a.id === 17)?.unlocked) {
unlockAchievement(17)
newUnlocks.push(17)
}
// Arriesgado
if (context.points === 500 && stats.fastAnswers >= 3 && !achievements.find((a) => a.id === 18)?.unlocked) {
unlockAchievement(18)
newUnlocks.push(18)
}
return newUnlocks.map((id) => achievements.find((a) => a.id === id)!)
},
[achievements, stats, unlockAchievement]
)
return {
achievements,
checkAchievements,
}
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import { useGameStore } from '../stores/gameStore'
import type { GameRoom, ChatMessage, AnswerResult } from '../types'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } =
useGameStore()
useEffect(() => {
// Create socket connection
socketRef.current = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
autoConnect: true,
})
const socket = socketRef.current
// Connection events
socket.on('connect', () => {
console.log('Connected to server')
})
socket.on('disconnect', () => {
console.log('Disconnected from server')
})
socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message)
})
// Room events
socket.on('room_created', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('player_joined', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('player_left', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('team_changed', (data: { room: GameRoom }) => {
setRoom(data.room)
})
// Game events
socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
setRoom(data.room)
// Fetch full question details
})
socket.on('answer_result', (data: AnswerResult) => {
setRoom(data.room)
if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
}
})
socket.on('steal_attempted', (data: { room: GameRoom }) => {
setRoom(data.room)
setShowStealPrompt(false)
})
socket.on('steal_passed', (data: { room: GameRoom }) => {
setRoom(data.room)
setShowStealPrompt(false)
})
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
setRoom(data.room)
if (!data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
} else {
setShowStealPrompt(false)
}
})
// Chat events
socket.on('chat_message', (data: ChatMessage) => {
addMessage(data)
})
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}`)
})
return () => {
socket.disconnect()
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd])
// Socket methods
const createRoom = useCallback((playerName: string) => {
socketRef.current?.emit('create_room', { player_name: playerName })
}, [])
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketRef.current?.emit('join_room', {
room_code: roomCode,
player_name: playerName,
team,
})
}, [])
const changeTeam = useCallback((team: 'A' | 'B') => {
socketRef.current?.emit('change_team', { team })
}, [])
const startGame = useCallback((board: Record<string, unknown>) => {
socketRef.current?.emit('start_game', { board })
}, [])
const selectQuestion = useCallback((questionId: number, categoryId: number) => {
socketRef.current?.emit('select_question', {
question_id: questionId,
category_id: categoryId,
})
}, [])
const submitAnswer = useCallback(
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
socketRef.current?.emit('submit_answer', {
answer,
question,
is_steal: isSteal,
})
},
[]
)
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
socketRef.current?.emit('steal_decision', {
attempt,
question_id: questionId,
answer,
})
}, [])
const sendChatMessage = useCallback((message: string) => {
socketRef.current?.emit('chat_message', { message })
}, [])
const sendEmojiReaction = useCallback((emoji: string) => {
socketRef.current?.emit('emoji_reaction', { emoji })
}, [])
const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {})
}, [])
return {
socket: socketRef.current,
createRoom,
joinRoom,
changeTeam,
startGame,
selectQuestion,
submitAnswer,
stealDecision,
sendChatMessage,
sendEmojiReaction,
notifyTimerExpired,
}
}

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useRef } from 'react'
import { Howl } from 'howler'
import { useSoundStore, soundPaths } from '../stores/soundStore'
import { useThemeStore } from '../stores/themeStore'
import type { ThemeName } from '../types'
type SoundEffect =
| 'correct'
| 'incorrect'
| 'steal'
| 'timer_tick'
| 'timer_urgent'
| 'victory'
| 'defeat'
| 'select'
export function useSound() {
const { volume, muted } = useSoundStore()
const { currentTheme } = useThemeStore()
const soundsRef = useRef<Map<string, Howl>>(new Map())
// Preload sounds for current theme
useEffect(() => {
const themeSounds = soundPaths[currentTheme]
if (!themeSounds) return
// Clear old sounds
soundsRef.current.forEach((sound) => sound.unload())
soundsRef.current.clear()
// Load new sounds
Object.entries(themeSounds).forEach(([key, path]) => {
const sound = new Howl({
src: [path],
volume: volume,
preload: true,
onloaderror: () => {
console.warn(`Failed to load sound: ${path}`)
},
})
soundsRef.current.set(key, sound)
})
return () => {
soundsRef.current.forEach((sound) => sound.unload())
}
}, [currentTheme])
// Update volume when it changes
useEffect(() => {
soundsRef.current.forEach((sound) => {
sound.volume(volume)
})
}, [volume])
const play = useCallback(
(effect: SoundEffect) => {
if (muted) return
const sound = soundsRef.current.get(effect)
if (sound) {
sound.play()
}
},
[muted]
)
const stop = useCallback((effect: SoundEffect) => {
const sound = soundsRef.current.get(effect)
if (sound) {
sound.stop()
}
}, [])
const stopAll = useCallback(() => {
soundsRef.current.forEach((sound) => sound.stop())
}, [])
return {
play,
stop,
stopAll,
}
}

120
frontend/src/index.css Normal file
View File

@@ -0,0 +1,120 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
:root {
--color-bg: #0a0a0a;
--color-primary: #FFE135;
--color-secondary: #00FFFF;
--color-accent: #FF00FF;
--color-text: #ffffff;
--color-text-muted: #888888;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: 4px;
}
/* Theme transitions */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Utility classes */
.text-shadow-neon {
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor;
}
.border-neon {
box-shadow: 0 0 5px currentColor, 0 0 10px currentColor, inset 0 0 5px currentColor;
}
/* CRT scanline effect for retro theme */
.crt-scanlines::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
transparent 50%,
rgba(0, 0, 0, 0.1) 50%
);
background-size: 100% 4px;
pointer-events: none;
}
/* Glitch effect for DRRR theme */
.glitch-text {
position: relative;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch-text::before {
animation: glitch-1 0.3s infinite;
color: var(--color-secondary);
z-index: -1;
}
.glitch-text::after {
animation: glitch-2 0.3s infinite;
color: var(--color-accent);
z-index: -2;
}
@keyframes glitch-1 {
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
20% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
40% { clip-path: inset(40% 0 40% 0); transform: translate(2px, 2px); }
60% { clip-path: inset(60% 0 20% 0); transform: translate(-2px, 2px); }
80% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
}
@keyframes glitch-2 {
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
20% { clip-path: inset(60% 0 20% 0); transform: translate(2px, 2px); }
40% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
60% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
80% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 2px); }
}
/* Sparkle effect for anime theme */
.sparkle::before {
content: '✦';
position: absolute;
animation: sparkle-float 2s infinite;
}
@keyframes sparkle-float {
0%, 100% { opacity: 0; transform: translateY(0) scale(0); }
50% { opacity: 1; transform: translateY(-20px) scale(1); }
}

16
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { ThemeProvider } from './themes/ThemeProvider'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

335
frontend/src/pages/Game.tsx Normal file
View File

@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { Question } from '../types'
const categories = [
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' },
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' },
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' },
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' },
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' },
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' },
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' },
]
export default function Game() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
const { play } = useSound()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore()
const { config, styles } = useThemeStyles()
const [answer, setAnswer] = useState('')
const [timeLeft, setTimeLeft] = useState(0)
const [showingQuestion, setShowingQuestion] = useState(false)
// Redirect if game finished
useEffect(() => {
if (room?.status === 'finished') {
navigate(`/results/${room.code}`)
}
}, [room?.status, room?.code, navigate])
// Timer logic
useEffect(() => {
if (!currentQuestion || !showingQuestion) return
setTimeLeft(currentQuestion.time_seconds)
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
if (prev === 6) play('timer_urgent')
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [currentQuestion, showingQuestion, play])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p>
</div>
)
}
const myTeam = room.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
const isMyTurn = room.current_team === myTeam
const currentPlayer = isMyTurn
? room.teams[myTeam][room.current_player_index[myTeam]]
: null
const amICurrentPlayer = currentPlayer?.name === playerName
const handleSelectQuestion = (question: Question, categoryId: number) => {
if (!amICurrentPlayer || question.answered) return
play('select')
selectQuestion(question.id, categoryId)
setShowingQuestion(true)
}
const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as Record<string, unknown>, room.can_steal)
setAnswer('')
setShowingQuestion(false)
}
const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return
if (attempt) {
setShowingQuestion(true)
} else {
stealDecision(false, currentQuestion.id)
}
setShowStealPrompt(false)
}
const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-6xl mx-auto">
{/* Scoreboard */}
<div className="flex justify-between items-center mb-6">
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}>
{room.scores.A}
</div>
</div>
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'}
</div>
{amICurrentPlayer && (
<div className="text-lg font-bold" style={{ color: config.colors.accent }}>
¡Tu turno!
</div>
)}
</div>
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-3xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
</div>
</div>
{/* Game Board */}
<div className="grid grid-cols-8 gap-2 mb-6">
{/* Category Headers */}
{categories.map((cat) => (
<div
key={cat.id}
className="text-center p-2 rounded-t-lg"
style={{ backgroundColor: cat.color }}
>
<div className="text-2xl">{cat.icon}</div>
<div className="text-xs text-white font-bold truncate">{cat.name}</div>
</div>
))}
{/* Questions Grid */}
{[1, 2, 3, 4, 5].map((difficulty) =>
categories.map((cat) => {
const questions = room.board[String(cat.id)] || []
const question = questions.find(q => q.difficulty === difficulty)
const isAnswered = question?.answered
return (
<motion.button
key={`${cat.id}-${difficulty}`}
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}}
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}}
onClick={() => question && handleSelectQuestion(question, cat.id)}
disabled={isAnswered || !amICurrentPlayer}
className={`p-4 rounded transition-all ${
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
}`}
style={{
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40',
border: `2px solid ${cat.color}`,
}}
>
<span className="text-xl font-bold" style={{ color: config.colors.text }}>
{difficulty * 100}
</span>
</motion.button>
)
})
)}
</div>
{/* Question Modal */}
<AnimatePresence>
{showingQuestion && currentQuestion && (
<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, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="w-full max-w-lg p-6 rounded-lg"
style={{
backgroundColor: config.colors.bg,
border: `3px solid ${config.colors.primary}`,
}}
>
{/* Timer */}
<div className="flex justify-between items-center mb-4">
<span className="text-sm" style={styles.textSecondary}>
{currentQuestion.points} puntos
</span>
<div
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`}
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }}
>
{timeLeft}s
</div>
</div>
{/* Question */}
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}>
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'}
</p>
{/* Answer Input */}
{amICurrentPlayer && (
<div className="space-y-4">
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
placeholder="Escribe tu respuesta..."
autoFocus
className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg"
style={{
border: `2px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
<button
onClick={handleSubmitAnswer}
disabled={!answer.trim()}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Responder
</button>
</div>
)}
{!amICurrentPlayer && (
<p className="text-center" style={styles.textSecondary}>
Esperando respuesta de {currentPlayer?.name}...
</p>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Steal Prompt */}
<AnimatePresence>
{showStealPrompt && room.current_team === myTeam && (
<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 }}
className="p-6 rounded-lg text-center"
style={{
backgroundColor: config.colors.bg,
border: `3px solid ${config.colors.accent}`,
}}
>
<h3 className="text-2xl font-bold mb-4" style={{ color: config.colors.accent }}>
¡Oportunidad de Robo!
</h3>
<p className="mb-6" style={styles.textSecondary}>
El equipo contrario falló. ¿Quieres intentar robar los puntos?
<br />
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span>
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => handleStealDecision(true)}
className="px-6 py-3 rounded-lg font-bold"
style={{
backgroundColor: config.colors.accent,
color: config.colors.bg,
}}
>
¡Robar!
</button>
<button
onClick={() => handleStealDecision(false)}
className="px-6 py-3 rounded-lg font-bold"
style={{
backgroundColor: 'transparent',
color: config.colors.text,
border: `2px solid ${config.colors.text}`,
}}
>
Pasar
</button>
</div>
</motion.div>
</motion.div>
)}
</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>
))}
</div>
</div>
</div>
)
}

213
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types'
export default function Home() {
const [playerName, setPlayerName] = useState('')
const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('')
const navigate = useNavigate()
const { createRoom, joinRoom } = useSocket()
const { setPlayerName: storeSetPlayerName, room } = useGameStore()
const { currentTheme, setTheme } = useThemeStore()
const { config, styles } = useThemeStyles()
// Navigate when room is created/joined
if (room) {
navigate(`/lobby/${room.code}`)
}
const handleCreateRoom = () => {
if (!playerName.trim()) {
setError('Ingresa tu nombre')
return
}
storeSetPlayerName(playerName.trim())
createRoom(playerName.trim())
}
const handleJoinRoom = () => {
if (!playerName.trim()) {
setError('Ingresa tu nombre')
return
}
if (!roomCode.trim() || roomCode.length !== 6) {
setError('Ingresa un código de sala válido (6 caracteres)')
return
}
storeSetPlayerName(playerName.trim())
joinRoom(roomCode.toUpperCase(), playerName.trim(), 'A')
}
return (
<div
className="min-h-screen flex flex-col items-center justify-center p-4"
style={styles.bgPrimary}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<h1
className={`text-4xl md:text-6xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
data-text="WebTriviasMulti"
>
WebTriviasMulti
</h1>
<p style={styles.textSecondary}>Trivia multiplayer en tiempo real</p>
</motion.div>
{/* Theme Selector */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap gap-2 mb-8 justify-center"
>
{(Object.keys(themes) as ThemeName[]).map((themeName) => (
<button
key={themeName}
onClick={() => setTheme(themeName)}
className={`px-3 py-1 rounded text-sm transition-all ${
currentTheme === themeName
? 'ring-2 ring-offset-2'
: 'opacity-70 hover:opacity-100'
}`}
style={{
backgroundColor: themes[themeName].colors.primary + '30',
color: themes[themeName].colors.primary,
ringColor: themes[themeName].colors.primary,
}}
>
{themes[themeName].displayName}
</button>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="w-full max-w-md p-6 rounded-lg"
style={{
backgroundColor: config.colors.bg,
border: `2px solid ${config.colors.primary}`,
boxShadow: config.effects.glow
? `0 0 20px ${config.colors.primary}40`
: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
{mode === 'select' ? (
<div className="space-y-4">
<button
onClick={() => setMode('create')}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Crear Sala
</button>
<button
onClick={() => setMode('join')}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.primary,
border: `2px solid ${config.colors.primary}`,
}}
>
Unirse a Sala
</button>
</div>
) : (
<div className="space-y-4">
<button
onClick={() => {
setMode('select')
setError('')
}}
className="text-sm mb-2"
style={{ color: config.colors.textMuted }}
>
Volver
</button>
<div>
<label className="block text-sm mb-1" style={styles.textSecondary}>
Tu nombre
</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Ingresa tu nombre"
maxLength={20}
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none"
style={{
border: `1px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
</div>
{mode === 'join' && (
<div>
<label className="block text-sm mb-1" style={styles.textSecondary}>
Código de sala
</label>
<input
type="text"
value={roomCode}
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
placeholder="ABCD12"
maxLength={6}
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none uppercase tracking-widest text-center text-xl"
style={{
border: `1px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
</div>
)}
{error && (
<p className="text-red-500 text-sm text-center">{error}</p>
)}
<button
onClick={mode === 'create' ? handleCreateRoom : handleJoinRoom}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
{mode === 'create' ? 'Crear Sala' : 'Unirse'}
</button>
</div>
)}
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-8 text-sm"
style={styles.textSecondary}
>
8 categorías 2 equipos Preguntas diarias
</motion.p>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
export default function Lobby() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { changeTeam, startGame } = useSocket()
const { room, playerName } = useGameStore()
const { config, styles } = useThemeStyles()
// Redirect if no room
useEffect(() => {
if (!room && !roomCode) {
navigate('/')
}
}, [room, roomCode, navigate])
// Navigate to game when started
useEffect(() => {
if (room?.status === 'playing') {
navigate(`/game/${room.code}`)
}
}, [room?.status, room?.code, navigate])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p>
</div>
)
}
const isHost = room.host === playerName
const canStart = room.teams.A.length > 0 && room.teams.B.length > 0
const handleStartGame = () => {
// In production, fetch today's questions and build board
const sampleBoard = {
'1': [
{ id: 1, category_id: 1, difficulty: 1, points: 100, time_seconds: 15, answered: false },
{ id: 2, category_id: 1, difficulty: 2, points: 200, time_seconds: 20, answered: false },
{ id: 3, category_id: 1, difficulty: 3, points: 300, time_seconds: 25, answered: false },
{ id: 4, category_id: 1, difficulty: 4, points: 400, time_seconds: 35, answered: false },
{ id: 5, category_id: 1, difficulty: 5, points: 500, time_seconds: 45, answered: false },
],
// Add more categories...
}
startGame(sampleBoard)
}
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<h1
className={`text-3xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
Sala de Espera
</h1>
<div
className="inline-block px-6 py-2 rounded-lg text-2xl tracking-widest"
style={{
backgroundColor: config.colors.primary + '20',
color: config.colors.primary,
border: `2px solid ${config.colors.primary}`,
}}
>
{room.code}
</div>
<p className="mt-2 text-sm" style={styles.textSecondary}>
Comparte este código con tus amigos
</p>
</motion.div>
{/* Teams */}
<div className="grid md:grid-cols-2 gap-6 mb-8">
{/* Team A */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="p-4 rounded-lg"
style={{
backgroundColor: config.colors.primary + '10',
border: `2px solid ${config.colors.primary}`,
}}
>
<h2
className="text-xl font-bold mb-4 text-center"
style={{ color: config.colors.primary }}
>
Equipo A
</h2>
<div className="space-y-2 min-h-[200px]">
{room.teams.A.map((player, index) => (
<div
key={player.socket_id || index}
className="px-4 py-2 rounded flex items-center justify-between"
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}40`,
}}
>
<span style={{ color: config.colors.text }}>{player.name}</span>
{player.name === room.host && (
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}>
Host
</span>
)}
</div>
))}
{room.teams.A.length < 4 && (
<button
onClick={() => changeTeam('A')}
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
style={{
borderColor: config.colors.primary,
color: config.colors.primary,
}}
>
+ Unirse al Equipo A
</button>
)}
</div>
</motion.div>
{/* Team B */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="p-4 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '10',
border: `2px solid ${config.colors.secondary}`,
}}
>
<h2
className="text-xl font-bold mb-4 text-center"
style={{ color: config.colors.secondary }}
>
Equipo B
</h2>
<div className="space-y-2 min-h-[200px]">
{room.teams.B.map((player, index) => (
<div
key={player.socket_id || index}
className="px-4 py-2 rounded flex items-center justify-between"
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.secondary}40`,
}}
>
<span style={{ color: config.colors.text }}>{player.name}</span>
{player.name === room.host && (
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.secondary, color: config.colors.bg }}>
Host
</span>
)}
</div>
))}
{room.teams.B.length < 4 && (
<button
onClick={() => changeTeam('B')}
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
style={{
borderColor: config.colors.secondary,
color: config.colors.secondary,
}}
>
+ Unirse al Equipo B
</button>
)}
</div>
</motion.div>
</div>
{/* Start Button */}
{isHost && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<button
onClick={handleStartGame}
disabled={!canStart}
className={`px-8 py-4 rounded-lg text-xl font-bold transition-all ${
canStart ? 'hover:scale-105' : 'opacity-50 cursor-not-allowed'
}`}
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
{canStart ? 'Iniciar Partida' : 'Esperando jugadores...'}
</button>
{!canStart && (
<p className="mt-2 text-sm" style={styles.textSecondary}>
Ambos equipos necesitan al menos un jugador
</p>
)}
</motion.div>
)}
{!isHost && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
style={styles.textSecondary}
>
Esperando a que el host inicie la partida...
</motion.p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { ReplayData, GameEvent } from '../types'
export default function Replay() {
const { sessionId } = useParams<{ sessionId: string }>()
const { config, styles } = useThemeStyles()
const [replayData, setReplayData] = useState<ReplayData | null>(null)
const [currentEventIndex, setCurrentEventIndex] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const [loading, setLoading] = useState(true)
// Fetch replay data
useEffect(() => {
const fetchReplay = async () => {
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`)
if (response.ok) {
const data = await response.json()
setReplayData(data)
}
} catch (error) {
console.error('Failed to fetch replay:', error)
} finally {
setLoading(false)
}
}
fetchReplay()
}, [sessionId])
// Playback logic
useEffect(() => {
if (!isPlaying || !replayData) return
const interval = setInterval(() => {
setCurrentEventIndex((prev) => {
if (prev >= replayData.events.length - 1) {
setIsPlaying(false)
return prev
}
return prev + 1
})
}, 1000 / speed)
return () => clearInterval(interval)
}, [isPlaying, speed, replayData])
const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || []
const currentScores = currentEvents.reduce(
(acc, event) => {
if (event.was_correct && event.points_earned) {
acc[event.team] += event.points_earned
} else if (!event.was_correct && event.was_steal && event.points_earned) {
acc[event.team] -= Math.abs(event.points_earned)
}
return acc
},
{ A: 0, B: 0 }
)
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando replay...</p>
</div>
)
}
if (!replayData) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No se encontró el replay</p>
<Link
to="/"
className="px-6 py-2 rounded-lg"
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
>
Volver al inicio
</Link>
</div>
)
}
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<Link to="/" style={{ color: config.colors.primary }}>
Volver
</Link>
<h1
className="text-2xl font-bold"
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
Replay: {replayData.session.room_code}
</h1>
<div style={styles.textSecondary}>
{new Date(replayData.session.created_at).toLocaleDateString()}
</div>
</div>
{/* Scores */}
<div className="flex justify-center gap-8 mb-8">
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
{currentScores.A}
</div>
</div>
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
{currentScores.B}
</div>
</div>
</div>
{/* Playback Controls */}
<div
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg"
style={{ backgroundColor: config.colors.bg, border: `1px solid ${config.colors.primary}` }}
>
<button
onClick={() => setCurrentEventIndex(0)}
className="p-2 rounded"
style={{ color: config.colors.text }}
>
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="px-6 py-2 rounded-lg font-bold"
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
>
{isPlaying ? '⏸️ Pausar' : '▶️ Reproducir'}
</button>
<button
onClick={() => setCurrentEventIndex(replayData.events.length - 1)}
className="p-2 rounded"
style={{ color: config.colors.text }}
>
</button>
<div className="ml-4 flex items-center gap-2">
<span style={styles.textSecondary}>Velocidad:</span>
{[1, 2, 4].map((s) => (
<button
key={s}
onClick={() => setSpeed(s)}
className={`px-3 py-1 rounded ${speed === s ? 'font-bold' : 'opacity-50'}`}
style={{
backgroundColor: speed === s ? config.colors.primary : 'transparent',
color: speed === s ? config.colors.bg : config.colors.text,
}}
>
{s}x
</button>
))}
</div>
</div>
{/* Timeline */}
<div className="mb-4">
<input
type="range"
min={0}
max={replayData.events.length - 1}
value={currentEventIndex}
onChange={(e) => setCurrentEventIndex(Number(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-sm" style={styles.textSecondary}>
<span>Evento {currentEventIndex + 1}</span>
<span>de {replayData.events.length}</span>
</div>
</div>
{/* Events List */}
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${config.colors.primary}` }}
>
<div className="max-h-96 overflow-y-auto">
{replayData.events.map((event, index) => (
<motion.div
key={event.id}
initial={{ opacity: 0 }}
animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }}
className={`p-3 border-b ${index === currentEventIndex ? 'ring-2 ring-inset' : ''}`}
style={{
borderColor: config.colors.primary + '30',
backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50',
ringColor: config.colors.accent,
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="px-2 py-1 rounded text-xs font-bold"
style={{
backgroundColor: event.team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg,
}}
>
{event.team}
</span>
<span style={{ color: config.colors.text }}>{event.player_name}</span>
</div>
<div className="flex items-center gap-2">
{event.was_correct ? (
<span className="text-green-500"> +{event.points_earned}</span>
) : (
<span className="text-red-500"> {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span>
)}
</div>
</div>
{event.answer_given && (
<div className="mt-1 text-sm" style={styles.textSecondary}>
Respuesta: "{event.answer_given}"
</div>
)}
</motion.div>
))}
</div>
</div>
{/* Final Scores */}
<div className="mt-8 text-center">
<div className="text-sm mb-2" style={styles.textSecondary}>Resultado Final</div>
<div className="text-2xl font-bold">
<span style={{ color: config.colors.primary }}>{replayData.session.team_a_score}</span>
<span style={styles.textSecondary}> - </span>
<span style={{ color: config.colors.secondary }}>{replayData.session.team_b_score}</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSound } from '../hooks/useSound'
import { useAchievements } from '../hooks/useAchievements'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
export default function Results() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { play } = useSound()
const { achievements } = useAchievements()
const { room, playerName, resetGame } = useGameStore()
const { config, styles } = useThemeStyles()
const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
const won = room ? room.scores[myTeam] > room.scores[myTeam === 'A' ? 'B' : 'A'] : false
const tied = room ? room.scores.A === room.scores.B : false
// Play victory/defeat sound
useEffect(() => {
if (won) {
play('victory')
} else if (!tied) {
play('defeat')
}
}, [won, tied, play])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No hay resultados disponibles</p>
</div>
)
}
const winnerTeam = room.scores.A > room.scores.B ? 'A' : room.scores.B > room.scores.A ? 'B' : null
const newAchievements = achievements.filter(a => a.unlocked && a.unlockedAt)
const handlePlayAgain = () => {
resetGame()
navigate('/')
}
return (
<div className="min-h-screen p-4 flex flex-col items-center justify-center" style={styles.bgPrimary}>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center max-w-2xl w-full"
>
{/* Result Header */}
<motion.div
initial={{ y: -50 }}
animate={{ y: 0 }}
transition={{ type: 'spring', bounce: 0.5 }}
className="mb-8"
>
{tied ? (
<h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.text, fontFamily: config.fonts.heading }}
>
¡EMPATE!
</h1>
) : won ? (
<>
<h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
¡VICTORIA!
</h1>
<p className="text-xl" style={styles.textSecondary}>
Tu equipo ha ganado
</p>
</>
) : (
<>
<h1
className={`text-5xl font-bold mb-2`}
style={{ color: config.colors.textMuted, fontFamily: config.fonts.heading }}
>
DERROTA
</h1>
<p className="text-xl" style={styles.textSecondary}>
Mejor suerte la próxima vez
</p>
</>
)}
</motion.div>
{/* Scores */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex justify-center gap-8 mb-8"
>
<div
className={`p-6 rounded-lg text-center ${winnerTeam === 'A' ? 'ring-4' : ''}`}
style={{
backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`,
ringColor: config.colors.primary,
}}
>
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div>
<div className="text-5xl font-bold" style={{ color: config.colors.primary }}>
{room.scores.A}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div>
<div className="flex items-center">
<span className="text-3xl" style={styles.textSecondary}>VS</span>
</div>
<div
className={`p-6 rounded-lg text-center ${winnerTeam === 'B' ? 'ring-4' : ''}`}
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
ringColor: config.colors.secondary,
}}
>
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div>
<div className="text-5xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
{/* New Achievements */}
{newAchievements.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mb-8"
>
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
¡Nuevos Logros Desbloqueados!
</h2>
<div className="flex flex-wrap justify-center gap-4">
{newAchievements.map((achievement) => (
<motion.div
key={achievement.id}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.5 }}
className="p-4 rounded-lg text-center"
style={{
backgroundColor: config.colors.accent + '20',
border: `2px solid ${config.colors.accent}`,
}}
>
<div className="text-3xl mb-2">{achievement.icon}</div>
<div className="font-bold" style={{ color: config.colors.text }}>
{achievement.name}
</div>
<div className="text-xs" style={styles.textSecondary}>
{achievement.description}
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Actions */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<button
onClick={handlePlayAgain}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Jugar de Nuevo
</button>
<Link
to={`/replay/${roomCode}`}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105 text-center"
style={{
backgroundColor: 'transparent',
color: config.colors.text,
border: `2px solid ${config.colors.text}`,
}}
>
Ver Replay
</Link>
</motion.div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
class ApiService {
private baseUrl: string
constructor() {
this.baseUrl = API_URL
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Game endpoints
async getCategories() {
return this.request<
Array<{ id: number; name: string; icon: string; color: string }>
>('/api/game/categories')
}
async getTodayQuestions() {
return this.request<{
date: string
categories: Record<
string,
{
name: string
questions: Array<{
difficulty: number
id: number
points: number
}>
}
>
}>('/api/game/today-questions')
}
async getQuestion(questionId: number) {
return this.request<{
id: number
question_text: string
difficulty: number
points: number
time_seconds: number
category_id: number
}>(`/api/game/question/${questionId}`)
}
async getAchievements() {
return this.request<
Array<{
id: number
name: string
description: string
icon: string
}>
>('/api/game/achievements')
}
// Replay endpoints
async getReplay(sessionId: string) {
return this.request<{
session: {
id: number
room_code: string
team_a_score: number
team_b_score: number
status: string
created_at: string
finished_at: string
}
events: Array<{
id: number
event_type: string
player_name: string
team: 'A' | 'B'
question_id: number
answer_given: string
was_correct: boolean
was_steal: boolean
points_earned: number
timestamp: string
}>
}>(`/api/replay/code/${sessionId}`)
}
async listReplays(limit = 20, offset = 0) {
return this.request<
Array<{
id: number
room_code: string
team_a_score: number
team_b_score: number
finished_at: string
}>
>(`/api/replay?limit=${limit}&offset=${offset}`)
}
}
export const api = new ApiService()

View File

@@ -0,0 +1,69 @@
import { io, Socket } from 'socket.io-client'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
class SocketService {
private socket: Socket | null = null
private listeners: Map<string, Set<(data: unknown) => void>> = new Map()
connect(): Socket {
if (!this.socket) {
this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
})
this.socket.on('connect', () => {
console.log('Socket connected:', this.socket?.id)
})
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason)
})
this.socket.on('error', (error) => {
console.error('Socket error:', error)
})
}
return this.socket
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect()
this.socket = null
}
}
on(event: string, callback: (data: unknown) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(callback)
this.socket?.on(event, callback)
}
off(event: string, callback: (data: unknown) => void): void {
this.listeners.get(event)?.delete(callback)
this.socket?.off(event, callback)
}
emit(event: string, data?: unknown): void {
this.socket?.emit(event, data)
}
get connected(): boolean {
return this.socket?.connected ?? false
}
get id(): string | undefined {
return this.socket?.id
}
}
export const socketService = new SocketService()

View File

@@ -0,0 +1,104 @@
import { create } from 'zustand'
import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types'
interface GameState {
// Room state
room: GameRoom | null
setRoom: (room: GameRoom | null) => void
// Player info
playerName: string
setPlayerName: (name: string) => void
// Current question
currentQuestion: Question | null
setCurrentQuestion: (question: Question | null) => void
// Timer
timerEnd: Date | null
setTimerEnd: (end: Date | null) => void
// Chat messages
messages: ChatMessage[]
addMessage: (message: ChatMessage) => void
clearMessages: () => void
// Achievements
achievements: Achievement[]
setAchievements: (achievements: Achievement[]) => void
unlockAchievement: (id: number) => void
// Game stats (for achievements tracking)
stats: {
correctStreak: number
stealsAttempted: number
stealsSuccessful: number
categoryCorrect: Record<number, number>
fastAnswers: number
maxDeficit: number
}
updateStats: (updates: Partial<GameState['stats']>) => void
resetStats: () => void
// UI state
showStealPrompt: boolean
setShowStealPrompt: (show: boolean) => void
// Reset
resetGame: () => void
}
const initialStats = {
correctStreak: 0,
stealsAttempted: 0,
stealsSuccessful: 0,
categoryCorrect: {},
fastAnswers: 0,
maxDeficit: 0,
}
export const useGameStore = create<GameState>((set) => ({
room: null,
setRoom: (room) => set({ room }),
playerName: '',
setPlayerName: (playerName) => set({ playerName }),
currentQuestion: null,
setCurrentQuestion: (currentQuestion) => set({ currentQuestion }),
timerEnd: null,
setTimerEnd: (timerEnd) => set({ timerEnd }),
messages: [],
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message].slice(-100) })),
clearMessages: () => set({ messages: [] }),
achievements: [],
setAchievements: (achievements) => set({ achievements }),
unlockAchievement: (id) =>
set((state) => ({
achievements: state.achievements.map((a) =>
a.id === id ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a
),
})),
stats: initialStats,
updateStats: (updates) =>
set((state) => ({ stats: { ...state.stats, ...updates } })),
resetStats: () => set({ stats: initialStats }),
showStealPrompt: false,
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
resetGame: () =>
set({
room: null,
currentQuestion: null,
timerEnd: null,
messages: [],
stats: initialStats,
showStealPrompt: false,
}),
}))

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ThemeName } from '../types'
type SoundEffect =
| 'correct'
| 'incorrect'
| 'steal'
| 'timer_tick'
| 'timer_urgent'
| 'victory'
| 'defeat'
| 'select'
interface SoundState {
volume: number
muted: boolean
setVolume: (volume: number) => void
setMuted: (muted: boolean) => void
toggleMute: () => void
}
export const useSoundStore = create<SoundState>()(
persist(
(set) => ({
volume: 0.7,
muted: false,
setVolume: (volume) => set({ volume }),
setMuted: (muted) => set({ muted }),
toggleMute: () => set((state) => ({ muted: !state.muted })),
}),
{
name: 'trivia-sound',
}
)
)
// Sound file paths per theme
export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
drrr: {
correct: '/sounds/drrr/correct.mp3',
incorrect: '/sounds/drrr/incorrect.mp3',
steal: '/sounds/drrr/steal.mp3',
timer_tick: '/sounds/drrr/tick.mp3',
timer_urgent: '/sounds/drrr/urgent.mp3',
victory: '/sounds/drrr/victory.mp3',
defeat: '/sounds/drrr/defeat.mp3',
select: '/sounds/drrr/select.mp3',
},
retro: {
correct: '/sounds/retro/correct.mp3',
incorrect: '/sounds/retro/incorrect.mp3',
steal: '/sounds/retro/steal.mp3',
timer_tick: '/sounds/retro/tick.mp3',
timer_urgent: '/sounds/retro/urgent.mp3',
victory: '/sounds/retro/victory.mp3',
defeat: '/sounds/retro/defeat.mp3',
select: '/sounds/retro/select.mp3',
},
minimal: {
correct: '/sounds/minimal/correct.mp3',
incorrect: '/sounds/minimal/incorrect.mp3',
steal: '/sounds/minimal/steal.mp3',
timer_tick: '/sounds/minimal/tick.mp3',
timer_urgent: '/sounds/minimal/urgent.mp3',
victory: '/sounds/minimal/victory.mp3',
defeat: '/sounds/minimal/defeat.mp3',
select: '/sounds/minimal/select.mp3',
},
rgb: {
correct: '/sounds/rgb/correct.mp3',
incorrect: '/sounds/rgb/incorrect.mp3',
steal: '/sounds/rgb/steal.mp3',
timer_tick: '/sounds/rgb/tick.mp3',
timer_urgent: '/sounds/rgb/urgent.mp3',
victory: '/sounds/rgb/victory.mp3',
defeat: '/sounds/rgb/defeat.mp3',
select: '/sounds/rgb/select.mp3',
},
anime: {
correct: '/sounds/anime/correct.mp3',
incorrect: '/sounds/anime/incorrect.mp3',
steal: '/sounds/anime/steal.mp3',
timer_tick: '/sounds/anime/tick.mp3',
timer_urgent: '/sounds/anime/urgent.mp3',
victory: '/sounds/anime/victory.mp3',
defeat: '/sounds/anime/defeat.mp3',
select: '/sounds/anime/select.mp3',
},
}

View File

@@ -0,0 +1,140 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ThemeName, ThemeConfig } from '../types'
export const themes: Record<ThemeName, ThemeConfig> = {
drrr: {
name: 'drrr',
displayName: 'DRRR (Dollars)',
colors: {
bg: '#0a0a0a',
primary: '#FFE135',
secondary: '#00FFFF',
accent: '#FF00FF',
text: '#ffffff',
textMuted: '#888888',
},
fonts: {
heading: 'Bebas Neue, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: true,
scanlines: false,
glitch: true,
sparkles: false,
rgbShift: false,
},
},
retro: {
name: 'retro',
displayName: 'Retro Arcade',
colors: {
bg: '#1a1a2e',
primary: '#9B59B6',
secondary: '#E91E63',
accent: '#00FFFF',
text: '#ffffff',
textMuted: '#aaaaaa',
},
fonts: {
heading: '"Press Start 2P", cursive',
body: '"Press Start 2P", cursive',
},
effects: {
glow: true,
scanlines: true,
glitch: false,
sparkles: false,
rgbShift: false,
},
},
minimal: {
name: 'minimal',
displayName: 'Moderno Minimalista',
colors: {
bg: '#ffffff',
primary: '#3498DB',
secondary: '#2ECC71',
accent: '#E74C3C',
text: '#2c3e50',
textMuted: '#7f8c8d',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: false,
scanlines: false,
glitch: false,
sparkles: false,
rgbShift: false,
},
},
rgb: {
name: 'rgb',
displayName: 'Gaming RGB',
colors: {
bg: '#0D0D0D',
primary: '#FF0080',
secondary: '#00FF80',
accent: '#8000FF',
text: '#ffffff',
textMuted: '#666666',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: true,
scanlines: false,
glitch: false,
sparkles: false,
rgbShift: true,
},
},
anime: {
name: 'anime',
displayName: 'Anime Clásico 90s',
colors: {
bg: '#FFF5F5',
primary: '#FFB6C1',
secondary: '#E6E6FA',
accent: '#FF69B4',
text: '#4a4a4a',
textMuted: '#888888',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: false,
scanlines: false,
glitch: false,
sparkles: true,
rgbShift: false,
},
},
}
interface ThemeState {
currentTheme: ThemeName
setTheme: (theme: ThemeName) => void
getThemeConfig: () => ThemeConfig
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
currentTheme: 'drrr',
setTheme: (currentTheme) => set({ currentTheme }),
getThemeConfig: () => themes[get().currentTheme],
}),
{
name: 'trivia-theme',
}
)
)

View File

@@ -0,0 +1,65 @@
import { useEffect, type ReactNode } from 'react'
import { useThemeStore, themes } from '../stores/themeStore'
interface ThemeProviderProps {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const { currentTheme } = useThemeStore()
const theme = themes[currentTheme]
useEffect(() => {
// Apply CSS variables
const root = document.documentElement
root.style.setProperty('--color-bg', theme.colors.bg)
root.style.setProperty('--color-primary', theme.colors.primary)
root.style.setProperty('--color-secondary', theme.colors.secondary)
root.style.setProperty('--color-accent', theme.colors.accent)
root.style.setProperty('--color-text', theme.colors.text)
root.style.setProperty('--color-text-muted', theme.colors.textMuted)
root.style.setProperty('--font-heading', theme.fonts.heading)
root.style.setProperty('--font-body', theme.fonts.body)
// Apply body background
document.body.style.backgroundColor = theme.colors.bg
document.body.style.color = theme.colors.text
// Add theme class to body
document.body.className = `theme-${currentTheme}`
}, [currentTheme, theme])
return <>{children}</>
}
// Theme-aware component wrapper
export function useThemeStyles() {
const { currentTheme, getThemeConfig } = useThemeStore()
const config = getThemeConfig()
return {
theme: currentTheme,
config,
styles: {
// Background styles
bgPrimary: { backgroundColor: config.colors.bg },
bgSecondary: { backgroundColor: config.colors.primary + '20' },
// Text styles
textPrimary: { color: config.colors.text },
textSecondary: { color: config.colors.textMuted },
textAccent: { color: config.colors.primary },
// Border styles
borderPrimary: { borderColor: config.colors.primary },
borderSecondary: { borderColor: config.colors.secondary },
// Effect classes
glowEffect: config.effects.glow ? 'text-shadow-neon' : '',
scanlineEffect: config.effects.scanlines ? 'crt-scanlines' : '',
glitchEffect: config.effects.glitch ? 'glitch-text' : '',
sparkleEffect: config.effects.sparkles ? 'sparkle' : '',
rgbEffect: config.effects.rgbShift ? 'animate-rgb-shift' : '',
},
}
}

135
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,135 @@
// Game Types
export interface Player {
name: string
team: 'A' | 'B'
position: number
socket_id?: string
}
export interface TeamState {
players: Player[]
score: number
currentPlayerIndex: number
}
export interface Question {
id: number
category_id: number
question_text?: string
difficulty: number
points: number
time_seconds: number
answered?: boolean
selected?: boolean
}
export interface Category {
id: number
name: string
icon: string
color: string
}
export interface GameRoom {
code: string
status: 'waiting' | 'playing' | 'finished'
host: string
teams: {
A: Player[]
B: Player[]
}
current_team: 'A' | 'B' | null
current_player_index: { A: number; B: number }
current_question: number | null
can_steal: boolean
scores: { A: number; B: number }
board: Record<string, Question[]>
}
export interface ChatMessage {
player_name: string
team: 'A' | 'B'
message: string
timestamp: string
}
export interface EmojiReaction {
player_name: string
team: 'A' | 'B'
emoji: string
}
export interface Achievement {
id: number
name: string
description: string
icon: string
unlocked?: boolean
unlockedAt?: string
}
export interface AnswerResult {
player_name: string
team: 'A' | 'B'
answer: string
valid: boolean
reason: string
points_earned: number
was_steal: boolean
room: GameRoom
}
export interface GameEvent {
id: number
event_type: string
player_name: string
team: 'A' | 'B'
question_id: number
answer_given: string
was_correct: boolean
was_steal: boolean
points_earned: number
timestamp: string
}
export interface ReplayData {
session: {
id: number
room_code: string
team_a_score: number
team_b_score: number
status: string
created_at: string
finished_at: string
}
events: GameEvent[]
}
// Theme Types
export type ThemeName = 'drrr' | 'retro' | 'minimal' | 'rgb' | 'anime'
export interface ThemeConfig {
name: ThemeName
displayName: string
colors: {
bg: string
primary: string
secondary: string
accent: string
text: string
textMuted: string
}
fonts: {
heading: string
body: string
}
effects: {
glow: boolean
scanlines: boolean
glitch: boolean
sparkles: boolean
rgbShift: boolean
}
}

View File

@@ -0,0 +1,78 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// DRRR Theme
drrr: {
bg: '#0a0a0a',
primary: '#FFE135',
secondary: '#00FFFF',
accent: '#FF00FF',
},
// Retro Arcade Theme
retro: {
bg: '#1a1a2e',
primary: '#9B59B6',
secondary: '#E91E63',
accent: '#00FFFF',
},
// Gaming RGB Theme
rgb: {
bg: '#0D0D0D',
primary: '#FF0000',
secondary: '#00FF00',
accent: '#0000FF',
},
// Anime 90s Theme
anime: {
bg: '#FFF5F5',
primary: '#FFB6C1',
secondary: '#E6E6FA',
accent: '#FF69B4',
},
},
fontFamily: {
'pixel': ['"Press Start 2P"', 'cursive'],
'urban': ['Bebas Neue', 'sans-serif'],
},
animation: {
'glitch': 'glitch 0.3s infinite',
'pulse-neon': 'pulse-neon 2s infinite',
'scanline': 'scanline 8s linear infinite',
'sparkle': 'sparkle 1.5s infinite',
'rgb-shift': 'rgb-shift 3s infinite',
},
keyframes: {
glitch: {
'0%, 100%': { transform: 'translate(0)' },
'20%': { transform: 'translate(-2px, 2px)' },
'40%': { transform: 'translate(-2px, -2px)' },
'60%': { transform: 'translate(2px, 2px)' },
'80%': { transform: 'translate(2px, -2px)' },
},
'pulse-neon': {
'0%, 100%': { boxShadow: '0 0 5px currentColor, 0 0 10px currentColor' },
'50%': { boxShadow: '0 0 20px currentColor, 0 0 30px currentColor' },
},
scanline: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
sparkle: {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.5, transform: 'scale(0.8)' },
},
'rgb-shift': {
'0%': { filter: 'hue-rotate(0deg)' },
'100%': { filter: 'hue-rotate(360deg)' },
},
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

14
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true
},
build: {
outDir: 'dist',
sourcemap: true
}
})