feat: Initial project structure for WebTriviasMulti

- Backend: FastAPI + Python-SocketIO + SQLAlchemy
  - Models for categories, questions, game sessions, events
  - AI services for answer validation and question generation (Claude)
  - Room management with Redis
  - Game logic with stealing mechanics
  - Admin API for question management

- Frontend: React + Vite + TypeScript + Tailwind
  - 5 visual themes (DRRR, Retro, Minimal, RGB, Anime 90s)
  - Real-time game with Socket.IO
  - Achievement system
  - Replay functionality
  - Sound effects per theme

- Docker Compose for deployment
- Design documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 07:50:48 +00:00
commit 43021b9c3c
57 changed files with 5446 additions and 0 deletions

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source
COPY . .
# Expose port
EXPOSE 3000
# Development command
CMD ["npm", "run", "dev"]

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="WebTriviasMulti - Trivia multiplayer en tiempo real" />
<title>WebTriviasMulti</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "webtriviasmulti-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"socket.io-client": "^4.7.4",
"zustand": "^4.5.0",
"framer-motion": "^11.0.3",
"howler": "^2.2.4",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/howler": "^2.2.11",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

22
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import Lobby from './pages/Lobby'
import Game from './pages/Game'
import Results from './pages/Results'
import Replay from './pages/Replay'
function App() {
return (
<div className="min-h-screen">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/lobby/:roomCode" element={<Lobby />} />
<Route path="/game/:roomCode" element={<Game />} />
<Route path="/results/:roomCode" element={<Results />} />
<Route path="/replay/:sessionId" element={<Replay />} />
</Routes>
</div>
)
}
export default App

View File

@@ -0,0 +1,165 @@
import { useCallback, useEffect } from 'react'
import { useGameStore } from '../stores/gameStore'
import type { Achievement } from '../types'
const STORAGE_KEY = 'trivia-achievements'
// Achievement definitions
const achievementDefinitions: Achievement[] = [
{ id: 1, name: 'Primera Victoria', description: 'Ganar tu primera partida', icon: '🏆' },
{ id: 2, name: 'Racha de 3', description: 'Responder 3 correctas seguidas', icon: '🔥' },
{ id: 3, name: 'Racha de 5', description: 'Responder 5 correctas seguidas', icon: '🔥🔥' },
{ id: 4, name: 'Ladrón Novato', description: 'Primer robo exitoso', icon: '🦝' },
{ id: 5, name: 'Ladrón Maestro', description: '5 robos exitosos en una partida', icon: '🦝👑' },
{ id: 6, name: 'Especialista Nintendo', description: '10 correctas en Nintendo', icon: '🍄' },
{ id: 7, name: 'Especialista Xbox', description: '10 correctas en Xbox', icon: '🎮' },
{ id: 8, name: 'Especialista PlayStation', description: '10 correctas en PlayStation', icon: '🎯' },
{ id: 9, name: 'Especialista Anime', description: '10 correctas en Anime', icon: '⛩️' },
{ id: 10, name: 'Especialista Música', description: '10 correctas en Música', icon: '🎵' },
{ id: 11, name: 'Especialista Películas', description: '10 correctas en Películas', icon: '🎬' },
{ id: 12, name: 'Especialista Libros', description: '10 correctas en Libros', icon: '📚' },
{ id: 13, name: 'Especialista Historia', description: '10 correctas en Historia-Cultura', icon: '🏛️' },
{ id: 14, name: 'Invicto', description: 'Ganar sin fallar ninguna pregunta', icon: '⭐' },
{ id: 15, name: 'Velocista', description: 'Responder correctamente en menos de 3 segundos', icon: '⚡' },
{ id: 16, name: 'Comeback', description: 'Ganar estando 500+ puntos abajo', icon: '🔄' },
{ id: 17, name: 'Dominio Total', description: 'Responder las 5 preguntas de una categoría', icon: '👑' },
{ id: 18, name: 'Arriesgado', description: 'Responder correctamente 3 preguntas de 500 pts', icon: '🎰' },
]
export function useAchievements() {
const { achievements, setAchievements, unlockAchievement, stats } = useGameStore()
// Load achievements from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
// Merge with definitions to ensure all achievements exist
const merged = achievementDefinitions.map((def) => ({
...def,
unlocked: parsed.find((a: Achievement) => a.id === def.id)?.unlocked || false,
unlockedAt: parsed.find((a: Achievement) => a.id === def.id)?.unlockedAt,
}))
setAchievements(merged)
} catch {
setAchievements(achievementDefinitions)
}
} else {
setAchievements(achievementDefinitions)
}
}, [setAchievements])
// Save achievements to localStorage when they change
useEffect(() => {
if (achievements.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(achievements))
}
}, [achievements])
const checkAchievements = useCallback(
(context: {
won?: boolean
correctStreak?: number
stealSuccess?: boolean
categoryId?: number
answerTime?: number
deficit?: number
points?: number
neverFailed?: boolean
categoryComplete?: number
}) => {
const newUnlocks: number[] = []
// First Victory
if (context.won && !achievements.find((a) => a.id === 1)?.unlocked) {
unlockAchievement(1)
newUnlocks.push(1)
}
// Streaks
if (context.correctStreak && context.correctStreak >= 3) {
if (!achievements.find((a) => a.id === 2)?.unlocked) {
unlockAchievement(2)
newUnlocks.push(2)
}
}
if (context.correctStreak && context.correctStreak >= 5) {
if (!achievements.find((a) => a.id === 3)?.unlocked) {
unlockAchievement(3)
newUnlocks.push(3)
}
}
// Steals
if (context.stealSuccess) {
if (!achievements.find((a) => a.id === 4)?.unlocked) {
unlockAchievement(4)
newUnlocks.push(4)
}
if (stats.stealsSuccessful >= 5 && !achievements.find((a) => a.id === 5)?.unlocked) {
unlockAchievement(5)
newUnlocks.push(5)
}
}
// Category specialists (6-13)
const categoryAchievementMap: Record<number, number> = {
1: 6, // Nintendo
2: 7, // Xbox
3: 8, // PlayStation
4: 9, // Anime
5: 10, // Música
6: 11, // Películas
7: 12, // Libros
8: 13, // Historia-Cultura
}
if (context.categoryId) {
const achievementId = categoryAchievementMap[context.categoryId]
const categoryCount = stats.categoryCorrect[context.categoryId] || 0
if (categoryCount >= 10 && achievementId && !achievements.find((a) => a.id === achievementId)?.unlocked) {
unlockAchievement(achievementId)
newUnlocks.push(achievementId)
}
}
// Invicto
if (context.won && context.neverFailed && !achievements.find((a) => a.id === 14)?.unlocked) {
unlockAchievement(14)
newUnlocks.push(14)
}
// Velocista
if (context.answerTime && context.answerTime < 3 && !achievements.find((a) => a.id === 15)?.unlocked) {
unlockAchievement(15)
newUnlocks.push(15)
}
// Comeback
if (context.won && context.deficit && context.deficit >= 500 && !achievements.find((a) => a.id === 16)?.unlocked) {
unlockAchievement(16)
newUnlocks.push(16)
}
// Dominio Total
if (context.categoryComplete && !achievements.find((a) => a.id === 17)?.unlocked) {
unlockAchievement(17)
newUnlocks.push(17)
}
// Arriesgado
if (context.points === 500 && stats.fastAnswers >= 3 && !achievements.find((a) => a.id === 18)?.unlocked) {
unlockAchievement(18)
newUnlocks.push(18)
}
return newUnlocks.map((id) => achievements.find((a) => a.id === id)!)
},
[achievements, stats, unlockAchievement]
)
return {
achievements,
checkAchievements,
}
}

