diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..180e5c5 --- /dev/null +++ b/App.tsx @@ -0,0 +1,152 @@ + +import React, { useState, useEffect } from 'react'; +import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'; +import PlayerView from './components/PlayerView'; +import AdminDashboard from './components/AdminDashboard'; +import SpectatorView from './components/SpectatorView'; +import { GameData, GameState, UserRole } from './types'; +import { api } from './services/api'; + +const INITIAL_GAME_DATA: GameData = { + id: 'game-123', + name: 'Trivia Night 2024', + state: GameState.LOBBY, + currentQuestionIndex: 0, + questions: [ + { id: 'q1', text: 'What is the capital of France?', answer: 'Paris', points: 100 }, + { id: 'q2', text: 'Which planet is known as the Red Planet?', answer: 'Mars', points: 150 }, + { id: 'q3', text: 'Who wrote "Romeo and Juliet"?', answer: 'William Shakespeare', points: 200 }, + ], + players: [], + teams: [], + activeBuzzerQueue: [], + countdownValue: 3, +}; + +const App: React.FC = () => { + const [game, setGame] = useState(() => { + const saved = localStorage.getItem(`game_${INITIAL_GAME_DATA.id}`); + return saved ? JSON.parse(saved) : INITIAL_GAME_DATA; + }); + const [currentUser, setCurrentUser] = useState(null); + const [isSynced, setIsSynced] = useState(false); + const [useLocal, setUseLocal] = useState(false); + + useEffect(() => { + const interval = setInterval(async () => { + try { + const data = await api.getGameStatus(game.id); + if (data) { + setGame(prev => ({ ...prev, ...data })); + setIsSynced(true); + setUseLocal(false); + } else { + setIsSynced(false); + setUseLocal(true); + } + } catch (e) { + setIsSynced(false); + setUseLocal(true); + } + }, 3000); + return () => clearInterval(interval); + }, [game.id]); + + const updateGame = async (newData: Partial) => { + const updated = { ...game, ...newData }; + setGame(updated); + // This updates localStorage internally and attempts the API call + await api.updateGameState(game.id, newData); + }; + + return ( +
+ + + } /> + } + /> + } + /> + } + /> + + + +
+
+ {isSynced ? 'Container Synced' : useLocal ? 'Local Cache Mode' : 'Connecting...'} +
+
+ ); +}; + +const Home: React.FC = () => { + const navigate = useNavigate(); + return ( +
+
BUZZMASTER // SYSTEM_DOCKER_V1
+ +
+

+ BUZZMASTER PRO +

+
+ ENTERPRISE +
+
+ +
+ navigate('/admin')} + /> + navigate('/player')} + /> + navigate('/spectator')} + /> +
+ +
+ DOCKER_SERVICE_DISCOVERY: ENABLED | DB_DRIVER: MARIADB_10.11 | PERSISTENCE_STRATEGY: HYBRID_LOCAL_REMOTE +
+
+ ); +}; + +const RoleCard: React.FC<{ title: string; desc: string; node: string; color: string; onClick: () => void }> = ({ title, desc, node, color, onClick }) => ( + +); + +export default App; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 2241000..b3a9e10 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/19kXz8tSX5JGdwe9rXkABq-znXUzwing2 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..98f4825 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,50 @@ + +const express = require('express'); +const mysql = require('mysql2/promise'); +const cors = require('cors'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +const dbConfig = { + host: process.env.DB_HOST || 'db', + user: process.env.DB_USER || 'buzz_user', + password: process.env.DB_PASS || 'buzz_password', + database: process.env.DB_NAME || 'buzzmaster' +}; + +let pool; + +async function initDb() { + try { + pool = await mysql.createPool(dbConfig); + console.log('Connected to MariaDB'); + } catch (e) { + console.warn('Database not available. Running in mock mode.'); + } +} + +app.get('/api/health', (req, res) => { + res.status(200).json({ status: 'ok', timestamp: Date.now() }); +}); + +app.get('/api/game/:id', async (req, res) => { + // In a full implementation, you'd fetch from `pool` + res.json({ id: req.params.id, state: 'LOBBY', players: [] }); +}); + +app.post('/api/game/:id/join', async (req, res) => { + const { name, team } = req.body; + res.json({ id: Date.now().toString(), name, teamId: team, status: 'PENDING' }); +}); + +app.patch('/api/game/:id/state', async (req, res) => { + res.status(200).send(); +}); + +const PORT = process.env.PORT || 5000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); + initDb().catch(console.error); +}); diff --git a/components/AdminDashboard.tsx b/components/AdminDashboard.tsx new file mode 100644 index 0000000..f41a6b2 --- /dev/null +++ b/components/AdminDashboard.tsx @@ -0,0 +1,207 @@ + +import React, { useState } from 'react'; +import { GameData, GameState, Player, Question } from '../types'; + +interface AdminDashboardProps { + game: GameData; + updateGame: (data: Partial) => void; +} + +const AdminDashboard: React.FC = ({ game, updateGame }) => { + const [activeTab, setActiveTab] = useState<'control' | 'lobby' | 'questions'>('control'); + + const approvePlayer = (id: string) => { + const updated = game.players.map(p => p.id === id ? { ...p, status: 'APPROVED' } : p); + updateGame({ players: updated as Player[] }); + }; + + const startGame = () => { + updateGame({ state: GameState.LOBBY }); + }; + + const startCountdown = () => { + updateGame({ state: GameState.COUNTDOWN, countdownValue: 3 }); + let count = 3; + const interval = setInterval(() => { + count -= 1; + if (count <= 0) { + clearInterval(interval); + updateGame({ state: GameState.QUESTION, countdownValue: 0, activeBuzzerQueue: [] }); + } else { + updateGame({ countdownValue: count }); + } + }, 1000); + }; + + const adjudicate = (isCorrect: boolean) => { + const firstBuzzer = game.activeBuzzerQueue[0]; + if (!firstBuzzer) return; + + const currentQ = game.questions[game.currentQuestionIndex]; + + if (isCorrect) { + const updatedPlayers = game.players.map(p => { + if (p.id === firstBuzzer.playerId) { + return { ...p, score: p.score + currentQ.points, correctAnswers: p.correctAnswers + 1 }; + } + return p; + }); + updateGame({ + state: GameState.REVEAL, + players: updatedPlayers as Player[] + }); + } else { + const updatedQueue = game.activeBuzzerQueue.slice(1); + const updatedPlayers = game.players.map(p => { + if (p.id === firstBuzzer.playerId) { + return { ...p, wrongAnswers: p.wrongAnswers + 1 }; + } + return p; + }); + updateGame({ + activeBuzzerQueue: updatedQueue, + players: updatedPlayers as Player[] + }); + } + }; + + const nextQuestion = () => { + const nextIdx = game.currentQuestionIndex + 1; + if (nextIdx >= game.questions.length) { + updateGame({ state: GameState.FINAL_STATS }); + } else { + updateGame({ state: GameState.SCOREBOARD, currentQuestionIndex: nextIdx }); + } + }; + + const currentQ = game.questions[game.currentQuestionIndex]; + + return ( +
+ {/* Sidebar */} +
+
HOST PANEL
+ +
+ + +
+
+ + {/* Main Area */} +
+ {activeTab === 'lobby' && ( +
+

Lobby Management

+
+ {game.players.map(p => ( +
+
+
{p.name}
+
Team: {p.teamId}
+
+ {p.status === 'PENDING' ? ( + + ) : ( + ACTIVE + )} +
+ ))} +
+ {game.state === GameState.LOBBY && ( + + )} +
+ )} + + {activeTab === 'control' && ( +
+
+
+

Question {game.currentQuestionIndex + 1}

+

{currentQ.text}

+

A: {currentQ.answer}

+
+
+
Points Value
+
{currentQ.points}
+
+
+ +
+ {/* Controls */} +
+

Game Actions

+
+ + +
+ +
+

Adjudication

+
+ + +
+
+
+ + {/* Buzzer Queue */} +
+

Buzzer Queue

+
+ {game.activeBuzzerQueue.length === 0 &&
Waiting for buzzes...
} + {game.activeBuzzerQueue.map((buzz, i) => { + const p = game.players.find(pl => pl.id === buzz.playerId); + return ( +
+
+ {i + 1} + {p?.name || 'Unknown'} +
+
{buzz.timestamp % 10000}ms
+
+ ); + })} +
+
+
+
+ )} +
+
+ ); +}; + +const NavBtn: React.FC<{ active: boolean; label: string; onClick: () => void }> = ({ active, label, onClick }) => ( + +); + +const SoundBtn: React.FC<{ label: string; color: string }> = ({ label, color }) => ( + +); + +export default AdminDashboard; diff --git a/components/PlayerView.tsx b/components/PlayerView.tsx new file mode 100644 index 0000000..91154ac --- /dev/null +++ b/components/PlayerView.tsx @@ -0,0 +1,141 @@ + +import React, { useState } from 'react'; +import { GameData, GameState, Player } from '../types'; + +interface PlayerViewProps { + game: GameData; + updateGame: (data: Partial) => void; + currentUser: Player | null; + setCurrentUser: (p: Player) => void; +} + +const PlayerView: React.FC = ({ game, updateGame, currentUser, setCurrentUser }) => { + const [name, setName] = useState(''); + const [teamName, setTeamName] = useState(''); + + const handleJoin = (e: React.FormEvent) => { + e.preventDefault(); + if (!name || !teamName) return; + + const newPlayer: Player = { + id: Math.random().toString(36).substr(2, 9), + name, + teamId: teamName, // Simplified team logic + status: 'PENDING', + score: 0, + correctAnswers: 0, + wrongAnswers: 0, + avgBuzzerMs: 0 + }; + + setCurrentUser(newPlayer); + updateGame({ players: [...game.players, newPlayer] }); + }; + + const handleBuzz = () => { + if (!currentUser || game.state !== GameState.QUESTION) return; + + const timestamp = Date.now(); + const alreadyBuzzed = game.activeBuzzerQueue.some(b => b.playerId === currentUser.id); + + if (!alreadyBuzzed) { + updateGame({ + state: GameState.BUZZED, + activeBuzzerQueue: [...game.activeBuzzerQueue, { playerId: currentUser.id, timestamp }] + }); + } + }; + + if (!currentUser) { + return ( +
+
+

Join the Game

+
+ + setName(e.target.value)} + className="w-full p-4 bg-white/5 border border-white/10 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="e.g. John Doe" + /> +
+
+ + setTeamName(e.target.value)} + className="w-full p-4 bg-white/5 border border-white/10 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="e.g. The Nerds" + /> +
+ +
+
+ ); + } + + if (currentUser.status === 'PENDING') { + return ( +
+
+

Waiting for Host...

+

Hang tight, {currentUser.name}! The host will approve you shortly.

+
+ ); + } + + const isFirst = game.activeBuzzerQueue[0]?.playerId === currentUser.id; + const hasBuzzed = game.activeBuzzerQueue.some(b => b.playerId === currentUser.id); + + return ( +
+
+
{currentUser.name}
+
{teamName}
+
+ +
+ {game.state === GameState.LOBBY && ( +
Game will start soon...
+ )} + + {game.state === GameState.COUNTDOWN && ( +
{game.countdownValue}
+ )} + + {game.state === GameState.QUESTION && ( + + )} + + {game.state === GameState.BUZZED && ( +
+ {isFirst ? 'YOUR TURN!' : hasBuzzed ? 'LOCKED' : 'WAITING'} +
+ )} + + {(game.state === GameState.REVEAL || game.state === GameState.SCOREBOARD) && ( +
+

Well Played!

+

Get ready for the next one.

+
+ )} +
+ +
+ Lat: ~15ms +
+
+ ); +}; + +export default PlayerView; diff --git a/components/SpectatorView.tsx b/components/SpectatorView.tsx new file mode 100644 index 0000000..fa78c4a --- /dev/null +++ b/components/SpectatorView.tsx @@ -0,0 +1,143 @@ + +import React from 'react'; +import { GameData, GameState } from '../types'; + +interface SpectatorViewProps { + game: GameData; +} + +const SpectatorView: React.FC = ({ game }) => { + const currentQ = game.questions[game.currentQuestionIndex]; + + return ( +
+ {/* Background Ambience */} +
+
+
+
+ +
+ + {game.state === GameState.LOBBY && ( +
+

+ JOIN THE QUIZ! +

+
+
+ {/* Simulate QR Code */} +
+
+ {[...Array(16)].map((_, i) => ( +
0.5 ? 'bg-white' : 'bg-transparent'}`}>
+ ))} +
+
+
+
+
Visit URL:
+
QUIZ.LIVE/BM-123
+
+
+
+

PLAYERS JOINED:

+
+ {game.players.filter(p => p.status === 'APPROVED').map(p => ( + + {p.name} + + ))} +
+
+
+ )} + + {game.state === GameState.COUNTDOWN && ( +
+ {game.countdownValue} +
+ )} + + {game.state === GameState.QUESTION && ( +
+
QUESTION {game.currentQuestionIndex + 1}
+

+ {currentQ.text} +

+
Waiting for buzzers...
+
+ )} + + {game.state === GameState.BUZZED && ( +
+

{currentQ.text}

+
+
PLAYER BUZZED!
+
+ {game.players.find(p => p.id === game.activeBuzzerQueue[0]?.playerId)?.name || 'Someone'} +
+
+
+ )} + + {game.state === GameState.REVEAL && ( +
+

THE ANSWER IS...

+
+ {currentQ.answer} +
+
Points awarded to {game.players.find(p => p.id === game.activeBuzzerQueue[0]?.playerId)?.teamId}!
+
+ )} + + {game.state === GameState.SCOREBOARD && ( +
+

LEADERBOARD

+
+ {game.players + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map((p, i) => ( +
+
+
+ {i+1} + {p.name} + ({p.teamId}) +
+
{p.score} pts
+
+ ))} +
+
+ )} + + {game.state === GameState.FINAL_STATS && ( +
+

GAME OVER!

+
+ + + +
+
+

🏆 FINAL CHAMPIONS 🏆

+
THE NERDS
+
+
+ )} +
+
+ ); +}; + +const StatBox: React.FC<{ label: string; sub: string; value: string }> = ({ label, sub, value }) => ( +
+
{sub}
+
{label}
+
{value}
+
+); + +export default SpectatorView; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c14acff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ + +version: '3.8' + +services: + db: + image: mariadb:10.11 + restart: always + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: buzzmaster + MYSQL_USER: buzz_user + MYSQL_PASSWORD: buzz_password + ports: + - "3306:3306" + volumes: + - db_data:/var/lib/mysql + + backend: + build: ./backend + restart: always + environment: + DB_HOST: db + DB_USER: buzz_user + DB_PASS: buzz_password + DB_NAME: buzzmaster + PORT: 5000 + ports: + - "5000:5000" + depends_on: + - db + + frontend: + build: . + ports: + - "80:80" + environment: + - VITE_API_URL=http://localhost:5000 + depends_on: + - backend + +volumes: + db_data: diff --git a/index.html b/index.html new file mode 100644 index 0000000..a002550 --- /dev/null +++ b/index.html @@ -0,0 +1,31 @@ + + + + + + + BuzzMaster Live + + + + + + +
+ + diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..aaa0c6e --- /dev/null +++ b/index.tsx @@ -0,0 +1,16 @@ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..c4b8c87 --- /dev/null +++ b/metadata.json @@ -0,0 +1,7 @@ +{ + "name": "BuzzMaster Live Quiz Platform", + "description": "A high-performance, real-time live quiz show platform with dedicated views for Main Display, Mobile Buzzer, and Admin Control.", + "requestFramePermissions": [ + "microphone" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec16c1b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "buzzmaster-live-quiz-platform", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-dom": "^19.2.3", + "react": "^19.2.3", + "react-router-dom": "^7.12.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/services/api.ts b/services/api.ts new file mode 100644 index 0000000..db272ad --- /dev/null +++ b/services/api.ts @@ -0,0 +1,82 @@ + +import { GameData, Player } from '../types'; + +const API_URL = process.env.VITE_API_URL || 'http://localhost:5000'; + +// Helper to check if the backend is actually alive +async function checkConnectivity(): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); + await fetch(`${API_URL}/api/health`, { signal: controller.signal }); + clearTimeout(timeoutId); + return true; + } catch (e) { + return false; + } +} + +export const api = { + async getGameStatus(gameId: string): Promise { + try { + const res = await fetch(`${API_URL}/api/game/${gameId}`); + if (!res.ok) throw new Error('Backend error'); + return await res.json(); + } catch (e) { + const local = localStorage.getItem(`game_${gameId}`); + return local ? JSON.parse(local) : null; + } + }, + + async joinGame(gameId: string, name: string, team: string): Promise { + try { + const res = await fetch(`${API_URL}/api/game/${gameId}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, team }), + }); + return await res.json(); + } catch (e) { + const mockPlayer: Player = { + id: Math.random().toString(36).substr(2, 9), + name, + teamId: team, + status: 'PENDING', + score: 0, + correctAnswers: 0, + wrongAnswers: 0, + avgBuzzerMs: 0 + }; + return mockPlayer; + } + }, + + async postBuzz(gameId: string, playerId: string): Promise { + try { + await fetch(`${API_URL}/api/game/${gameId}/buzz`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playerId, timestamp: Date.now() }), + }); + } catch (e) { + console.warn("API Offline: Buzz saved locally"); + } + }, + + async updateGameState(gameId: string, updates: Partial): Promise { + // Persist locally regardless of backend status for immediate UI responsiveness + const existing = localStorage.getItem(`game_${gameId}`); + const data = existing ? JSON.parse(existing) : {}; + localStorage.setItem(`game_${gameId}`, JSON.stringify({ ...data, ...updates })); + + try { + await fetch(`${API_URL}/api/game/${gameId}/state`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + } catch (e) { + // Silently fail if offline, as localStorage has already handled the update + } + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..8da3a89 --- /dev/null +++ b/types.ts @@ -0,0 +1,60 @@ + +export enum GameState { + LOBBY = 'LOBBY', + COUNTDOWN = 'COUNTDOWN', + QUESTION = 'QUESTION', + BUZZED = 'BUZZED', + REVEAL = 'REVEAL', + SCOREBOARD = 'SCOREBOARD', + FINAL_STATS = 'FINAL_STATS' +} + +export enum UserRole { + HOST = 'HOST', + PLAYER = 'PLAYER', + SPECTATOR = 'SPECTATOR' +} + +export interface Player { + id: string; + name: string; + teamId: string; + status: 'PENDING' | 'APPROVED'; + score: number; + correctAnswers: number; + wrongAnswers: number; + avgBuzzerMs: number; +} + +export interface Team { + id: string; + name: string; + score: number; +} + +export interface Question { + id: string; + text: string; + answer: string; + points: number; + audioUrl?: string; + audioStart?: number; + audioEnd?: number; +} + +export interface BuzzerLog { + playerId: string; + timestamp: number; // ms precision +} + +export interface GameData { + id: string; + name: string; + state: GameState; + currentQuestionIndex: number; + questions: Question[]; + players: Player[]; + teams: Team[]; + activeBuzzerQueue: BuzzerLog[]; + countdownValue: number; +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});