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:
247
frontend/src/pages/Replay.tsx
Normal file
247
frontend/src/pages/Replay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user