View File

@@ -0,0 +1,175 @@
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'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } =
useGameStore()
useEffect(() => {
// Create socket connection
socketRef.current = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
autoConnect: true,
})
const socket = socketRef.current
// Connection events
socket.on('connect', () => {
console.log('Connected to server')
})
socket.on('disconnect', () => {
console.log('Disconnected from server')
})
socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message)
})
// Room events
socket.on('room_created', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('player_joined', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('player_left', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('team_changed', (data: { room: GameRoom }) => {
setRoom(data.room)
})
// Game events
socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room)
})
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
setRoom(data.room)
// Fetch full question details
})
socket.on('answer_result', (data: AnswerResult) => {
setRoom(data.room)
if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
}
})
socket.on('steal_attempted', (data: { room: GameRoom }) => {
setRoom(data.room)
setShowStealPrompt(false)
})
socket.on('steal_passed', (data: { room: GameRoom }) => {
setRoom(data.room)
setShowStealPrompt(false)
})
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
setRoom(data.room)
if (!data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
} else {
setShowStealPrompt(false)
}
})
// Chat events
socket.on('chat_message', (data: ChatMessage) => {
addMessage(data)
})
socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => {
// Handle emoji reaction display
console.log(`${data.player_name} reacted with ${data.emoji}`)
})
return () => {
socket.disconnect()
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd])
// Socket methods
const createRoom = useCallback((playerName: string) => {
socketRef.current?.emit('create_room', { player_name: playerName })
}, [])
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketRef.current?.emit('join_room', {
room_code: roomCode,
player_name: playerName,
team,
})
}, [])
const changeTeam = useCallback((team: 'A' | 'B') => {
socketRef.current?.emit('change_team', { team })
}, [])
const startGame = useCallback((board: Record<string, unknown>) => {
socketRef.current?.emit('start_game', { board })
}, [])
const selectQuestion = useCallback((questionId: number, categoryId: number) => {
socketRef.current?.emit('select_question', {
question_id: questionId,
category_id: categoryId,
})
}, [])
const submitAnswer = useCallback(
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
socketRef.current?.emit('submit_answer', {
answer,
question,
is_steal: isSteal,
})
},
[]
)
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
socketRef.current?.emit('steal_decision', {
attempt,
question_id: questionId,
answer,
})
}, [])
const sendChatMessage = useCallback((message: string) => {
socketRef.current?.emit('chat_message', { message })
}, [])
const sendEmojiReaction = useCallback((emoji: string) => {
socketRef.current?.emit('emoji_reaction', { emoji })
}, [])
const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {})
}, [])
return {
socket: socketRef.current,
createRoom,
joinRoom,
changeTeam,
startGame,
selectQuestion,
submitAnswer,
stealDecision,
sendChatMessage,
sendEmojiReaction,
notifyTimerExpired,
}
}

View File

@@ -0,0 +1,84 @@
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'
| 'incorrect'
| 'steal'
| 'timer_tick'
| 'timer_urgent'
| 'victory'
| 'defeat'
| 'select'
export function useSound() {
const { volume, muted } = useSoundStore()
const { currentTheme } = useThemeStore()
const soundsRef = useRef<Map<string, Howl>>(new Map())
// Preload sounds for current theme
useEffect(() => {
const themeSounds = soundPaths[currentTheme]
if (!themeSounds) return
// Clear old sounds
soundsRef.current.forEach((sound) => sound.unload())
soundsRef.current.clear()
// Load new sounds
Object.entries(themeSounds).forEach(([key, path]) => {
const sound = new Howl({
src: [path],
volume: volume,
preload: true,
onloaderror: () => {
console.warn(`Failed to load sound: ${path}`)
},
})
soundsRef.current.set(key, sound)
})
return () => {
soundsRef.current.forEach((sound) => sound.unload())
}
}, [currentTheme])
// Update volume when it changes
useEffect(() => {
soundsRef.current.forEach((sound) => {
sound.volume(volume)
})
}, [volume])
const play = useCallback(
(effect: SoundEffect) => {
if (muted) return
const sound = soundsRef.current.get(effect)
if (sound) {
sound.play()
}
},
[muted]
)
const stop = useCallback((effect: SoundEffect) => {
const sound = soundsRef.current.get(effect)
if (sound) {
sound.stop()
}
}, [])
const stopAll = useCallback(() => {
soundsRef.current.forEach((sound) => sound.stop())
}, [])
return {
play,
stop,
stopAll,
}
}

120
frontend/src/index.css Normal file
View File

