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:
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal 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
18
frontend/index.html
Normal 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
39
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
22
frontend/src/App.tsx
Normal file
22
frontend/src/App.tsx
Normal 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
|
||||
165
frontend/src/hooks/useAchievements.ts
Normal file
165
frontend/src/hooks/useAchievements.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
175
frontend/src/hooks/useSocket.ts
Normal file
175
frontend/src/hooks/useSocket.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
84
frontend/src/hooks/useSound.ts
Normal file
84
frontend/src/hooks/useSound.ts
Normal 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
120
frontend/src/index.css
Normal 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
16
frontend/src/main.tsx
Normal 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
335
frontend/src/pages/Game.tsx
Normal 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
213
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
frontend/src/pages/Lobby.tsx
Normal file
227
frontend/src/pages/Lobby.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
frontend/src/pages/Replay.tsx
Normal file
247
frontend/src/pages/Replay.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||
import type { ReplayData, GameEvent } from '../types'
|
||||
|
||||
export default function Replay() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
const { config, styles } = useThemeStyles()
|
||||
|
||||
const [replayData, setReplayData] = useState<ReplayData | null>(null)
|
||||
const [currentEventIndex, setCurrentEventIndex] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [speed, setSpeed] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch replay data
|
||||
useEffect(() => {
|
||||
const fetchReplay = async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setReplayData(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch replay:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchReplay()
|
||||
}, [sessionId])
|
||||
|
||||
// Playback logic
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !replayData) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentEventIndex((prev) => {
|
||||
if (prev >= replayData.events.length - 1) {
|
||||
setIsPlaying(false)
|
||||
return prev
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
}, 1000 / speed)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPlaying, speed, replayData])
|
||||
|
||||
const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || []
|
||||
const currentScores = currentEvents.reduce(
|
||||
(acc, event) => {
|
||||
if (event.was_correct && event.points_earned) {
|
||||
acc[event.team] += event.points_earned
|
||||
} else if (!event.was_correct && event.was_steal && event.points_earned) {
|
||||
acc[event.team] -= Math.abs(event.points_earned)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ A: 0, B: 0 }
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||
<p style={styles.textSecondary}>Cargando replay...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!replayData) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4" style={styles.bgPrimary}>
|
||||
<p style={styles.textSecondary}>No se encontró el replay</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-6 py-2 rounded-lg"
|
||||
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
||||
>
|
||||
Volver al inicio
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Link to="/" style={{ color: config.colors.primary }}>
|
||||
← Volver
|
||||
</Link>
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||
>
|
||||
Replay: {replayData.session.room_code}
|
||||
</h1>
|
||||
<div style={styles.textSecondary}>
|
||||
{new Date(replayData.session.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scores */}
|
||||
<div className="flex justify-center gap-8 mb-8">
|
||||
<div className="text-center">
|
||||
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
|
||||
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
|
||||
{currentScores.A}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
|
||||
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
|
||||
{currentScores.B}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div
|
||||
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg"
|
||||
style={{ backgroundColor: config.colors.bg, border: `1px solid ${config.colors.primary}` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCurrentEventIndex(0)}
|
||||
className="p-2 rounded"
|
||||
style={{ color: config.colors.text }}
|
||||
>
|
||||
⏮️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="px-6 py-2 rounded-lg font-bold"
|
||||
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
||||
>
|
||||
{isPlaying ? '⏸️ Pausar' : '▶️ Reproducir'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentEventIndex(replayData.events.length - 1)}
|
||||
className="p-2 rounded"
|
||||
style={{ color: config.colors.text }}
|
||||
>
|
||||
⏭️
|
||||
</button>
|
||||
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<span style={styles.textSecondary}>Velocidad:</span>
|
||||
{[1, 2, 4].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSpeed(s)}
|
||||
className={`px-3 py-1 rounded ${speed === s ? 'font-bold' : 'opacity-50'}`}
|
||||
style={{
|
||||
backgroundColor: speed === s ? config.colors.primary : 'transparent',
|
||||
color: speed === s ? config.colors.bg : config.colors.text,
|
||||
}}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={replayData.events.length - 1}
|
||||
value={currentEventIndex}
|
||||
onChange={(e) => setCurrentEventIndex(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-sm" style={styles.textSecondary}>
|
||||
<span>Evento {currentEventIndex + 1}</span>
|
||||
<span>de {replayData.events.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: `1px solid ${config.colors.primary}` }}
|
||||
>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{replayData.events.map((event, index) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }}
|
||||
className={`p-3 border-b ${index === currentEventIndex ? 'ring-2 ring-inset' : ''}`}
|
||||
style={{
|
||||
borderColor: config.colors.primary + '30',
|
||||
backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50',
|
||||
ringColor: config.colors.accent,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: event.team === 'A' ? config.colors.primary : config.colors.secondary,
|
||||
color: config.colors.bg,
|
||||
}}
|
||||
>
|
||||
{event.team}
|
||||
</span>
|
||||
<span style={{ color: config.colors.text }}>{event.player_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{event.was_correct ? (
|
||||
<span className="text-green-500">✓ +{event.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-red-500">✗ {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{event.answer_given && (
|
||||
<div className="mt-1 text-sm" style={styles.textSecondary}>
|
||||
Respuesta: "{event.answer_given}"
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Scores */}
|
||||
<div className="mt-8 text-center">
|
||||
<div className="text-sm mb-2" style={styles.textSecondary}>Resultado Final</div>
|
||||
<div className="text-2xl font-bold">
|
||||
<span style={{ color: config.colors.primary }}>{replayData.session.team_a_score}</span>
|
||||
<span style={styles.textSecondary}> - </span>
|
||||
<span style={{ color: config.colors.secondary }}>{replayData.session.team_b_score}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
frontend/src/pages/Results.tsx
Normal file
210
frontend/src/pages/Results.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/services/api.ts
Normal file
116
frontend/src/services/api.ts
Normal 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()
|
||||
69
frontend/src/services/socket.ts
Normal file
69
frontend/src/services/socket.ts
Normal 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()
|
||||
104
frontend/src/stores/gameStore.ts
Normal file
104
frontend/src/stores/gameStore.ts
Normal 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,
|
||||
}),
|
||||
}))
|
||||
90
frontend/src/stores/soundStore.ts
Normal file
90
frontend/src/stores/soundStore.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
140
frontend/src/stores/themeStore.ts
Normal file
140
frontend/src/stores/themeStore.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
)
|
||||
65
frontend/src/themes/ThemeProvider.tsx
Normal file
65
frontend/src/themes/ThemeProvider.tsx
Normal 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
135
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
78
frontend/tailwind.config.js
Normal file
78
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
14
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user