Files
Trivy/frontend/src/pages/Replay.tsx
consultoria-as 43021b9c3c 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>
2026-01-26 07:50:48 +00:00

248 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}