@@ -0,0 +1,120 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
:root {
--color-bg: #0a0a0a;
--color-primary: #FFE135;
--color-secondary: #00FFFF;
--color-accent: #FF00FF;
--color-text: #ffffff;
--color-text-muted: #888888;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: 4px;
}
/* Theme transitions */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Utility classes */
.text-shadow-neon {
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor;
}
.border-neon {
box-shadow: 0 0 5px currentColor, 0 0 10px currentColor, inset 0 0 5px currentColor;
}
/* CRT scanline effect for retro theme */
.crt-scanlines::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
transparent 50%,
rgba(0, 0, 0, 0.1) 50%
);
background-size: 100% 4px;
pointer-events: none;
}
/* Glitch effect for DRRR theme */
.glitch-text {
position: relative;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch-text::before {
animation: glitch-1 0.3s infinite;
color: var(--color-secondary);
z-index: -1;
}
.glitch-text::after {
animation: glitch-2 0.3s infinite;
color: var(--color-accent);
z-index: -2;
}
@keyframes glitch-1 {
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
20% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
40% { clip-path: inset(40% 0 40% 0); transform: translate(2px, 2px); }
60% { clip-path: inset(60% 0 20% 0); transform: translate(-2px, 2px); }
80% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
}
@keyframes glitch-2 {
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
20% { clip-path: inset(60% 0 20% 0); transform: translate(2px, 2px); }
40% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
60% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
80% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 2px); }
}
/* Sparkle effect for anime theme */
.sparkle::before {
content: '✦';
position: absolute;
animation: sparkle-float 2s infinite;
}
@keyframes sparkle-float {
0%, 100% { opacity: 0; transform: translateY(0) scale(0); }
50% { opacity: 1; transform: translateY(-20px) scale(1); }
}

16
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { ThemeProvider } from './themes/ThemeProvider'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

335
frontend/src/pages/Game.tsx Normal file
View File

@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { Question } from '../types'
const categories = [
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' },
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' },
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' },
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' },
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' },
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' },
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' },
]
export default function Game() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
const { play } = useSound()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore()
const { config, styles } = useThemeStyles()
const [answer, setAnswer] = useState('')
const [timeLeft, setTimeLeft] = useState(0)
const [showingQuestion, setShowingQuestion] = useState(false)
// Redirect if game finished
useEffect(() => {
if (room?.status === 'finished') {
navigate(`/results/${room.code}`)
}
}, [room?.status, room?.code, navigate])
// Timer logic
useEffect(() => {
if (!currentQuestion || !showingQuestion) return
setTimeLeft(currentQuestion.time_seconds)
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
if (prev === 6) play('timer_urgent')
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [currentQuestion, showingQuestion, play])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p>
</div>
)
}
const myTeam = room.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
const isMyTurn = room.current_team === myTeam
const currentPlayer = isMyTurn
? room.teams[myTeam][room.current_player_index[myTeam]]
: null
const amICurrentPlayer = currentPlayer?.name === playerName
const handleSelectQuestion = (question: Question, categoryId: number) => {
if (!amICurrentPlayer || question.answered) return
play('select')
selectQuestion(question.id, categoryId)
setShowingQuestion(true)
}
const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as Record<string, unknown>, room.can_steal)
setAnswer('')
setShowingQuestion(false)
}
const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return
if (attempt) {
setShowingQuestion(true)
} else {
stealDecision(false, currentQuestion.id)
}
setShowStealPrompt(false)
}
const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-6xl mx-auto">
{/* Scoreboard */}
<div className="flex justify-between items-center mb-6">
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}>
{room.scores.A}
</div>
</div>
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'}
</div>
{amICurrentPlayer && (
<div className="text-lg font-bold" style={{ color: config.colors.accent }}>
¡Tu turno!
</div>
)}
</div>
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-3xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
</div>
</div>
{/* Game Board */}
<div className="grid grid-cols-8 gap-2 mb-6">
{/* Category Headers */}
{categories.map((cat) => (
<div
key={cat.id}
className="text-center p-2 rounded-t-lg"
style={{ backgroundColor: cat.color }}
>
<div className="text-2xl">{cat.icon}</div>
<div className="text-xs text-white font-bold truncate">{cat.name}</div>
</div>
))}
{/* Questions Grid */}
{[1, 2, 3, 4, 5].map((difficulty) =>
categories.map((cat) => {
const questions = room.board[String(cat.id)] || []
const question = questions.find(q => q.difficulty === difficulty)
const isAnswered = question?.answered
return (
<motion.button
key={`${cat.id}-${difficulty}`}
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}}
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}}
onClick={() => question && handleSelectQuestion(question, cat.id)}
disabled={isAnswered || !amICurrentPlayer}
className={`p-4 rounded transition-all ${
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
}`}
style={{
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40',
border: `2px solid ${cat.color}`,
}}
>
<span className="text-xl font-bold" style={{ color: config.colors.text }}>
{difficulty * 100}
</span>
</motion.button>
)
})
)}
</div>
{/* Question Modal */}
<AnimatePresence>
{showingQuestion && currentQuestion && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="w-full max-w-lg p-6 rounded-lg"
style={{
backgroundColor: config.colors.bg,
border: `3px solid ${config.colors.primary}`,
}}
>
{/* Timer */}
<div className="flex justify-between items-center mb-4">
<span className="text-sm" style={styles.textSecondary}>
{currentQuestion.points} puntos
</span>
<div
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`}
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }}
>
{timeLeft}s
</div>
</div>
{/* Question */}
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}>
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'}
</p>
{/* Answer Input */}
{amICurrentPlayer && (
<div className="space-y-4">
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
placeholder="Escribe tu respuesta..."
autoFocus
className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg"
style={{
border: `2px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
<button
onClick={handleSubmitAnswer}
disabled={!answer.trim()}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Responder
</button>
</div>
)}
{!amICurrentPlayer && (
<p className="text-center" style={styles.textSecondary}>
Esperando respuesta de {currentPlayer?.name}...
</p>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Steal Prompt */}
<AnimatePresence>
{showStealPrompt && room.current_team === myTeam && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="p-6 rounded-lg text-center"
style={{
backgroundColor: config.colors.bg,
border: `3px solid ${config.colors.accent}`,
}}
>
<h3 className="text-2xl font-bold mb-4" style={{ color: config.colors.accent }}>
¡Oportunidad de Robo!
</h3>
<p className="mb-6" style={styles.textSecondary}>
El equipo contrario falló. ¿Quieres intentar robar los puntos?
<br />
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span>
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => handleStealDecision(true)}
className="px-6 py-3 rounded-lg font-bold"
style={{
backgroundColor: config.colors.accent,
color: config.colors.bg,
}}
>
¡Robar!
</button>
<button
onClick={() => handleStealDecision(false)}
className="px-6 py-3 rounded-lg font-bold"
style={{
backgroundColor: 'transparent',
color: config.colors.text,
border: `2px solid ${config.colors.text}`,
}}
>
Pasar
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Emoji Reactions */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{emojis.map((emoji) => (
<button
key={emoji}
onClick={() => sendEmojiReaction(emoji)}
className="text-2xl p-2 rounded-lg transition-transform hover:scale-125"
style={{ backgroundColor: config.colors.bg + '80' }}
>
{emoji}
</button>
))}
</div>
</div>
</div>
)
}

