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:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
setReplay(data)
|
||||||
setReplayData(data)
|
} catch (e) {
|
||||||
}
|
setError('No se encontro el replay')
|
||||||
} catch (error) {
|
|
||||||
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(() => {
|
||||||
(acc, event) => {
|
if (!replay) return { A: 0, B: 0 }
|
||||||
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 }
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const eventsUpToCurrent = replay.events.slice(0, currentEventIndex + 1)
|
||||||
|
return eventsUpToCurrent.reduce(
|
||||||
|
(acc, event) => {
|
||||||
|
if (event.points_earned !== 0) {
|
||||||
|
acc[event.team] += event.points_earned
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ 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}>
|
||||||
<p style={styles.textSecondary}>Cargando replay...</p>
|
<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>
|
||||||
|
</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
|
||||||
<Link
|
initial={{ opacity: 0, y: 20 }}
|
||||||
to="/"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="px-6 py-2 rounded-lg"
|
className="text-center"
|
||||||
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
|
||||||
>
|
>
|
||||||
Volver al inicio
|
<div className="text-6xl mb-4">:(</div>
|
||||||
</Link>
|
<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
|
||||||
|
to="/"
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
Volver al inicio
|
||||||
|
</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">
|
||||||
{/* Header */}
|
{/* Main Content */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex-1">
|
||||||
<Link to="/" style={{ color: config.colors.primary }}>
|
{/* Header */}
|
||||||
← Volver
|
<div className="flex justify-between items-center mb-6">
|
||||||
</Link>
|
<Link
|
||||||
<h1
|
to="/"
|
||||||
className="text-2xl font-bold"
|
className="flex items-center gap-2 transition-opacity hover:opacity-70"
|
||||||
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
style={{ color: config.colors.primary }}
|
||||||
>
|
>
|
||||||
Replay: {replayData.session.room_code}
|
<span>←</span> Volver
|
||||||
</h1>
|
</Link>
|
||||||
<div style={styles.textSecondary}>
|
<h1
|
||||||
{new Date(replayData.session.created_at).toLocaleDateString()}
|
className="text-2xl font-bold"
|
||||||
</div>
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
</div>
|
>
|
||||||
|
Replay: {replay.metadata.room_code}
|
||||||
{/* Scores */}
|
</h1>
|
||||||
<div className="flex justify-center gap-8 mb-8">
|
<div style={styles.textSecondary}>
|
||||||
<div className="text-center">
|
{new Date(replay.metadata.created_at).toLocaleDateString('es-ES')} |{' '}
|
||||||
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
|
{formatDuration(replay.metadata.duration_seconds)}
|
||||||
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
|
|
||||||
{currentScores.A}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
|
{/* Scoreboard */}
|
||||||
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
|
<div className="flex justify-center gap-8 mb-8">
|
||||||
{currentScores.B}
|
<motion.div
|
||||||
|
className="text-center px-8 py-4 rounded-lg"
|
||||||
|
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}
|
||||||
|
</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>
|
||||||
|
<motion.div
|
||||||
|
key={currentScores.B}
|
||||||
|
initial={{ scale: 1.2 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="text-4xl font-bold"
|
||||||
|
style={{ color: config.colors.secondary }}
|
||||||
|
>
|
||||||
|
{currentScores.B}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Playback Controls */}
|
{/* Current Event Display */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg"
|
className="mb-8 p-6 rounded-lg min-h-[200px] flex items-center justify-center"
|
||||||
style={{ backgroundColor: config.colors.bg, border: `1px solid ${config.colors.primary}` }}
|
style={{
|
||||||
>
|
backgroundColor: config.colors.bg,
|
||||||
<button
|
border: `2px solid ${config.colors.primary}`,
|
||||||
onClick={() => setCurrentEventIndex(0)}
|
}}
|
||||||
className="p-2 rounded"
|
|
||||||
style={{ color: config.colors.text }}
|
|
||||||
>
|
>
|
||||||
⏮️
|
<AnimatePresence mode="wait">
|
||||||
</button>
|
{currentEvent && (
|
||||||
<button
|
<motion.div
|
||||||
onClick={() => setIsPlaying(!isPlaying)}
|
key={currentEvent.id}
|
||||||
className="px-6 py-2 rounded-lg font-bold"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
exit={{ opacity: 0, y: -20 }}
|
||||||
{isPlaying ? '⏸️ Pausar' : '▶️ Reproducir'}
|
transition={{ duration: 0.3 }}
|
||||||
</button>
|
className="text-center w-full"
|
||||||
<button
|
>
|
||||||
onClick={() => setCurrentEventIndex(replayData.events.length - 1)}
|
{/* Event Type Badge */}
|
||||||
className="p-2 rounded"
|
<div className="mb-4">
|
||||||
style={{ color: config.colors.text }}
|
<span
|
||||||
>
|
className="inline-block px-3 py-1 rounded-full text-sm font-bold"
|
||||||
⏭️
|
style={{
|
||||||
</button>
|
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 className="ml-4 flex items-center gap-2">
|
{/* Event Content */}
|
||||||
<span style={styles.textSecondary}>Velocidad:</span>
|
{currentEvent.event_type === 'answer_submitted' && (
|
||||||
{[1, 2, 4].map((s) => (
|
<div>
|
||||||
<button
|
<p className="text-lg mb-2" style={styles.textSecondary}>
|
||||||
key={s}
|
{currentEvent.player_name} respondio:
|
||||||
onClick={() => setSpeed(s)}
|
</p>
|
||||||
className={`px-3 py-1 rounded ${speed === s ? 'font-bold' : 'opacity-50'}`}
|
<motion.p
|
||||||
style={{
|
initial={{ scale: 0.9 }}
|
||||||
backgroundColor: speed === s ? config.colors.primary : 'transparent',
|
animate={{ scale: 1 }}
|
||||||
color: speed === s ? config.colors.bg : config.colors.text,
|
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>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-lg mb-6"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `1px solid ${config.colors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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)))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s}x
|
<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>
|
||||||
))}
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Sidebar - Events List */}
|
||||||
<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
|
<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.was_correct ? (
|
{event.event_type === 'answer_submitted' && (
|
||||||
<span className="text-green-500">✓ +{event.points_earned}</span>
|
<>
|
||||||
) : (
|
{event.was_correct ? (
|
||||||
<span className="text-red-500">✗ {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span>
|
<span className="text-green-500 text-sm">+{event.points_earned}</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (won) {
|
if (gameResult) {
|
||||||
play('victory')
|
if (won) {
|
||||||
} else if (!tied) {
|
play('victory')
|
||||||
play('defeat')
|
} else if (!tied) {
|
||||||
|
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 */}
|
||||||
<motion.div
|
<div className="flex justify-center gap-8 mb-8">
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
initial={{ x: -50, opacity: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
className="flex justify-center gap-8 mb-8"
|
transition={{ delay: 0.2 }}
|
||||||
>
|
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>
|
||||||
<div className="mt-2 text-sm" style={styles.textSecondary}>
|
</motion.div>
|
||||||
{room.teams.A.map(p => p.name).join(', ')}
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
<div className="mt-2 text-sm" style={styles.textSecondary}>
|
</motion.div>
|
||||||
{room.teams.B.map(p => p.name).join(', ')}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* New Achievements */}
|
{/* Achievements Unlocked */}
|
||||||
{newAchievements.length > 0 && (
|
{gameResult.achievementsUnlocked && gameResult.achievementsUnlocked.length > 0 && (
|
||||||
<motion.div
|
<div className="mb-8">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
|
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
|
||||||
¡Nuevos Logros Desbloqueados!
|
Logros Desbloqueados
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className="grid gap-4">
|
||||||
{newAchievements.map((achievement) => (
|
{gameResult.achievementsUnlocked.map((unlock, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={achievement.id}
|
key={i}
|
||||||
initial={{ scale: 0 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ type: 'spring', bounce: 0.5 }}
|
transition={{ delay: i * 0.2 }}
|
||||||
className="p-4 rounded-lg text-center"
|
className="flex items-center justify-center gap-3 p-4 rounded-lg"
|
||||||
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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
146
frontend/src/styles/effects.css
Normal file
146
frontend/src/styles/effects.css
Normal 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); }
|
||||||
|
}
|
||||||
@@ -93,17 +93,21 @@ export interface GameEvent {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplayMetadata {
|
||||||
|
session_id: number
|
||||||
|
room_code: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
finished_at: string
|
||||||
|
duration_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReplayData {
|
export interface ReplayData {
|
||||||
session: {
|
metadata: ReplayMetadata
|
||||||
id: number
|
final_scores: { A: number; B: number }
|
||||||
room_code: string
|
winner: 'A' | 'B' | 'tie'
|
||||||
team_a_score: number
|
|
||||||
team_b_score: number
|
|
||||||
status: string
|
|
||||||
created_at: string
|
|
||||||
finished_at: string
|
|
||||||
}
|
|
||||||
events: GameEvent[]
|
events: GameEvent[]
|
||||||
|
event_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme Types
|
// Theme Types
|
||||||
|
|||||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user