From 3e91305e463ab83752cb21b033059de0f0b090e9 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 26 Jan 2026 08:40:36 +0000 Subject: [PATCH] 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 --- frontend/src/App.tsx | 2 +- frontend/src/hooks/useSocket.ts | 30 +- frontend/src/hooks/useSound.ts | 1 - frontend/src/main.tsx | 1 + frontend/src/pages/Game.tsx | 4 +- frontend/src/pages/Home.tsx | 4 +- frontend/src/pages/Lobby.tsx | 70 +++- frontend/src/pages/Replay.tsx | 645 +++++++++++++++++++++++-------- frontend/src/pages/Results.tsx | 204 +++++----- frontend/src/stores/gameStore.ts | 19 +- frontend/src/styles/effects.css | 146 +++++++ frontend/src/types/index.ts | 22 +- frontend/src/vite-env.d.ts | 10 + 13 files changed, 862 insertions(+), 296 deletions(-) create mode 100644 frontend/src/styles/effects.css create mode 100644 frontend/src/vite-env.d.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3e4557..a245a8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ function App() { } /> } /> } /> - } /> + } /> ) diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index ad5b725..d9f8eea 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -1,13 +1,13 @@ import { useEffect, useRef, useCallback } from 'react' import { io, Socket } from 'socket.io-client' 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' export function useSocket() { const socketRef = useRef(null) - const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } = + const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult } = useGameStore() useEffect(() => { @@ -95,10 +95,34 @@ export function useSocket() { 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 () => { socket.disconnect() } - }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd]) + }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult]) // Socket methods const createRoom = useCallback((playerName: string) => { diff --git a/frontend/src/hooks/useSound.ts b/frontend/src/hooks/useSound.ts index d3bd2ad..952061c 100644 --- a/frontend/src/hooks/useSound.ts +++ b/frontend/src/hooks/useSound.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from 'react' import { Howl } from 'howler' import { useSoundStore, soundPaths } from '../stores/soundStore' import { useThemeStore } from '../stores/themeStore' -import type { ThemeName } from '../types' type SoundEffect = | 'correct' diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0f1cdbd..625a365 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import App from './App' import { ThemeProvider } from './themes/ThemeProvider' import './index.css' +import './styles/effects.css' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx index 1547b39..f3c4193 100644 --- a/frontend/src/pages/Game.tsx +++ b/frontend/src/pages/Game.tsx @@ -19,7 +19,7 @@ const categories = [ ] export default function Game() { - const { roomCode } = useParams<{ roomCode: string }>() + useParams<{ roomCode: string }>() const navigate = useNavigate() const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket() const { play } = useSound() @@ -80,7 +80,7 @@ export default function Game() { const handleSubmitAnswer = () => { if (!currentQuestion || !answer.trim()) return - submitAnswer(answer, currentQuestion as Record, room.can_steal) + submitAnswer(answer, currentQuestion as unknown as Record, room.can_steal) setAnswer('') setShowingQuestion(false) } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 17b4615..0350c41 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -85,8 +85,8 @@ export default function Home() { style={{ backgroundColor: themes[themeName].colors.primary + '30', color: themes[themeName].colors.primary, - ringColor: themes[themeName].colors.primary, - }} + '--tw-ring-color': themes[themeName].colors.primary, + } as React.CSSProperties} > {themes[themeName].displayName} diff --git a/frontend/src/pages/Lobby.tsx b/frontend/src/pages/Lobby.tsx index 5eb4116..16a36bb 100644 --- a/frontend/src/pages/Lobby.tsx +++ b/frontend/src/pages/Lobby.tsx @@ -1,9 +1,11 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' import { useSocket } from '../hooks/useSocket' import { useGameStore } from '../stores/gameStore' import { useThemeStyles } from '../themes/ThemeProvider' +import { api } from '../services/api' +import type { Category } from '../types' export default function Lobby() { const { roomCode } = useParams<{ roomCode: string }>() @@ -12,6 +14,24 @@ export default function Lobby() { const { room, playerName } = useGameStore() const { config, styles } = useThemeStyles() + const [categories, setCategories] = useState([]) + 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 useEffect(() => { if (!room && !roomCode) { @@ -38,18 +58,8 @@ export default function Lobby() { const canStart = room.teams.A.length > 0 && room.teams.B.length > 0 const handleStartGame = () => { - // In production, fetch today's questions and build board - const sampleBoard = { - '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) + // El backend carga el tablero desde la BD + startGame({}) } return ( @@ -183,6 +193,40 @@ export default function Lobby() { + {/* Categories */} + {!loading && categories.length > 0 && ( + +

+ Categorias de hoy +

+
+ {categories.map(cat => ( + + {cat.icon} {cat.name} + + ))} +
+
+ )} + + {!loading && categories.length === 0 && ( + + No hay preguntas disponibles para hoy. Contacta al administrador. + + )} + {/* Start Button */} {isHost && ( () + const { replayCode } = useParams<{ replayCode: string }>() const { config, styles } = useThemeStyles() - const [replayData, setReplayData] = useState(null) + // State + const [replay, setReplay] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') const [currentEventIndex, setCurrentEventIndex] = useState(0) const [isPlaying, setIsPlaying] = useState(false) - const [speed, setSpeed] = useState(1) - const [loading, setLoading] = useState(true) + const [playbackSpeed, setPlaybackSpeed] = useState(1) // 1x, 2x, 4x - // Fetch replay data + // Fetch replay data on mount useEffect(() => { const fetchReplay = async () => { try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000' - const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`) - if (response.ok) { - const data = await response.json() - setReplayData(data) - } - } catch (error) { - console.error('Failed to fetch replay:', error) + const response = await fetch(`${API_URL}/api/replay/${replayCode}`) + if (!response.ok) throw new Error('Replay not found') + const data = await response.json() + setReplay(data) + } catch (e) { + setError('No se encontro el replay') } finally { setLoading(false) } } - fetchReplay() - }, [sessionId]) + }, [replayCode]) // Playback logic useEffect(() => { - if (!isPlaying || !replayData) return + if (!isPlaying || !replay) return const interval = setInterval(() => { setCurrentEventIndex((prev) => { - if (prev >= replayData.events.length - 1) { + if (prev >= replay.events.length - 1) { setIsPlaying(false) return prev } return prev + 1 }) - }, 1000 / speed) + }, 2000 / playbackSpeed) return () => clearInterval(interval) - }, [isPlaying, speed, replayData]) + }, [isPlaying, playbackSpeed, replay]) - const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || [] - const currentScores = currentEvents.reduce( - (acc, event) => { - if (event.was_correct && event.points_earned) { - acc[event.team] += event.points_earned - } else if (!event.was_correct && event.was_steal && event.points_earned) { - acc[event.team] -= Math.abs(event.points_earned) - } - return acc - }, - { A: 0, B: 0 } - ) + // Calculate accumulated score up to current event + const currentScores = useMemo(() => { + if (!replay) return { 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 = { + 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) { return (
-

Cargando replay...

+ +
+

Cargando replay...

+
) } - if (!replayData) { + // Error state + if (error || !replay) { return (
-

No se encontró el replay

- - Volver al inicio - +
:(
+

+ {error || 'No se encontro el replay'} +

+

+ El codigo "{replayCode}" no existe o ha expirado +

+ + Volver al inicio + +
) } + const progressPercent = ((currentEventIndex + 1) / replay.events.length) * 100 + return (
-
- {/* Header */} -
- - ← Volver - -

- Replay: {replayData.session.room_code} -

-
- {new Date(replayData.session.created_at).toLocaleDateString()} -
-
- - {/* Scores */} -
-
-
Equipo A
-
- {currentScores.A} +
+ {/* Main Content */} +
+ {/* Header */} +
+ + Volver + +

+ Replay: {replay.metadata.room_code} +

+
+ {new Date(replay.metadata.created_at).toLocaleDateString('es-ES')} |{' '} + {formatDuration(replay.metadata.duration_seconds)}
-
-
Equipo B
-
- {currentScores.B} + + {/* Scoreboard */} +
+ +
+ Equipo A +
+ + {currentScores.A} + +
+ +
+ + VS +
+ + +
+ Equipo B +
+ + {currentScores.B} + +
-
- {/* Playback Controls */} -
- - - + + {currentEvent && ( + + {/* Event Type Badge */} +
+ + {currentEvent.team === 'A' ? 'Equipo A' : 'Equipo B'} - {currentEvent.player_name} + +
-
- Velocidad: - {[1, 2, 4].map((s) => ( -
+ + {/* Playback Controls */} +
+ {/* Progress Bar */} +
+
{ + 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 + +
+
+ Evento {currentEventIndex + 1} + de {replay.events.length} +
+
+ + {/* Control Buttons */} +
+ - ))} + + + + + + + + + + {/* Speed Controls */} +
+ Velocidad: + {[1, 2, 4].map((speed) => ( + + ))} +
+
+ + {/* Final Result */} + {currentEventIndex === replay.events.length - 1 && ( + +

+ Resultado Final +

+
+ {replay.final_scores.A} + - + {replay.final_scores.B} +
+

+ {replay.winner === 'tie' + ? 'Empate!' + : `Ganador: Equipo ${replay.winner}`} +

+
+ )}
- {/* Timeline */} -
- setCurrentEventIndex(Number(e.target.value))} - className="w-full" - /> -
- Evento {currentEventIndex + 1} - de {replayData.events.length} -
-
- - {/* Events List */} + {/* Sidebar - Events List */}
-
- {replayData.events.map((event, index) => ( +
+ Eventos ({replay.event_count}) +
+
+ {replay.events.map((event, index) => ( setCurrentEventIndex(index)} + className={`p-3 border-b cursor-pointer transition-all hover:bg-opacity-80 ${ + index === currentEventIndex ? 'ring-2 ring-inset' : '' + }`} style={{ borderColor: config.colors.primary + '30', - backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50', - ringColor: config.colors.accent, - }} + backgroundColor: + index === currentEventIndex + ? config.colors.primary + '20' + : index <= currentEventIndex + ? config.colors.bg + : config.colors.bg + '50', + '--tw-ring-color': config.colors.accent, + } as React.CSSProperties} >
{event.team} - {event.player_name} + + {event.player_name} +
-
- {event.was_correct ? ( - ✓ +{event.points_earned} - ) : ( - ✗ {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''} +
+ {event.event_type === 'answer_submitted' && ( + <> + {event.was_correct ? ( + +{event.points_earned} + ) : ( + + {event.points_earned < 0 ? event.points_earned : 'X'} + + )} + )}
+
+ {getEventTypeLabel(event.event_type)} + {event.was_steal && ' (Robo)'} +
{event.answer_given && ( -
- Respuesta: "{event.answer_given}" +
+ "{event.answer_given}"
)} ))}
- - {/* Final Scores */} -
-
Resultado Final
-
- {replayData.session.team_a_score} - - - {replayData.session.team_b_score} -
-
) diff --git a/frontend/src/pages/Results.tsx b/frontend/src/pages/Results.tsx index 69e1f00..8dad459 100644 --- a/frontend/src/pages/Results.tsx +++ b/frontend/src/pages/Results.tsx @@ -1,33 +1,33 @@ import { useEffect } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' import { useSound } from '../hooks/useSound' -import { useAchievements } from '../hooks/useAchievements' import { useGameStore } from '../stores/gameStore' import { useThemeStyles } from '../themes/ThemeProvider' export default function Results() { - const { roomCode } = useParams<{ roomCode: string }>() const navigate = useNavigate() const { play } = useSound() - const { achievements } = useAchievements() - const { room, playerName, resetGame } = useGameStore() + const { gameResult, resetGame, playerName, room } = useGameStore() const { config, styles } = useThemeStyles() + // Determine if current player won 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 tied = room ? room.scores.A === room.scores.B : false + const won = gameResult?.winner === myTeam + const tied = gameResult?.winner === null // Play victory/defeat sound useEffect(() => { - if (won) { - play('victory') - } else if (!tied) { - play('defeat') + if (gameResult) { + if (won) { + play('victory') + } else if (!tied) { + play('defeat') + } } - }, [won, tied, play]) + }, [gameResult, won, tied, play]) - if (!room) { + if (!gameResult) { return (

No hay resultados disponibles

@@ -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 = () => { resetGame() navigate('/') @@ -52,135 +49,130 @@ export default function Results() { > {/* Result Header */} - {tied ? ( + {gameResult.winner ? ( +

+ ¡Equipo {gameResult.winner} Gana! +

+ ) : (

- ¡EMPATE! + ¡Empate!

- ) : won ? ( - <> -

- ¡VICTORIA! -

-

- Tu equipo ha ganado -

- - ) : ( - <> -

- DERROTA -

-

- Mejor suerte la próxima vez -

- )}
- {/* Scores */} - -
+
Equipo A
-
- {room.scores.A} +
+ {gameResult.finalScores.A}
-
- {room.teams.A.map(p => p.name).join(', ')} -
-
+
VS
-
Equipo B
-
- {room.scores.B} +
+ {gameResult.finalScores.B}
-
- {room.teams.B.map(p => p.name).join(', ')} -
-
- + +
- {/* New Achievements */} - {newAchievements.length > 0 && ( - + {/* Achievements Unlocked */} + {gameResult.achievementsUnlocked && gameResult.achievementsUnlocked.length > 0 && ( +

- ¡Nuevos Logros Desbloqueados! + Logros Desbloqueados

-
- {newAchievements.map((achievement) => ( +
+ {gameResult.achievementsUnlocked.map((unlock, i) => ( -
{achievement.icon}
-
- {achievement.name} -
-
- {achievement.description} -
+ {unlock.achievement.icon} + + {unlock.achievement.name} + + - + + {unlock.player_name} +
))}
- +
)} - {/* Actions */} + {/* Action Buttons */} + {gameResult.replayCode && ( + + )} + - - - Ver Replay -
diff --git a/frontend/src/stores/gameStore.ts b/frontend/src/stores/gameStore.ts index 9a7fefa..6679064 100644 --- a/frontend/src/stores/gameStore.ts +++ b/frontend/src/stores/gameStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types' +import type { GameRoom, Question, ChatMessage, Achievement } from '../types' interface GameState { // Room state @@ -44,6 +44,19 @@ interface GameState { showStealPrompt: boolean 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 resetGame: () => void } @@ -92,6 +105,9 @@ export const useGameStore = create((set) => ({ showStealPrompt: false, setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }), + gameResult: null, + setGameResult: (gameResult) => set({ gameResult }), + resetGame: () => set({ room: null, @@ -100,5 +116,6 @@ export const useGameStore = create((set) => ({ messages: [], stats: initialStats, showStealPrompt: false, + gameResult: null, }), })) diff --git a/frontend/src/styles/effects.css b/frontend/src/styles/effects.css new file mode 100644 index 0000000..c8f351c --- /dev/null +++ b/frontend/src/styles/effects.css @@ -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); } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f23f053..642ac58 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,17 +93,21 @@ export interface GameEvent { 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 { - session: { - id: number - room_code: string - team_a_score: number - team_b_score: number - status: string - created_at: string - finished_at: string - } + metadata: ReplayMetadata + final_scores: { A: number; B: number } + winner: 'A' | 'B' | 'tie' events: GameEvent[] + event_count: number } // Theme Types diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..7c8ed3c --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string + readonly VITE_WS_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}