213
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types'
export default function Home() {
const [playerName, setPlayerName] = useState('')
const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('')
const navigate = useNavigate()
const { createRoom, joinRoom } = useSocket()
const { setPlayerName: storeSetPlayerName, room } = useGameStore()
const { currentTheme, setTheme } = useThemeStore()
const { config, styles } = useThemeStyles()
// Navigate when room is created/joined
if (room) {
navigate(`/lobby/${room.code}`)
}
const handleCreateRoom = () => {
if (!playerName.trim()) {
setError('Ingresa tu nombre')
return
}
storeSetPlayerName(playerName.trim())
createRoom(playerName.trim())
}
const handleJoinRoom = () => {
if (!playerName.trim()) {
setError('Ingresa tu nombre')
return
}
if (!roomCode.trim() || roomCode.length !== 6) {
setError('Ingresa un código de sala válido (6 caracteres)')
return
}
storeSetPlayerName(playerName.trim())
joinRoom(roomCode.toUpperCase(), playerName.trim(), 'A')
}
return (
<div
className="min-h-screen flex flex-col items-center justify-center p-4"
style={styles.bgPrimary}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<h1
className={`text-4xl md:text-6xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
data-text="WebTriviasMulti"
>
WebTriviasMulti
</h1>
<p style={styles.textSecondary}>Trivia multiplayer en tiempo real</p>
</motion.div>
{/* Theme Selector */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap gap-2 mb-8 justify-center"
>
{(Object.keys(themes) as ThemeName[]).map((themeName) => (
<button
key={themeName}
onClick={() => setTheme(themeName)}
className={`px-3 py-1 rounded text-sm transition-all ${
currentTheme === themeName
? 'ring-2 ring-offset-2'
: 'opacity-70 hover:opacity-100'
}`}
style={{
backgroundColor: themes[themeName].colors.primary + '30',
color: themes[themeName].colors.primary,
ringColor: themes[themeName].colors.primary,
}}
>
{themes[themeName].displayName}
</button>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="w-full max-w-md p-6 rounded-lg"
style={{
backgroundColor: config.colors.bg,
border: `2px solid ${config.colors.primary}`,
boxShadow: config.effects.glow
? `0 0 20px ${config.colors.primary}40`
: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
{mode === 'select' ? (
<div className="space-y-4">
<button
onClick={() => setMode('create')}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Crear Sala
</button>
<button
onClick={() => setMode('join')}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.primary,
border: `2px solid ${config.colors.primary}`,
}}
>
Unirse a Sala
</button>
</div>
) : (
<div className="space-y-4">
<button
onClick={() => {
setMode('select')
setError('')
}}
className="text-sm mb-2"
style={{ color: config.colors.textMuted }}
>
Volver
</button>
<div>
<label className="block text-sm mb-1" style={styles.textSecondary}>
Tu nombre
</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Ingresa tu nombre"
maxLength={20}
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none"
style={{
border: `1px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
</div>
{mode === 'join' && (
<div>
<label className="block text-sm mb-1" style={styles.textSecondary}>
Código de sala
</label>
<input
type="text"
value={roomCode}
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
placeholder="ABCD12"
maxLength={6}
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none uppercase tracking-widest text-center text-xl"
style={{
border: `1px solid ${config.colors.primary}`,
color: config.colors.text,
}}
/>
</div>
)}
{error && (
<p className="text-red-500 text-sm text-center">{error}</p>
)}
<button
onClick={mode === 'create' ? handleCreateRoom : handleJoinRoom}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
{mode === 'create' ? 'Crear Sala' : 'Unirse'}
</button>
</div>
)}
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-8 text-sm"
style={styles.textSecondary}
>
8 categorías 2 equipos Preguntas diarias
</motion.p>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useEffect } 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'
export default function Lobby() {
const { roomCode } = useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { changeTeam, startGame } = useSocket()
const { room, playerName } = useGameStore()
const { config, styles } = useThemeStyles()
// Redirect if no room
useEffect(() => {
if (!room && !roomCode) {
navigate('/')
}
}, [room, roomCode, navigate])
// Navigate to game when started
useEffect(() => {
if (room?.status === 'playing') {
navigate(`/game/${room.code}`)
}
}, [room?.status, room?.code, navigate])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p>
</div>
)
}
const isHost = room.host === playerName
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)
}
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<h1
className={`text-3xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
Sala de Espera
</h1>
<div
className="inline-block px-6 py-2 rounded-lg text-2xl tracking-widest"
style={{
backgroundColor: config.colors.primary + '20',
color: config.colors.primary,
border: `2px solid ${config.colors.primary}`,
}}
>
{room.code}
</div>
<p className="mt-2 text-sm" style={styles.textSecondary}>
Comparte este código con tus amigos
</p>
</motion.div>
{/* Teams */}
<div className="grid md:grid-cols-2 gap-6 mb-8">
{/* Team A */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="p-4 rounded-lg"
style={{
backgroundColor: config.colors.primary + '10',
border: `2px solid ${config.colors.primary}`,
}}
>
<h2
className="text-xl font-bold mb-4 text-center"
style={{ color: config.colors.primary }}
>
Equipo A
</h2>
<div className="space-y-2 min-h-[200px]">
{room.teams.A.map((player, index) => (
<div
key={player.socket_id || index}
className="px-4 py-2 rounded flex items-center justify-between"
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}40`,
}}
>
<span style={{ color: config.colors.text }}>{player.name}</span>
{player.name === room.host && (
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}>
Host
</span>
)}
</div>
))}
{room.teams.A.length < 4 && (
<button
onClick={() => changeTeam('A')}
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
style={{
borderColor: config.colors.primary,
color: config.colors.primary,
}}
>
+ Unirse al Equipo A
</button>
)}
</div>
</motion.div>
{/* Team B */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="p-4 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '10',
border: `2px solid ${config.colors.secondary}`,
}}
>
<h2
className="text-xl font-bold mb-4 text-center"
style={{ color: config.colors.secondary }}
>
Equipo B
</h2>
<div className="space-y-2 min-h-[200px]">
{room.teams.B.map((player, index) => (
<div
key={player.socket_id || index}
className="px-4 py-2 rounded flex items-center justify-between"
style={{
backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.secondary}40`,
}}
>
<span style={{ color: config.colors.text }}>{player.name}</span>
{player.name === room.host && (
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.secondary, color: config.colors.bg }}>
Host
</span>
)}
</div>
))}
{room.teams.B.length < 4 && (
<button
onClick={() => changeTeam('B')}
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
style={{
borderColor: config.colors.secondary,
color: config.colors.secondary,
}}
>
+ Unirse al Equipo B
</button>
)}
</div>
</motion.div>
</div>
{/* Start Button */}
{isHost && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<button
onClick={handleStartGame}
disabled={!canStart}
className={`px-8 py-4 rounded-lg text-xl font-bold transition-all ${
canStart ? 'hover:scale-105' : 'opacity-50 cursor-not-allowed'
}`}
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
{canStart ? 'Iniciar Partida' : 'Esperando jugadores...'}
</button>
{!canStart && (
<p className="mt-2 text-sm" style={styles.textSecondary}>
Ambos equipos necesitan al menos un jugador
</p>
)}
</motion.div>
)}
{!isHost && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
style={styles.textSecondary}
>
Esperando a que el host inicie la partida...
</motion.p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useThemeStyles } from '../themes/ThemeProvider'
import type { ReplayData, GameEvent } from '../types'
export default function Replay() {
const { sessionId } = useParams<{ sessionId: string }>()
const { config, styles } = useThemeStyles()
const [replayData, setReplayData] = useState<ReplayData | null>(null)
const [currentEventIndex, setCurrentEventIndex] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const [loading, setLoading] = useState(true)
// Fetch replay data
useEffect(() => {
const fetchReplay = async () => {
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`)
if (response.ok) {
const data = await response.json()
setReplayData(data)
}
} catch (error) {
console.error('Failed to fetch replay:', error)
} finally {
setLoading(false)
}
}
fetchReplay()
}, [sessionId])
// Playback logic
useEffect(() => {
if (!isPlaying || !replayData) return
const interval = setInterval(() => {
setCurrentEventIndex((prev) => {
if (prev >= replayData.events.length - 1) {
setIsPlaying(false)
return prev
}
return prev + 1
})
}, 1000 / speed)
return () => clearInterval(interval)
}, [isPlaying, speed, replayData])
const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || []
const currentScores = currentEvents.reduce(
(acc, event) => {
if (event.was_correct && event.points_earned) {
acc[event.team] += event.points_earned
} else if (!event.was_correct && event.was_steal && event.points_earned) {
acc[event.team] -= Math.abs(event.points_earned)
}
return acc
},
{ A: 0, B: 0 }
)
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando replay...</p>
</div>
)
}
if (!replayData) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No se encontró el replay</p>
<Link
to="/"
className="px-6 py-2 rounded-lg"
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
>
Volver al inicio
</Link>
</div>
)
}
return (
<div className="min-h-screen p-4" style={styles.bgPrimary}>
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<Link to="/" style={{ color: config.colors.primary }}>
Volver
</Link>
<h1
className="text-2xl font-bold"
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
Replay: {replayData.session.room_code}
</h1>
<div style={styles.textSecondary}>
{new Date(replayData.session.created_at).toLocaleDateString()}
</div>
</div>
{/* Scores */}
<div className="flex justify-center gap-8 mb-8">
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
{currentScores.A}
</div>
</div>
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
{currentScores.B}
</div>
</div>
</div>
{/* Playback Controls */}
<div
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg"
style={{ backgroundColor: config.colors.bg, border: `1px solid ${config.colors.primary}` }}
>
<button
onClick={() => setCurrentEventIndex(0)}
className="p-2 rounded"
style={{ color: config.colors.text }}
>
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="px-6 py-2 rounded-lg font-bold"
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
>
{isPlaying ? '⏸️ Pausar' : '▶️ Reproducir'}
</button>
<button
onClick={() => setCurrentEventIndex(replayData.events.length - 1)}
className="p-2 rounded"
style={{ color: config.colors.text }}
>
</button>
<div className="ml-4 flex items-center gap-2">
<span style={styles.textSecondary}>Velocidad:</span>
{[1, 2, 4].map((s) => (
<button
key={s}
onClick={() => setSpeed(s)}
className={`px-3 py-1 rounded ${speed === s ? 'font-bold' : 'opacity-50'}`}
style={{
backgroundColor: speed === s ? config.colors.primary : 'transparent',
color: speed === s ? config.colors.bg : config.colors.text,
}}
>
{s}x
</button>
))}
</div>
</div>
{/* Timeline */}
<div className="mb-4">
<input
type="range"
min={0}
max={replayData.events.length - 1}
value={currentEventIndex}
onChange={(e) => setCurrentEventIndex(Number(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-sm" style={styles.textSecondary}>
<span>Evento {currentEventIndex + 1}</span>
<span>de {replayData.events.length}</span>
</div>
</div>
{/* Events List */}
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${config.colors.primary}` }}
>
<div className="max-h-96 overflow-y-auto">
{replayData.events.map((event, index) => (
<motion.div
key={event.id}
initial={{ opacity: 0 }}
animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }}
className={`p-3 border-b ${index === currentEventIndex ? 'ring-2 ring-inset' : ''}`}
style={{
borderColor: config.colors.primary + '30',
backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50',
ringColor: config.colors.accent,
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="px-2 py-1 rounded text-xs font-bold"
style={{
backgroundColor: event.team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg,
}}
>
{event.team}
</span>
<span style={{ color: config.colors.text }}>{event.player_name}</span>
</div>
<div className="flex items-center gap-2">
{event.was_correct ? (
<span className="text-green-500"> +{event.points_earned}</span>
) : (
<span className="text-red-500"> {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span>
)}
</div>
</div>
{event.answer_given && (
<div className="mt-1 text-sm" style={styles.textSecondary}>
Respuesta: "{event.answer_given}"
</div>
)}
</motion.div>
))}
</div>
</div>
{/* Final Scores */}
<div className="mt-8 text-center">
<div className="text-sm mb-2" style={styles.textSecondary}>Resultado Final</div>
<div className="text-2xl font-bold">
<span style={{ color: config.colors.primary }}>{replayData.session.team_a_score}</span>
<span style={styles.textSecondary}> - </span>
<span style={{ color: config.colors.secondary }}>{replayData.session.team_b_score}</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useEffect } from 'react'
import { useParams, useNavigate, Link } 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 { config, styles } = useThemeStyles()
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
// Play victory/defeat sound
useEffect(() => {
if (won) {
play('victory')
} else if (!tied) {
play('defeat')
}
}, [won, tied, play])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No hay resultados disponibles</p>
</div>
)
}
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('/')
}
return (
<div className="min-h-screen p-4 flex flex-col items-center justify-center" style={styles.bgPrimary}>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center max-w-2xl w-full"
>
{/* Result Header */}
<motion.div
initial={{ y: -50 }}
animate={{ y: 0 }}
transition={{ type: 'spring', bounce: 0.5 }}
className="mb-8"
>
{tied ? (
<h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{ color: config.colors.text, fontFamily: config.fonts.heading }}
>
¡EMPATE!
</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>
{/* Scores */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex justify-center gap-8 mb-8"
>
<div
className={`p-6 rounded-lg text-center ${winnerTeam === 'A' ? 'ring-4' : ''}`}
style={{
backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`,
ringColor: config.colors.primary,
}}
>
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div>
<div className="text-5xl font-bold" style={{ color: config.colors.primary }}>
{room.scores.A}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div>
<div className="flex items-center">
<span className="text-3xl" style={styles.textSecondary}>VS</span>
</div>
<div
className={`p-6 rounded-lg text-center ${winnerTeam === 'B' ? 'ring-4' : ''}`}
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
ringColor: config.colors.secondary,
}}
>
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div>
<div className="text-5xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
<div className="mt-2 text-sm" style={styles.textSecondary}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
{/* New Achievements */}
{newAchievements.length > 0 && (
<motion.div
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 }}>
¡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={{
backgroundColor: config.colors.accent + '20',
border: `2px solid ${config.colors.accent}`,
}}
>
<div className="text-3xl mb-2">{achievement.icon}</div>
<div className="font-bold" style={{ color: config.colors.text }}>
{achievement.name}
</div>
<div className="text-xs" style={styles.textSecondary}>
{achievement.description}
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Actions */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<button
onClick={handlePlayAgain}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{
backgroundColor: config.colors.primary,
color: config.colors.bg,
}}
>
Jugar de Nuevo
</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>
</div>
)
}

