- 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>
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
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>
|
||
)
|
||
}
|