feat(phase4): Complete frontend with themes, replay, and results

Visual Effects:
- Add effects.css with neon glow, CRT scanlines, glitch, sparkles, RGB shift
- Animations work with all 5 themes (DRRR, Retro, Minimal, RGB, Anime)

Game Finished Handler:
- Add gameResult state to gameStore (winner, scores, replay, achievements)
- Handle game_finished WebSocket event in useSocket
- Store achievements unlocked by all players

Results Page:
- Show winner with animation (or tie)
- Display final scores with staggered animation
- List achievements unlocked per player
- Buttons for replay and new game

Replay Player:
- Fetch replay from API by code
- Auto-playback with configurable speed (1x, 2x, 4x)
- Play/Pause and timeline controls
- Events sidebar for navigation
- Animated event transitions

Lobby Updates:
- Load categories from API on mount
- Display available categories with icons
- Backend generates board (no hardcoded data)

TypeScript Fixes:
- Add vite-env.d.ts for import.meta.env types
- Fix ringColor style issues
- Remove unused imports

Build verified: npm run build succeeds

Phase 4 tasks completed:
- F4.1: CSS effects for themes
- F4.2: Results page with achievements
- F4.3: Replay player
- F4.4: game_finished handler
- F4.5: Lobby API integration
- F4.6: Build verification and fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 08:40:36 +00:00
parent 0141153653
commit 3e91305e46
13 changed files with 862 additions and 296 deletions

View File

@@ -13,7 +13,7 @@ function App() {
<Route path="/lobby/:roomCode" element={<Lobby />} /> <Route path="/lobby/:roomCode" element={<Lobby />} />
<Route path="/game/:roomCode" element={<Game />} /> <Route path="/game/:roomCode" element={<Game />} />
<Route path="/results/:roomCode" element={<Results />} /> <Route path="/results/:roomCode" element={<Results />} />
<Route path="/replay/:sessionId" element={<Replay />} /> <Route path="/replay/:replayCode" element={<Replay />} />
</Routes> </Routes>
</div> </div>
) )

View File