View File

@@ -0,0 +1,116 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
class ApiService {
private baseUrl: string
constructor() {
this.baseUrl = API_URL
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Game endpoints
async getCategories() {
return this.request<
Array<{ id: number; name: string; icon: string; color: string }>
>('/api/game/categories')
}
async getTodayQuestions() {
return this.request<{
date: string
categories: Record<
string,
{
name: string
questions: Array<{
difficulty: number
id: number
points: number
}>
}
>
}>('/api/game/today-questions')
}
async getQuestion(questionId: number) {
return this.request<{
id: number
question_text: string
difficulty: number
points: number
time_seconds: number
category_id: number
}>(`/api/game/question/${questionId}`)
}
async getAchievements() {
return this.request<
Array<{
id: number
name: string
description: string
icon: string
}>
>('/api/game/achievements')
}
// Replay endpoints
async getReplay(sessionId: string) {
return this.request<{
session: {
id: number
room_code: string
team_a_score: number
team_b_score: number
status: string
created_at: string
finished_at: string
}
events: Array<{
id: number
event_type: string
player_name: string
team: 'A' | 'B'
question_id: number
answer_given: string
was_correct: boolean
was_steal: boolean
points_earned: number
timestamp: string
}>
}>(`/api/replay/code/${sessionId}`)
}
async listReplays(limit = 20, offset = 0) {
return this.request<
Array<{
id: number
room_code: string
team_a_score: number
team_b_score: number
finished_at: string
}>
>(`/api/replay?limit=${limit}&offset=${offset}`)
}
}
export const api = new ApiService()

View File

@@ -0,0 +1,69 @@
import { io, Socket } from 'socket.io-client'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
class SocketService {
private socket: Socket | null = null
private listeners: Map<string, Set<(data: unknown) => void>> = new Map()
connect(): Socket {
if (!this.socket) {
this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
})
this.socket.on('connect', () => {
console.log('Socket connected:', this.socket?.id)
})
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason)
})
this.socket.on('error', (error) => {
console.error('Socket error:', error)
})
}
return this.socket
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect()
this.socket = null
}
}
on(event: string, callback: (data: unknown) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(callback)
this.socket?.on(event, callback)
}
off(event: string, callback: (data: unknown) => void): void {
this.listeners.get(event)?.delete(callback)
this.socket?.off(event, callback)
}
emit(event: string, data?: unknown): void {
this.socket?.emit(event, data)
}
get connected(): boolean {
return this.socket?.connected ?? false
}
get id(): string | undefined {
return this.socket?.id
}
}
export const socketService = new SocketService()

