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

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>
)
}