@@ -1,13 +1,13 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { useGameStore } from '../stores/gameStore' import { useGameStore } from '../stores/gameStore'
import type { GameRoom, ChatMessage, AnswerResult } from '../types' import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000' const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
export function useSocket() { export function useSocket() {
const socketRef = useRef<Socket | null>(null) const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } = const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } =
useGameStore() useGameStore()
useEffect(() => { useEffect(() => {
@@ -95,10 +95,34 @@ export function useSocket() {
console.log(`${data.player_name} reacted with ${data.emoji}`) console.log(`${data.player_name} reacted with ${data.emoji}`)
}) })
socket.on('game_finished', (data: {
room: GameRoom
winner: 'A' | 'B' | null
final_scores: { A: number; B: number }
replay_code: string | null
achievements_unlocked: Array<{
player_name: string
team: 'A' | 'B'
achievement: unknown
}>
}) => {
setRoom(data.room)
setGameResult({
winner: data.winner,
finalScores: data.final_scores,
replayCode: data.replay_code,
achievementsUnlocked: data.achievements_unlocked.map(a => ({
player_name: a.player_name,
team: a.team,
achievement: a.achievement as Achievement
}))
})
})
return () => { return () => {
socket.disconnect() socket.disconnect()
} }
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd]) }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult])
// Socket methods // Socket methods
const createRoom = useCallback((playerName: string) => { const createRoom = useCallback((playerName: string) => {

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'
import { Howl } from 'howler' import { Howl } from 'howler'
import { useSoundStore, soundPaths } from '../stores/soundStore' import { useSoundStore, soundPaths } from '../stores/soundStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import type { ThemeName } from '../types'
type SoundEffect = type SoundEffect =
| 'correct' | 'correct'

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { ThemeProvider } from './themes/ThemeProvider' import { ThemeProvider } from './themes/ThemeProvider'
import './index.css' import './index.css'
import './styles/effects.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -19,7 +19,7 @@ const categories = [
] ]
export default function Game() { export default function Game() {
const { roomCode } = useParams<{ roomCode: string }>() useParams<{ roomCode: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket() const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
const { play } = useSound() const { play } = useSound()
@@ -80,7 +80,7 @@ export default function Game() {
const handleSubmitAnswer = () => { const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as Record<string, unknown>, room.can_steal) submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
setAnswer('') setAnswer('')
setShowingQuestion(false) setShowingQuestion(false)
} }

View File

@@ -85,8 +85,8 @@ export default function Home() {
style={{ style={{
backgroundColor: themes[themeName].colors.primary + '30', backgroundColor: themes[themeName].colors.primary + '30',
color: themes[themeName].colors.primary, color: themes[themeName].colors.primary,
ringColor: themes[themeName].colors.primary, '--tw-ring-color': themes[themeName].colors.primary,
}} } as React.CSSProperties}
> >
{themes[themeName].displayName} {themes[themeName].displayName}
</button> </button>

View File

@@ -1,9 +1,11 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket' import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore' import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
import { api } from '../services/api'
import type { Category } from '../types'
export default function Lobby() { export default function Lobby() {
const { roomCode } = useParams<{ roomCode: string }>() const { roomCode } = useParams<{ roomCode: string }>()
@@ -12,6 +14,24 @@ export default function Lobby() {
const { room, playerName } = useGameStore() const { room, playerName } = useGameStore()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
// Fetch categories on mount
useEffect(() => {
const fetchCategories = async () => {
try {
const data = await api.getCategories()
setCategories(data)
} catch (error) {
console.error('Error fetching categories:', error)
} finally {
setLoading(false)
}
}
fetchCategories()
}, [])
// Redirect if no room // Redirect if no room
useEffect(() => { useEffect(() => {
if (!room && !roomCode) { if (!room && !roomCode) {
@@ -38,18 +58,8 @@ export default function Lobby() {
const canStart = room.teams.A.length > 0 && room.teams.B.length > 0 const canStart = room.teams.A.length > 0 && room.teams.B.length > 0
const handleStartGame = () => { const handleStartGame = () => {
// In production, fetch today's questions and build board // El backend carga el tablero desde la BD
const sampleBoard = { startGame({})
'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 ( return (
@@ -183,6 +193,40 @@ export default function Lobby() {
</motion.div> </motion.div>
</div> </div>
{/* Categories */}
{!loading && categories.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<h3 className="text-center mb-2" style={styles.textSecondary}>
Categorias de hoy
</h3>
<div className="flex flex-wrap justify-center gap-2">
{categories.map(cat => (
<span
key={cat.id}
className="px-3 py-1 rounded-full text-sm"
style={{ backgroundColor: cat.color + '30', color: cat.color }}
>
{cat.icon} {cat.name}
</span>
))}
</div>
</motion.div>
)}
{!loading && categories.length === 0 && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center text-yellow-500 mb-4"
>
No hay preguntas disponibles para hoy. Contacta al administrador.
</motion.p>
)}
{/* Start Button */} {/* Start Button */}
{isHost && ( {isHost && (
<motion.div <motion.div

View File

@@ -1,246 +1,587 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
import type { ReplayData, GameEvent } from '../types' import type { ReplayData } from '../types'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export default function Replay() { export default function Replay() {
const { sessionId } = useParams<{ sessionId: string }>() const { replayCode } = useParams<{ replayCode: string }>()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
const [replayData, setReplayData] = useState<ReplayData | null>(null) // State
const [replay, setReplay] = useState<ReplayData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [currentEventIndex, setCurrentEventIndex] = useState(0) const [currentEventIndex, setCurrentEventIndex] = useState(0)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [speed, setSpeed] = useState(1) const [playbackSpeed, setPlaybackSpeed] = useState(1) // 1x, 2x, 4x
const [loading, setLoading] = useState(true)
// Fetch replay data // Fetch replay data on mount
useEffect(() => { useEffect(() => {
const fetchReplay = async () => { const fetchReplay = async () => {
try { try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000' const response = await fetch(`${API_URL}/api/replay/${replayCode}`)
const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`) if (!response.ok) throw new Error('Replay not found')
if (response.ok) {
const data = await response.json() const data = await response.json()
setReplayData(data) setReplay(data)
} } catch (e) {
} catch (error) { setError('No se encontro el replay')
console.error('Failed to fetch replay:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
fetchReplay() fetchReplay()
}, [sessionId]) }, [replayCode])
// Playback logic // Playback logic
useEffect(() => { useEffect(() => {
if (!isPlaying || !replayData) return if (!isPlaying || !replay) return
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentEventIndex((prev) => { setCurrentEventIndex((prev) => {
if (prev >= replayData.events.length - 1) { if (prev >= replay.events.length - 1) {
setIsPlaying(false) setIsPlaying(false)
return prev return prev
} }
return prev + 1 return prev + 1
}) })
}, 1000 / speed) }, 2000 / playbackSpeed)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isPlaying, speed, replayData]) }, [isPlaying, playbackSpeed, replay])
const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || [] // Calculate accumulated score up to current event
const currentScores = currentEvents.reduce( const currentScores = useMemo(() => {
if (!replay) return { A: 0, B: 0 }
const eventsUpToCurrent = replay.events.slice(0, currentEventIndex + 1)
return eventsUpToCurrent.reduce(
(acc, event) => { (acc, event) => {
if (event.was_correct && event.points_earned) { if (event.points_earned !== 0) {
acc[event.team] += 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 return acc
}, },
{ A: 0, B: 0 } { A: 0, B: 0 }
) )
}, [replay, currentEventIndex])
const currentEvent = replay?.events[currentEventIndex]
// Format timestamp for display
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp)
return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Get event type label
const getEventTypeLabel = (eventType: string) => {
const labels: Record<string, string> = {
question_selected: 'Pregunta seleccionada',
answer_submitted: 'Respuesta enviada',
steal_attempted: 'Intento de robo',
steal_declined: 'Robo rechazado',
game_started: 'Juego iniciado',
game_finished: 'Juego finalizado',
}
return labels[eventType] || eventType
}
// Loading state
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}> <div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<div
className="w-16 h-16 border-4 rounded-full animate-spin mx-auto mb-4"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
<p style={styles.textSecondary}>Cargando replay...</p> <p style={styles.textSecondary}>Cargando replay...</p>
</motion.div>
</div> </div>
) )
} }
if (!replayData) { // Error state
if (error || !replay) {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4" style={styles.bgPrimary}> <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> <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<div className="text-6xl mb-4">:(</div>
<p className="text-xl mb-2" style={{ color: config.colors.text }}>
{error || 'No se encontro el replay'}
</p>
<p className="mb-6" style={styles.textSecondary}>
El codigo "{replayCode}" no existe o ha expirado
</p>
<Link <Link
to="/" to="/"
className="px-6 py-2 rounded-lg" className="inline-block px-6 py-3 rounded-lg font-bold transition-transform hover:scale-105"
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }} style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
> >
Volver al inicio Volver al inicio
</Link> </Link>
</motion.div>
</div> </div>
) )
} }
const progressPercent = ((currentEventIndex + 1) / replay.events.length) * 100
return ( return (
<div className="min-h-screen p-4" style={styles.bgPrimary}> <div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto flex gap-6">
{/* Main Content */}
<div className="flex-1">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<Link to="/" style={{ color: config.colors.primary }}> <Link
Volver to="/"
className="flex items-center gap-2 transition-opacity hover:opacity-70"
style={{ color: config.colors.primary }}
>
<span>&larr;</span> Volver
</Link> </Link>
<h1 <h1
className="text-2xl font-bold" className="text-2xl font-bold"
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }} style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
> >
Replay: {replayData.session.room_code} Replay: {replay.metadata.room_code}
</h1> </h1>
<div style={styles.textSecondary}> <div style={styles.textSecondary}>
{new Date(replayData.session.created_at).toLocaleDateString()} {new Date(replay.metadata.created_at).toLocaleDateString('es-ES')} |{' '}
{formatDuration(replay.metadata.duration_seconds)}
</div> </div>
</div> </div>
{/* Scores */} {/* Scoreboard */}
<div className="flex justify-center gap-8 mb-8"> <div className="flex justify-center gap-8 mb-8">
<div className="text-center"> <motion.div
<div className="text-sm" style={styles.textSecondary}>Equipo A</div> className="text-center px-8 py-4 rounded-lg"
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}> animate={{
scale: currentEvent?.team === 'A' && currentEvent?.was_correct ? [1, 1.05, 1] : 1,
}}
transition={{ duration: 0.3 }}
style={{
backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>
Equipo A
</div>
<motion.div
key={currentScores.A}
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
className="text-4xl font-bold"
style={{ color: config.colors.primary }}
>
{currentScores.A} {currentScores.A}
</motion.div>
</motion.div>
<div className="flex items-center">
<span className="text-3xl font-bold" style={styles.textSecondary}>
VS
</span>
</div> </div>
<motion.div
className="text-center px-8 py-4 rounded-lg"
animate={{
scale: currentEvent?.team === 'B' && currentEvent?.was_correct ? [1, 1.05, 1] : 1,
}}
transition={{ duration: 0.3 }}
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>
Equipo B
</div> </div>
<div className="text-center"> <motion.div
<div className="text-sm" style={styles.textSecondary}>Equipo B</div> key={currentScores.B}
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}> initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
className="text-4xl font-bold"
style={{ color: config.colors.secondary }}
>
{currentScores.B} {currentScores.B}
</motion.div>
</motion.div>
</div> </div>
{/* Current Event Display */}
<div
className="mb-8 p-6 rounded-lg min-h-[200px] flex items-center justify-center"
style={{
backgroundColor: config.colors.bg,
border: `2px solid ${config.colors.primary}`,
}}
>
<AnimatePresence mode="wait">
{currentEvent && (
<motion.div
key={currentEvent.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="text-center w-full"
>
{/* Event Type Badge */}
<div className="mb-4">
<span
className="inline-block px-3 py-1 rounded-full text-sm font-bold"
style={{
backgroundColor:
currentEvent.team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg,
}}
>
{currentEvent.team === 'A' ? 'Equipo A' : 'Equipo B'} - {currentEvent.player_name}
</span>
</div> </div>
{/* Event Content */}
{currentEvent.event_type === 'answer_submitted' && (
<div>
<p className="text-lg mb-2" style={styles.textSecondary}>
{currentEvent.player_name} respondio:
</p>
<motion.p
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="text-2xl font-bold mb-4"
style={{ color: config.colors.text }}
>
"{currentEvent.answer_given}"
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<p
className={`text-xl font-bold ${
currentEvent.was_correct ? 'text-green-500' : 'text-red-500'
}`}
>
{currentEvent.was_correct ? 'Correcto' : 'Incorrecto'}
{currentEvent.was_steal && ' (Robo)'}
</p>
{currentEvent.points_earned !== 0 && (
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
className={`text-lg font-bold ${
currentEvent.points_earned > 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{currentEvent.points_earned > 0 ? '+' : ''}
{currentEvent.points_earned} puntos
</motion.p>
)}
</motion.div>
</div>
)}
{currentEvent.event_type === 'question_selected' && (
<div>
<p className="text-lg" style={styles.textSecondary}>
{currentEvent.player_name} selecciono una pregunta
</p>
<p className="text-sm mt-2" style={styles.textSecondary}>
Pregunta #{currentEvent.question_id}
</p>
</div>
)}
{currentEvent.event_type === 'steal_attempted' && (
<div>
<p className="text-lg" style={{ color: config.colors.accent }}>
{currentEvent.player_name} intenta robar!
</p>
</div>
)}
{currentEvent.event_type === 'steal_declined' && (
<div>
<p className="text-lg" style={styles.textSecondary}>
{currentEvent.team === 'A' ? 'Equipo A' : 'Equipo B'} paso el robo
</p>
</div>
)}
{!['answer_submitted', 'question_selected', 'steal_attempted', 'steal_declined'].includes(
currentEvent.event_type
) && (
<div>
<p className="text-lg" style={styles.textSecondary}>
{getEventTypeLabel(currentEvent.event_type)}
</p>
</div>
)}
{/* Timestamp */}
<p className="mt-4 text-sm" style={styles.textSecondary}>
{formatTimestamp(currentEvent.timestamp)}
</p>
</motion.div>
)}
</AnimatePresence>
</div> </div>
{/* Playback Controls */} {/* Playback Controls */}
<div <div
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg" className="p-4 rounded-lg mb-6"
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={{ style={{
backgroundColor: speed === s ? config.colors.primary : 'transparent', backgroundColor: config.colors.bg,
color: speed === s ? config.colors.bg : config.colors.text, border: `1px solid ${config.colors.primary}`,
}} }}
> >
{s}x {/* Progress Bar */}
<div className="mb-4">
<div
className="relative h-2 rounded-full cursor-pointer overflow-hidden"
style={{ backgroundColor: config.colors.primary + '30' }}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percent = x / rect.width
const newIndex = Math.floor(percent * replay.events.length)
setCurrentEventIndex(Math.max(0, Math.min(newIndex, replay.events.length - 1)))
}}
>
<motion.div
className="absolute inset-y-0 left-0 rounded-full"
style={{ backgroundColor: config.colors.primary }}
initial={false}
animate={{ width: `${progressPercent}%` }}
transition={{ duration: 0.2 }}
/>
</div>
<div className="flex justify-between text-sm mt-1" style={styles.textSecondary}>
<span>Evento {currentEventIndex + 1}</span>
<span>de {replay.events.length}</span>
</div>
</div>
{/* Control Buttons */}
<div className="flex items-center justify-center gap-4">
<button
onClick={() => setCurrentEventIndex(0)}
className="p-2 rounded transition-transform hover:scale-110"
style={{ color: config.colors.text }}
title="Ir al inicio"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M15 10l-7 7V3l7 7zM3 3h2v14H3V3z" />
</svg>
</button>
<button
onClick={() => setCurrentEventIndex((prev) => Math.max(0, prev - 1))}
className="p-2 rounded transition-transform hover:scale-110"
style={{ color: config.colors.text }}
title="Evento anterior"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M15 10l-9 7V3l9 7z" transform="rotate(180 10 10)" />
</svg>
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: isPlaying ? config.colors.accent : config.colors.primary,
color: config.colors.bg,
}}
>
{isPlaying ? 'Pausar' : 'Reproducir'}
</button>
<button
onClick={() =>
setCurrentEventIndex((prev) => Math.min(replay.events.length - 1, prev + 1))
}
className="p-2 rounded transition-transform hover:scale-110"
style={{ color: config.colors.text }}
title="Siguiente evento"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 10l9-7v14l-9-7z" transform="rotate(180 10 10)" />
</svg>
</button>
<button
onClick={() => setCurrentEventIndex(replay.events.length - 1)}
className="p-2 rounded transition-transform hover:scale-110"
style={{ color: config.colors.text }}
title="Ir al final"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 10l7-7v14l-7-7zM15 3h2v14h-2V3z" />
</svg>
</button>
{/* Speed Controls */}
<div className="ml-6 flex items-center gap-2">
<span style={styles.textSecondary}>Velocidad:</span>
{[1, 2, 4].map((speed) => (
<button
key={speed}
onClick={() => setPlaybackSpeed(speed)}
className={`px-3 py-1 rounded transition-all ${
playbackSpeed === speed ? 'font-bold' : 'opacity-50 hover:opacity-70'
}`}
style={{
backgroundColor: playbackSpeed === speed ? config.colors.primary : 'transparent',
color: playbackSpeed === speed ? config.colors.bg : config.colors.text,
}}
>
{speed}x
</button> </button>
))} ))}
</div> </div>
</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> </div>
{/* Events List */} {/* Final Result */}
{currentEventIndex === replay.events.length - 1 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="p-6 rounded-lg text-center"
style={{
backgroundColor: config.colors.accent + '20',
border: `2px solid ${config.colors.accent}`,
}}
>
<h3 className="text-2xl font-bold mb-2" style={{ color: config.colors.accent }}>
Resultado Final
</h3>
<div className="text-3xl font-bold mb-2">
<span style={{ color: config.colors.primary }}>{replay.final_scores.A}</span>
<span style={styles.textSecondary}> - </span>
<span style={{ color: config.colors.secondary }}>{replay.final_scores.B}</span>
</div>
<p className="text-xl" style={{ color: config.colors.text }}>
{replay.winner === 'tie'
? 'Empate!'
: `Ganador: Equipo ${replay.winner}`}
</p>
</motion.div>
)}
</div>
{/* Sidebar - Events List */}
<div <div
className="rounded-lg overflow-hidden" className="w-80 rounded-lg overflow-hidden flex-shrink-0"
style={{ border: `1px solid ${config.colors.primary}` }} style={{ border: `1px solid ${config.colors.primary}` }}
> >
<div className="max-h-96 overflow-y-auto"> <div
{replayData.events.map((event, index) => ( className="p-3 font-bold"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
fontFamily: config.fonts.heading,
}}
>
Eventos ({replay.event_count})
</div>
<div
className="max-h-[600px] overflow-y-auto"
style={{ backgroundColor: config.colors.bg }}
>
{replay.events.map((event, index) => (
<motion.div <motion.div
key={event.id} key={event.id}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }} animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }}
className={`p-3 border-b ${index === currentEventIndex ? 'ring-2 ring-inset' : ''}`} onClick={() => setCurrentEventIndex(index)}
className={`p-3 border-b cursor-pointer transition-all hover:bg-opacity-80 ${
index === currentEventIndex ? 'ring-2 ring-inset' : ''
}`}
style={{ style={{
borderColor: config.colors.primary + '30', borderColor: config.colors.primary + '30',
backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50', backgroundColor:
ringColor: config.colors.accent, index === currentEventIndex
}} ? config.colors.primary + '20'
: index <= currentEventIndex
? config.colors.bg
: config.colors.bg + '50',
'--tw-ring-color': config.colors.accent,
} as React.CSSProperties}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="px-2 py-1 rounded text-xs font-bold" className="px-2 py-0.5 rounded text-xs font-bold"
style={{ style={{
backgroundColor: event.team === 'A' ? config.colors.primary : config.colors.secondary, backgroundColor:
event.team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg, color: config.colors.bg,
}} }}
> >
{event.team} {event.team}
</span> </span>
<span style={{ color: config.colors.text }}>{event.player_name}</span> <span className="text-sm truncate max-w-[100px]" style={{ color: config.colors.text }}>
{event.player_name}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{event.event_type === 'answer_submitted' && (
<>
{event.was_correct ? ( {event.was_correct ? (
<span className="text-green-500"> +{event.points_earned}</span> <span className="text-green-500 text-sm">+{event.points_earned}</span>
) : ( ) : (
<span className="text-red-500"> {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span> <span className="text-red-500 text-sm">
{event.points_earned < 0 ? event.points_earned : 'X'}
</span>
)}
</>
)} )}
</div> </div>
</div> </div>
<div className="text-xs mt-1" style={styles.textSecondary}>
{getEventTypeLabel(event.event_type)}
{event.was_steal && ' (Robo)'}
</div>
{event.answer_given && ( {event.answer_given && (
<div className="mt-1 text-sm" style={styles.textSecondary}> <div
Respuesta: "{event.answer_given}" className="text-xs mt-1 truncate"
style={styles.textSecondary}
title={event.answer_given}
>
"{event.answer_given}"
</div> </div>
)} )}
</motion.div> </motion.div>
))} ))}
</div> </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>
</div> </div>
) )

View File

@@ -1,33 +1,33 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSound } from '../hooks/useSound' import { useSound } from '../hooks/useSound'
import { useAchievements } from '../hooks/useAchievements'
import { useGameStore } from '../stores/gameStore' import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
export default function Results() { export default function Results() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { play } = useSound() const { play } = useSound()
const { achievements } = useAchievements() const { gameResult, resetGame, playerName, room } = useGameStore()
const { room, playerName, resetGame } = useGameStore()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
// Determine if current player won
const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B' 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 won = gameResult?.winner === myTeam
const tied = room ? room.scores.A === room.scores.B : false const tied = gameResult?.winner === null
// Play victory/defeat sound // Play victory/defeat sound
useEffect(() => { useEffect(() => {
if (gameResult) {
if (won) { if (won) {
play('victory') play('victory')
} else if (!tied) { } else if (!tied) {
play('defeat') play('defeat')
} }
}, [won, tied, play]) }
}, [gameResult, won, tied, play])
if (!room) { if (!gameResult) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}> <div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No hay resultados disponibles</p> <p style={styles.textSecondary}>No hay resultados disponibles</p>
@@ -35,9 +35,6 @@ export default function Results() {
) )
} }
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 = () => { const handlePlayAgain = () => {
resetGame() resetGame()
navigate('/') navigate('/')
@@ -52,135 +49,130 @@ export default function Results() {
> >
{/* Result Header */} {/* Result Header */}
<motion.div <motion.div
initial={{ y: -50 }} initial={{ scale: 0 }}
animate={{ y: 0 }} animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.5 }} transition={{ type: 'spring', bounce: 0.5 }}
className="mb-8" className="mb-8"
> >
{tied ? ( {gameResult.winner ? (
<h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{
color: gameResult.winner === 'A' ? config.colors.primary : config.colors.secondary,
fontFamily: config.fonts.heading,
}}
>
¡Equipo {gameResult.winner} Gana!
</h1>
) : (
<h1 <h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`} className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.text, fontFamily: config.fonts.heading }} style={{ color: config.colors.text, fontFamily: config.fonts.heading }}
> >
¡EMPATE! ¡Empate!
</h1> </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> </motion.div>
{/* Scores */} {/* Final Scores */}
<div className="flex justify-center gap-8 mb-8">
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ x: -50, opacity: 0 }}
animate={{ opacity: 1 }} animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.2 }}
className="flex justify-center gap-8 mb-8" className={`p-6 rounded-lg text-center ${gameResult.winner === 'A' ? 'ring-4' : ''}`}
>
<div
className={`p-6 rounded-lg text-center ${winnerTeam === 'A' ? 'ring-4' : ''}`}
style={{ style={{
backgroundColor: config.colors.primary + '20', backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`, border: `2px solid ${config.colors.primary}`,
ringColor: config.colors.primary, '--tw-ring-color': config.colors.primary,
}} } as React.CSSProperties}
> >
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div> <div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div>
<div className="text-5xl font-bold" style={{ color: config.colors.primary }}> <div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
{room.scores.A} {gameResult.finalScores.A}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div> </div>
</motion.div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-3xl" style={styles.textSecondary}>VS</span> <span className="text-3xl" style={styles.textSecondary}>VS</span>
</div> </div>
<div <motion.div
className={`p-6 rounded-lg text-center ${winnerTeam === 'B' ? 'ring-4' : ''}`} initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className={`p-6 rounded-lg text-center ${gameResult.winner === 'B' ? 'ring-4' : ''}`}
style={{ style={{
backgroundColor: config.colors.secondary + '20', backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`, border: `2px solid ${config.colors.secondary}`,
ringColor: config.colors.secondary, '--tw-ring-color': config.colors.secondary,
}} } as React.CSSProperties}
> >
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div> <div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div>
<div className="text-5xl font-bold" style={{ color: config.colors.secondary }}> <div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B} {gameResult.finalScores.B}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div> </div>
</motion.div> </motion.div>
</div>
{/* New Achievements */} {/* Achievements Unlocked */}
{newAchievements.length > 0 && ( {gameResult.achievementsUnlocked && gameResult.achievementsUnlocked.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
Logros Desbloqueados
</h2>
<div className="grid gap-4">
{gameResult.achievementsUnlocked.map((unlock, i) => (
<motion.div <motion.div
key={i}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }} transition={{ delay: i * 0.2 }}
className="mb-8" className="flex items-center justify-center gap-3 p-4 rounded-lg"
>
<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={{ style={{
backgroundColor: config.colors.accent + '20', backgroundColor: config.colors.accent + '20',
border: `2px solid ${config.colors.accent}`, border: `2px solid ${config.colors.accent}`,
}} }}
> >
<div className="text-3xl mb-2">{achievement.icon}</div> <span className="text-3xl">{unlock.achievement.icon}</span>
<div className="font-bold" style={{ color: config.colors.text }}> <span className="font-bold" style={{ color: config.colors.text }}>
{achievement.name} {unlock.achievement.name}
</div> </span>
<div className="text-xs" style={styles.textSecondary}> <span style={styles.textSecondary}>-</span>
{achievement.description} <span
</div> style={{
color: unlock.team === 'A' ? config.colors.primary : config.colors.secondary,
}}
>
{unlock.player_name}
</span>
</motion.div> </motion.div>
))} ))}
</div> </div>
</motion.div> </div>
)} )}
{/* Actions */} {/* Action Buttons */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.7 }} transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 justify-center" className="flex gap-4 justify-center"
> >
{gameResult.replayCode && (
<button
onClick={() => navigate(`/replay/${gameResult.replayCode}`)}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.text,
border: `2px solid ${config.colors.text}`,
}}
>
Ver Replay
</button>
)}
<button <button
onClick={handlePlayAgain} onClick={handlePlayAgain}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105" className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
@@ -189,20 +181,8 @@ export default function Results() {
color: config.colors.bg, color: config.colors.bg,
}} }}
> >
Jugar de Nuevo Nueva Partida
</button> </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>
</motion.div> </motion.div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types' import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
interface GameState { interface GameState {
// Room state // Room state
@@ -44,6 +44,19 @@ interface GameState {
showStealPrompt: boolean showStealPrompt: boolean
setShowStealPrompt: (show: boolean) => void setShowStealPrompt: (show: boolean) => void
// Game result
gameResult: {
winner: 'A' | 'B' | null
finalScores: { A: number; B: number }
replayCode: string | null
achievementsUnlocked: Array<{
player_name: string
team: 'A' | 'B'
achievement: Achievement
}>
} | null
setGameResult: (result: GameState['gameResult']) => void
// Reset // Reset
resetGame: () => void resetGame: () => void
} }
@@ -92,6 +105,9 @@ export const useGameStore = create<GameState>((set) => ({
showStealPrompt: false, showStealPrompt: false,
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }), setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
gameResult: null,
setGameResult: (gameResult) => set({ gameResult }),
resetGame: () => resetGame: () =>
set({ set({
room: null, room: null,
@@ -100,5 +116,6 @@ export const useGameStore = create<GameState>((set) => ({
messages: [], messages: [],
stats: initialStats, stats: initialStats,
showStealPrompt: false, showStealPrompt: false,
gameResult: null,
}), }),
})) }))

View File

@@ -0,0 +1,146 @@
/* Neon Glow Effect (DRRR, Retro, RGB themes) */
.text-shadow-neon {
text-shadow:
0 0 5px currentColor,
0 0 10px currentColor,
0 0 20px currentColor,
0 0 40px currentColor;
}
/* CRT Scanlines (Retro Arcade theme) */
.crt-scanlines {
position: relative;
}
.crt-scanlines::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 10;
}
/* Glitch Effect (DRRR theme) */
.glitch-text {
position: relative;
animation: glitch 2s infinite;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
left: 0;
width: 100%;
overflow: hidden;
}
.glitch-text::before {
animation: glitch-top 2s infinite;
clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
-webkit-clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
}
.glitch-text::after {
animation: glitch-bottom 2s infinite;
clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
-webkit-clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
}
@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); }
}
@keyframes glitch-top {
0%, 100% { transform: translate(0); color: #00ffff; }
50% { transform: translate(-3px); }
}
@keyframes glitch-bottom {
0%, 100% { transform: translate(0); color: #ff00ff; }
50% { transform: translate(3px); }
}
/* Sparkle Effect (Anime 90s theme) */
.sparkle {
position: relative;
}
.sparkle::before {
content: '\2728';
position: absolute;
top: -10px;
right: -10px;
animation: sparkle-float 1.5s ease-in-out infinite;
font-size: 1rem;
}
.sparkle::after {
content: '\2B50';
position: absolute;
bottom: -5px;
left: -5px;
animation: sparkle-float 1.5s ease-in-out infinite 0.5s;
font-size: 0.8rem;
}
@keyframes sparkle-float {
0%, 100% { opacity: 1; transform: scale(1) rotate(0deg); }
50% { opacity: 0.5; transform: scale(1.2) rotate(180deg); }
}
/* RGB Shift Effect (Gaming RGB theme) */
.animate-rgb-shift {
animation: rgb-shift 3s linear infinite;
}
@keyframes rgb-shift {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
/* RGB Border Animation */
.rgb-border {
position: relative;
}
.rgb-border::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(45deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #8b00ff, #ff0000);
background-size: 400% 400%;
animation: rgb-gradient 3s linear infinite;
z-index: -1;
border-radius: inherit;
}
@keyframes rgb-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Pulse Animation */
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px currentColor; }
50% { box-shadow: 0 0 20px currentColor, 0 0 30px currentColor; }
}
/* Floating Animation */
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}

View File

@@ -93,17 +93,21 @@ export interface GameEvent {
timestamp: string timestamp: string
} }
export interface ReplayData { export interface ReplayMetadata {
session: { session_id: number
id: number
room_code: string room_code: string
team_a_score: number
team_b_score: number
status: string status: string
created_at: string created_at: string
finished_at: string finished_at: string
duration_seconds: number
} }
export interface ReplayData {
metadata: ReplayMetadata
final_scores: { A: number; B: number }
winner: 'A' | 'B' | 'tie'
events: GameEvent[] events: GameEvent[]
event_count: number
} }
// Theme Types // Theme Types

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_WS_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}