View File

@@ -0,0 +1,104 @@
import { create } from 'zustand'
import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types'
interface GameState {
// Room state
room: GameRoom | null
setRoom: (room: GameRoom | null) => void
// Player info
playerName: string
setPlayerName: (name: string) => void
// Current question
currentQuestion: Question | null
setCurrentQuestion: (question: Question | null) => void
// Timer
timerEnd: Date | null
setTimerEnd: (end: Date | null) => void
// Chat messages
messages: ChatMessage[]
addMessage: (message: ChatMessage) => void
clearMessages: () => void
// Achievements
achievements: Achievement[]
setAchievements: (achievements: Achievement[]) => void
unlockAchievement: (id: number) => void
// Game stats (for achievements tracking)
stats: {
correctStreak: number
stealsAttempted: number
stealsSuccessful: number
categoryCorrect: Record<number, number>
fastAnswers: number
maxDeficit: number
}
updateStats: (updates: Partial<GameState['stats']>) => void
resetStats: () => void
// UI state
showStealPrompt: boolean
setShowStealPrompt: (show: boolean) => void
// Reset
resetGame: () => void
}
const initialStats = {
correctStreak: 0,
stealsAttempted: 0,
stealsSuccessful: 0,
categoryCorrect: {},
fastAnswers: 0,
maxDeficit: 0,
}
export const useGameStore = create<GameState>((set) => ({
room: null,
setRoom: (room) => set({ room }),
playerName: '',
setPlayerName: (playerName) => set({ playerName }),
currentQuestion: null,
setCurrentQuestion: (currentQuestion) => set({ currentQuestion }),
timerEnd: null,
setTimerEnd: (timerEnd) => set({ timerEnd }),
messages: [],
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message].slice(-100) })),
clearMessages: () => set({ messages: [] }),
achievements: [],
setAchievements: (achievements) => set({ achievements }),
unlockAchievement: (id) =>
set((state) => ({
achievements: state.achievements.map((a) =>
a.id === id ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a
),
})),
stats: initialStats,
updateStats: (updates) =>
set((state) => ({ stats: { ...state.stats, ...updates } })),
resetStats: () => set({ stats: initialStats }),
showStealPrompt: false,
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
resetGame: () =>
set({
room: null,
currentQuestion: null,
timerEnd: null,
messages: [],
stats: initialStats,
showStealPrompt: false,
}),
}))

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ThemeName } from '../types'
type SoundEffect =
| 'correct'
| 'incorrect'
| 'steal'
| 'timer_tick'
| 'timer_urgent'
| 'victory'
| 'defeat'
| 'select'
interface SoundState {
volume: number
muted: boolean
setVolume: (volume: number) => void
setMuted: (muted: boolean) => void
toggleMute: () => void
}
export const useSoundStore = create<SoundState>()(
persist(
(set) => ({
volume: 0.7,
muted: false,
setVolume: (volume) => set({ volume }),
setMuted: (muted) => set({ muted }),
toggleMute: () => set((state) => ({ muted: !state.muted })),
}),
{
name: 'trivia-sound',
}
)
)
// Sound file paths per theme
export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
drrr: {
correct: '/sounds/drrr/correct.mp3',
incorrect: '/sounds/drrr/incorrect.mp3',
steal: '/sounds/drrr/steal.mp3',
timer_tick: '/sounds/drrr/tick.mp3',
timer_urgent: '/sounds/drrr/urgent.mp3',
victory: '/sounds/drrr/victory.mp3',
defeat: '/sounds/drrr/defeat.mp3',
select: '/sounds/drrr/select.mp3',
},
retro: {
correct: '/sounds/retro/correct.mp3',
incorrect: '/sounds/retro/incorrect.mp3',
steal: '/sounds/retro/steal.mp3',
timer_tick: '/sounds/retro/tick.mp3',
timer_urgent: '/sounds/retro/urgent.mp3',
victory: '/sounds/retro/victory.mp3',
defeat: '/sounds/retro/defeat.mp3',
select: '/sounds/retro/select.mp3',
},
minimal: {
correct: '/sounds/minimal/correct.mp3',
incorrect: '/sounds/minimal/incorrect.mp3',
steal: '/sounds/minimal/steal.mp3',
timer_tick: '/sounds/minimal/tick.mp3',
timer_urgent: '/sounds/minimal/urgent.mp3',
victory: '/sounds/minimal/victory.mp3',
defeat: '/sounds/minimal/defeat.mp3',
select: '/sounds/minimal/select.mp3',
},
rgb: {
correct: '/sounds/rgb/correct.mp3',
incorrect: '/sounds/rgb/incorrect.mp3',
steal: '/sounds/rgb/steal.mp3',
timer_tick: '/sounds/rgb/tick.mp3',
timer_urgent: '/sounds/rgb/urgent.mp3',
victory: '/sounds/rgb/victory.mp3',
defeat: '/sounds/rgb/defeat.mp3',
select: '/sounds/rgb/select.mp3',
},
anime: {
correct: '/sounds/anime/correct.mp3',
incorrect: '/sounds/anime/incorrect.mp3',
steal: '/sounds/anime/steal.mp3',
timer_tick: '/sounds/anime/tick.mp3',
timer_urgent: '/sounds/anime/urgent.mp3',
victory: '/sounds/anime/victory.mp3',
defeat: '/sounds/anime/defeat.mp3',
select: '/sounds/anime/select.mp3',
},
}

View File

@@ -0,0 +1,140 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ThemeName, ThemeConfig } from '../types'
export const themes: Record<ThemeName, ThemeConfig> = {
drrr: {
name: 'drrr',
displayName: 'DRRR (Dollars)',
colors: {
bg: '#0a0a0a',
primary: '#FFE135',
secondary: '#00FFFF',
accent: '#FF00FF',
text: '#ffffff',
textMuted: '#888888',
},
fonts: {
heading: 'Bebas Neue, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: true,
scanlines: false,
glitch: true,
sparkles: false,
rgbShift: false,
},
},
retro: {
name: 'retro',
displayName: 'Retro Arcade',
colors: {
bg: '#1a1a2e',
primary: '#9B59B6',
secondary: '#E91E63',
accent: '#00FFFF',
text: '#ffffff',
textMuted: '#aaaaaa',
},
fonts: {
heading: '"Press Start 2P", cursive',
body: '"Press Start 2P", cursive',
},
effects: {
glow: true,
scanlines: true,
glitch: false,
sparkles: false,
rgbShift: false,
},
},
minimal: {
name: 'minimal',
displayName: 'Moderno Minimalista',
colors: {
bg: '#ffffff',
primary: '#3498DB',
secondary: '#2ECC71',
accent: '#E74C3C',
text: '#2c3e50',
textMuted: '#7f8c8d',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: false,
scanlines: false,
glitch: false,
sparkles: false,
rgbShift: false,
},
},
rgb: {
name: 'rgb',
displayName: 'Gaming RGB',
colors: {
bg: '#0D0D0D',
primary: '#FF0080',
secondary: '#00FF80',
accent: '#8000FF',
text: '#ffffff',
textMuted: '#666666',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: true,
scanlines: false,
glitch: false,
sparkles: false,
rgbShift: true,
},
},
anime: {
name: 'anime',
displayName: 'Anime Clásico 90s',
colors: {
bg: '#FFF5F5',
primary: '#FFB6C1',
secondary: '#E6E6FA',
accent: '#FF69B4',
text: '#4a4a4a',
textMuted: '#888888',
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
effects: {
glow: false,
scanlines: false,
glitch: false,
sparkles: true,
rgbShift: false,
},
},
}
interface ThemeState {
currentTheme: ThemeName
setTheme: (theme: ThemeName) => void
getThemeConfig: () => ThemeConfig
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
currentTheme: 'drrr',
setTheme: (currentTheme) => set({ currentTheme }),
getThemeConfig: () => themes[get().currentTheme],
}),
{
name: 'trivia-theme',
}
)
)

View File

@@ -0,0 +1,65 @@
import { useEffect, type ReactNode } from 'react'
import { useThemeStore, themes } from '../stores/themeStore'
interface ThemeProviderProps {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const { currentTheme } = useThemeStore()
const theme = themes[currentTheme]
useEffect(() => {
// Apply CSS variables
const root = document.documentElement
root.style.setProperty('--color-bg', theme.colors.bg)
root.style.setProperty('--color-primary', theme.colors.primary)
root.style.setProperty('--color-secondary', theme.colors.secondary)
root.style.setProperty('--color-accent', theme.colors.accent)
root.style.setProperty('--color-text', theme.colors.text)
root.style.setProperty('--color-text-muted', theme.colors.textMuted)
root.style.setProperty('--font-heading', theme.fonts.heading)
root.style.setProperty('--font-body', theme.fonts.body)
// Apply body background
document.body.style.backgroundColor = theme.colors.bg
document.body.style.color = theme.colors.text
// Add theme class to body
document.body.className = `theme-${currentTheme}`
}, [currentTheme, theme])
return <>{children}</>
}
// Theme-aware component wrapper
export function useThemeStyles() {
const { currentTheme, getThemeConfig } = useThemeStore()
const config = getThemeConfig()
return {
theme: currentTheme,
config,
styles: {
// Background styles
bgPrimary: { backgroundColor: config.colors.bg },
bgSecondary: { backgroundColor: config.colors.primary + '20' },
// Text styles
textPrimary: { color: config.colors.text },
textSecondary: { color: config.colors.textMuted },
textAccent: { color: config.colors.primary },
// Border styles
borderPrimary: { borderColor: config.colors.primary },
borderSecondary: { borderColor: config.colors.secondary },
// Effect classes
glowEffect: config.effects.glow ? 'text-shadow-neon' : '',
scanlineEffect: config.effects.scanlines ? 'crt-scanlines' : '',
glitchEffect: config.effects.glitch ? 'glitch-text' : '',
sparkleEffect: config.effects.sparkles ? 'sparkle' : '',
rgbEffect: config.effects.rgbShift ? 'animate-rgb-shift' : '',
},
}
}

135
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,135 @@
// Game Types
export interface Player {
name: string
team: 'A' | 'B'
position: number
socket_id?: string
}
export interface TeamState {
players: Player[]
score: number
currentPlayerIndex: number
}
export interface Question {
id: number
category_id: number
question_text?: string
difficulty: number
points: number
time_seconds: number
answered?: boolean
selected?: boolean
}
export interface Category {
id: number
name: string
icon: string
color: string
}
export interface GameRoom {
code: string
status: 'waiting' | 'playing' | 'finished'
host: string
teams: {
A: Player[]
B: Player[]
}
current_team: 'A' | 'B' | null
current_player_index: { A: number; B: number }
current_question: number | null
can_steal: boolean
scores: { A: number; B: number }
board: Record<string, Question[]>
}
export interface ChatMessage {
player_name: string
team: 'A' | 'B'
message: string
timestamp: string
}
export interface EmojiReaction {
player_name: string
team: 'A' | 'B'
emoji: string
}
export interface Achievement {
id: number
name: string
description: string
icon: string
unlocked?: boolean
unlockedAt?: string
}
export interface AnswerResult {
player_name: string
team: 'A' | 'B'
answer: string
valid: boolean
reason: string
points_earned: number
was_steal: boolean
room: GameRoom
}
export interface GameEvent {
id: number
event_type: string
player_name: string
team: 'A' | 'B'
question_id: number
answer_given: string
was_correct: boolean
was_steal: boolean
points_earned: number
timestamp: string
}
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
}
events: GameEvent[]
}
// Theme Types
export type ThemeName = 'drrr' | 'retro' | 'minimal' | 'rgb' | 'anime'
export interface ThemeConfig {
name: ThemeName
displayName: string
colors: {
bg: string
primary: string
secondary: string
accent: string
text: string
textMuted: string
}
fonts: {
heading: string
body: string
}
effects: {
glow: boolean
scanlines: boolean
glitch: boolean
sparkles: boolean
rgbShift: boolean
}
}

View File

@@ -0,0 +1,78 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// DRRR Theme
drrr: {
bg: '#0a0a0a',
primary: '#FFE135',
secondary: '#00FFFF',
accent: '#FF00FF',
},
// Retro Arcade Theme
retro: {
bg: '#1a1a2e',
primary: '#9B59B6',
secondary: '#E91E63',
accent: '#00FFFF',
},
// Gaming RGB Theme
rgb: {
bg: '#0D0D0D',
primary: '#FF0000',
secondary: '#00FF00',
accent: '#0000FF',
},
// Anime 90s Theme
anime: {
bg: '#FFF5F5',
primary: '#FFB6C1',
secondary: '#E6E6FA',
accent: '#FF69B4',
},
},
fontFamily: {
'pixel': ['"Press Start 2P"', 'cursive'],
'urban': ['Bebas Neue', 'sans-serif'],
},
animation: {
'glitch': 'glitch 0.3s infinite',
'pulse-neon': 'pulse-neon 2s infinite',
'scanline': 'scanline 8s linear infinite',
'sparkle': 'sparkle 1.5s infinite',
'rgb-shift': 'rgb-shift 3s infinite',
},
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)' },
},
'pulse-neon': {
'0%, 100%': { boxShadow: '0 0 5px currentColor, 0 0 10px currentColor' },
'50%': { boxShadow: '0 0 20px currentColor, 0 0 30px currentColor' },
},
scanline: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
sparkle: {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.5, transform: 'scale(0.8)' },
},
'rgb-shift': {
'0%': { filter: 'hue-rotate(0deg)' },
'100%': { filter: 'hue-rotate(360deg)' },
},
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

14
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true
},
build: {
outDir: 'dist',
sourcemap: true
}
})