2 Commits

Author SHA1 Message Date
Philip 5a12341d4c feat: Implement host authentication and settings management
Introduces a new authentication flow for the host, requiring a password to access host-specific features. This commit also adds a dedicated settings section within the host view, allowing administrators to configure essential application parameters such as the API key for AI question generation and the join URL for players.

The backend has been updated to include a new `settings` table in the database to persist these configurations. The Gemini service is refactored to accept the API key as a parameter, enhancing flexibility and security. UI components like `HostView` and `App.tsx` are modified to integrate the new authentication and settings management functionalities.

Key changes include:
- Password-based authentication for host access.
- A new settings interface for API key and join URL management.
- Database schema update with a `settings` table.
- Refactoring `geminiService` to accept API key.
- UI adjustments for login and settings screens.
2026-01-30 16:13:39 -08:00
Philip c5cf88491f feat: Integrate join URL and QR code for player access
Adds a dynamic join URL and QR code displayed on the spectator view and host dashboard. This allows players to easily join the quiz session by scanning the QR code or navigating to the provided URL.

The `joinUrl` is now managed within the `GameContext` and exposed to relevant components. The spectator view uses this URL to generate the QR code, while the host view displays it for easy access. This enhances the onboarding experience for new players and simplifies session management.
2026-01-28 19:01:34 -08:00
8 changed files with 735 additions and 354 deletions
+76 -11
View File
@@ -1,28 +1,83 @@
import React, { useState, useEffect, Suspense } from 'react'; import React, { useState, useEffect, Suspense } from 'react';
import { GameProvider } from './context/GameContext'; import { GameProvider, useGame } from './context/GameContext';
import { Monitor, Smartphone, LayoutDashboard, Loader2 } from 'lucide-react'; import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw, Lock } from 'lucide-react';
// Lazy load components to reduce initial bundle size
const HostView = React.lazy(() => import('./components/HostView').then(module => ({ default: module.HostView }))); const HostView = React.lazy(() => import('./components/HostView').then(module => ({ default: module.HostView })));
const PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView }))); const PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView })));
const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView }))); const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView })));
const App: React.FC = () => { const LoginScreen: React.FC = () => {
const { login } = useGame();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
const success = await login(password);
if (!success) {
setError('Invalid Password');
}
setLoading(false);
};
return (
<div className="flex items-center justify-center h-full bg-slate-900">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-sm w-full">
<div className="flex justify-center mb-6 text-indigo-600">
<Lock size={48} />
</div>
<h2 className="text-2xl font-bold text-center text-slate-800 mb-6">Host Access</h2>
<form onSubmit={handleSubmit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg mb-4 text-slate-800 focus:ring-2 focus:ring-indigo-500 outline-none"
placeholder="Enter Admin Password"
autoFocus
/>
{error && <p className="text-red-500 text-sm mb-4 text-center font-bold">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-bold hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{loading ? 'Verifying...' : 'Login'}
</button>
</form>
</div>
</div>
);
};
const AppContent: React.FC = () => {
const { setIsHost, isSyncing, isAuthenticated } = useGame();
const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR'); const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR');
// Simple hash routing for demo purposes
useEffect(() => { useEffect(() => {
const handleHashChange = () => { const handleHashChange = () => {
const hash = window.location.hash; const hash = window.location.hash;
if (hash === '#host') setView('HOST'); if (hash === '#host') {
else if (hash === '#player') setView('PLAYER'); setView('HOST');
else setView('SPECTATOR'); setIsHost(true);
}
else if (hash === '#player') {
setView('PLAYER');
setIsHost(false);
}
else {
setView('SPECTATOR');
setIsHost(false);
}
}; };
handleHashChange(); handleHashChange();
window.addEventListener('hashchange', handleHashChange); window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange);
}, []); }, [setIsHost]);
const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => { const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => {
setView(newView); setView(newView);
@@ -31,7 +86,8 @@ const App: React.FC = () => {
const renderView = () => { const renderView = () => {
switch (view) { switch (view) {
case 'HOST': return <HostView />; case 'HOST':
return isAuthenticated ? <HostView /> : <LoginScreen />;
case 'PLAYER': return <PlayerView />; case 'PLAYER': return <PlayerView />;
case 'SPECTATOR': return <SpectatorView />; case 'SPECTATOR': return <SpectatorView />;
default: return <SpectatorView />; default: return <SpectatorView />;
@@ -39,13 +95,15 @@ const App: React.FC = () => {
}; };
return ( return (
<GameProvider>
<div className="flex flex-col h-screen bg-slate-950 overflow-hidden"> <div className="flex flex-col h-screen bg-slate-950 overflow-hidden">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
<nav className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shadow-md shrink-0 z-50"> <nav className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shadow-md shrink-0 z-50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center font-black italic text-white shadow-lg border border-white/10">Q</div> <div className="w-8 h-8 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center font-black italic text-white shadow-lg border border-white/10">Q</div>
<span className="font-bold text-lg tracking-wide text-slate-100 hidden sm:block">QuizMaster Live</span> <span className="font-bold text-lg tracking-wide text-slate-100 hidden sm:block">QuizMaster Live</span>
{isSyncing && (
<RefreshCw size={14} className="text-slate-500 animate-spin ml-2" />
)}
</div> </div>
<div className="flex bg-slate-800 p-1 rounded-lg border border-slate-700"> <div className="flex bg-slate-800 p-1 rounded-lg border border-slate-700">
@@ -82,6 +140,13 @@ const App: React.FC = () => {
</Suspense> </Suspense>
</div> </div>
</div> </div>
);
};
const App: React.FC = () => {
return (
<GameProvider>
<AppContent />
</GameProvider> </GameProvider>
); );
}; };
+129
View File
@@ -0,0 +1,129 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");
// --- CONFIGURATION ---
$host = 'localhost';
$db = 'quiz_db'; // CHANGE THIS
$user = 'root'; // CHANGE THIS
$pass = ''; // CHANGE THIS
$charset = 'utf8mb4';
// --- DB CONNECTION ---
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
exit;
}
// Handle Preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit(0);
}
$action = $_GET['action'] ?? '';
$input = json_decode(file_get_contents('php://input'), true);
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($action === 'getState') {
$stmt = $pdo->query("SELECT game_data FROM game_state WHERE id = 1");
$row = $stmt->fetch();
echo $row['game_data'] ?: '{}';
}
elseif ($action === 'getIntents') {
$pdo->beginTransaction();
$stmt = $pdo->query("SELECT * FROM player_intents ORDER BY created_at ASC");
$intents = $stmt->fetchAll();
if ($intents) {
$pdo->exec("DELETE FROM player_intents");
}
$pdo->commit();
foreach ($intents as &$intent) {
$intent['payload'] = json_decode($intent['payload'], true);
}
echo json_encode($intents);
}
elseif ($action === 'getPublicSettings') {
// Only return the Join URL for public clients (Spectators/Players)
$stmt = $pdo->query("SELECT join_url FROM settings WHERE id = 1");
$row = $stmt->fetch();
echo json_encode(['joinUrl' => $row['join_url'] ?? '']);
}
}
elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'pushState') {
$data = json_encode($input);
$stmt = $pdo->prepare("UPDATE game_state SET game_data = ? WHERE id = 1");
$stmt->execute([$data]);
echo json_encode(['success' => true]);
}
elseif ($action === 'pushIntent') {
$type = $input['type'];
$payload = json_encode($input['payload']);
$stmt = $pdo->prepare("INSERT INTO player_intents (type, payload) VALUES (?, ?)");
$stmt->execute([$type, $payload]);
echo json_encode(['success' => true]);
}
elseif ($action === 'login') {
$password = $input['password'];
$stmt = $pdo->query("SELECT * FROM settings WHERE id = 1");
$row = $stmt->fetch();
$loginSuccess = false;
if ($row) {
// 1. Check secure hash
if (password_verify($password, $row['admin_password'])) {
$loginSuccess = true;
}
// 2. Check plaintext fallback (First Run / Recovery)
elseif ($row['admin_password'] === $password) {
$loginSuccess = true;
// Auto-upgrade to secure hash
$newHash = password_hash($password, PASSWORD_DEFAULT);
$upd = $pdo->prepare("UPDATE settings SET admin_password = ? WHERE id = 1");
$upd->execute([$newHash]);
}
}
if ($loginSuccess) {
echo json_encode([
'success' => true,
'apiKey' => $row['api_key'],
'joinUrl' => $row['join_url']
]);
} else {
echo json_encode(['success' => false, 'error' => 'Invalid password']);
}
}
elseif ($action === 'updateSettings') {
$sql = "UPDATE settings SET api_key = ?, join_url = ? WHERE id = 1";
$params = [$input['apiKey'], $input['joinUrl']];
if (!empty($input['newPassword'])) {
$sql = "UPDATE settings SET api_key = ?, join_url = ?, admin_password = ? WHERE id = 1";
$hash = password_hash($input['newPassword'], PASSWORD_DEFAULT);
$params[] = $hash;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true]);
}
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
?>
+218 -239
View File
@@ -3,18 +3,18 @@ import { useGame } from '../context/GameContext';
import { GamePhase } from '../types'; import { GamePhase } from '../types';
import type { Question } from '../types'; import type { Question } from '../types';
import { Soundboard } from './Soundboard'; import { Soundboard } from './Soundboard';
import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw } from 'lucide-react'; import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw, Settings, Save } from 'lucide-react';
import { generateQuestions } from '../services/geminiService'; import { generateQuestions } from '../services/geminiService';
export const HostView: React.FC = () => { export const HostView: React.FC = () => {
const { const {
gameState, activeGameName, players, teams, buzzQueue, questions, games, gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl, apiKey,
approvePlayer, startGame, startCountdown, openBuzzers, approvePlayer, startGame, startCountdown, openBuzzers,
resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame, resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame,
createGame, updateGame, deleteGame, loadGameToLive createGame, updateGame, deleteGame, loadGameToLive, updateSettings
} = useGame(); } = useGame();
const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY'>('GAME'); const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY' | 'SETTINGS'>('GAME');
// Library State // Library State
const [editingGameId, setEditingGameId] = useState<string | null>(null); const [editingGameId, setEditingGameId] = useState<string | null>(null);
@@ -26,10 +26,21 @@ export const HostView: React.FC = () => {
const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isAddingQuestion, setIsAddingQuestion] = useState(false);
const [manualQ, setManualQ] = useState({ text: '', answer: '', points: 10, category: '', mediaUrl: '' }); const [manualQ, setManualQ] = useState({ text: '', answer: '', points: 10, category: '', mediaUrl: '' });
// Settings State
const [settingsForm, setSettingsForm] = useState({
joinUrl: joinUrl,
apiKey: apiKey,
newPassword: ''
});
// Update local form state when context changes
React.useEffect(() => {
setSettingsForm(prev => ({ ...prev, joinUrl: joinUrl, apiKey: apiKey }));
}, [joinUrl, apiKey]);
const currentQ = questions[gameState.currentQuestionIndex]; const currentQ = questions[gameState.currentQuestionIndex];
const isPreGame = gameState.currentQuestionIndex === -1; const isPreGame = gameState.currentQuestionIndex === -1;
const isGameOver = gameState.currentQuestionIndex >= questions.length; const isGameOver = gameState.currentQuestionIndex >= questions.length;
// Correctly identify if we have finished all questions and are just lingering on the last leaderboard
const isLastQuestionPhase = gameState.currentQuestionIndex === questions.length - 1; const isLastQuestionPhase = gameState.currentQuestionIndex === questions.length - 1;
// --- Handlers --- // --- Handlers ---
@@ -44,7 +55,7 @@ export const HostView: React.FC = () => {
const handleAiGenerate = async (gameId: string) => { const handleAiGenerate = async (gameId: string) => {
if (!aiTopic) return; if (!aiTopic) return;
setIsGenerating(true); setIsGenerating(true);
const newQuestions = await generateQuestions(aiTopic); const newQuestions = await generateQuestions(aiTopic, apiKey); // Pass apiKey
if (newQuestions.length > 0) { if (newQuestions.length > 0) {
const game = games.find(g => g.id === gameId); const game = games.find(g => g.id === gameId);
if (game) { if (game) {
@@ -53,7 +64,7 @@ export const HostView: React.FC = () => {
alert(`Added ${newQuestions.length} questions!`); alert(`Added ${newQuestions.length} questions!`);
setAiTopic(''); setAiTopic('');
} else { } else {
alert("Failed to generate. Check API Key or try again."); alert("Failed to generate. Check API Key in Settings.");
} }
setIsGenerating(false); setIsGenerating(false);
}; };
@@ -73,7 +84,6 @@ export const HostView: React.FC = () => {
if (!manualQ.text || !manualQ.answer) return; if (!manualQ.text || !manualQ.answer) return;
const game = games.find(g => g.id === gameId); const game = games.find(g => g.id === gameId);
if (game) { if (game) {
// Detect Media Type
let mediaType: 'image' | 'video' | undefined = undefined; let mediaType: 'image' | 'video' | undefined = undefined;
if (manualQ.mediaUrl) { if (manualQ.mediaUrl) {
if (manualQ.mediaUrl.startsWith('data:image') || manualQ.mediaUrl.match(/\.(jpeg|jpg|gif|png)$/i)) { if (manualQ.mediaUrl.startsWith('data:image') || manualQ.mediaUrl.match(/\.(jpeg|jpg|gif|png)$/i)) {
@@ -81,7 +91,6 @@ export const HostView: React.FC = () => {
} else if (manualQ.mediaUrl.includes('youtube') || manualQ.mediaUrl.includes('youtu.be')) { } else if (manualQ.mediaUrl.includes('youtube') || manualQ.mediaUrl.includes('youtu.be')) {
mediaType = 'video'; mediaType = 'video';
} else { } else {
// Fallback assumption for uploads vs links
mediaType = 'image'; mediaType = 'image';
} }
} }
@@ -109,12 +118,16 @@ export const HostView: React.FC = () => {
}; };
const handleLoadGame = (gameId: string) => { const handleLoadGame = (gameId: string) => {
// Removed confirm() as it can block execution or be annoying
loadGameToLive(gameId); loadGameToLive(gameId);
setActiveTab('GAME'); setActiveTab('GAME');
}; };
// Find the winner of the current round (if any) const handleSaveSettings = () => {
updateSettings(settingsForm.apiKey, settingsForm.joinUrl, settingsForm.newPassword);
setSettingsForm(prev => ({ ...prev, newPassword: '' })); // Clear password field
alert("Settings Updated!");
};
const currentWinnerBuzz = buzzQueue.find(b => b.status === 'CORRECT'); const currentWinnerBuzz = buzzQueue.find(b => b.status === 'CORRECT');
const currentWinner = currentWinnerBuzz ? players.find(p => p.id === currentWinnerBuzz.playerId) : null; const currentWinner = currentWinnerBuzz ? players.find(p => p.id === currentWinnerBuzz.playerId) : null;
@@ -127,7 +140,6 @@ export const HostView: React.FC = () => {
<div className="bg-indigo-600 text-white font-black px-2 py-1 rounded text-xl">QM</div> <div className="bg-indigo-600 text-white font-black px-2 py-1 rounded text-xl">QM</div>
<h1 className="font-bold text-lg text-slate-700">Host Dashboard</h1> <h1 className="font-bold text-lg text-slate-700">Host Dashboard</h1>
</div> </div>
{/* Active Game Display */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-full shadow-sm"> <div className="hidden md:flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-full shadow-sm">
<Library size={16} className="text-indigo-600" /> <Library size={16} className="text-indigo-600" />
<span className="text-xs text-indigo-400 font-bold uppercase">ACTIVE GAME:</span> <span className="text-xs text-indigo-400 font-bold uppercase">ACTIVE GAME:</span>
@@ -155,7 +167,7 @@ export const HostView: React.FC = () => {
</header> </header>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Navigation & Soundboard */} {/* Left Sidebar */}
<aside className="w-64 bg-slate-900 text-white flex flex-col overflow-y-auto"> <aside className="w-64 bg-slate-900 text-white flex flex-col overflow-y-auto">
<nav className="p-4 space-y-2"> <nav className="p-4 space-y-2">
<button <button
@@ -176,6 +188,14 @@ export const HostView: React.FC = () => {
> >
<Library className="w-4 h-4" /> Game Library <Library className="w-4 h-4" /> Game Library
</button> </button>
<div className="pt-4 mt-4 border-t border-slate-700">
<button
onClick={() => setActiveTab('SETTINGS')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded transition-colors ${activeTab === 'SETTINGS' ? 'bg-indigo-600' : 'hover:bg-slate-800'}`}
>
<Settings className="w-4 h-4" /> Settings
</button>
</div>
</nav> </nav>
<div className="mt-auto p-4 border-t border-slate-800"> <div className="mt-auto p-4 border-t border-slate-800">
@@ -189,7 +209,6 @@ export const HostView: React.FC = () => {
{/* GAME CONTROL TAB */} {/* GAME CONTROL TAB */}
{activeTab === 'GAME' && ( {activeTab === 'GAME' && (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* LAST QUESTION WARNING */}
{isLastQuestionPhase && !isGameOver && ( {isLastQuestionPhase && !isGameOver && (
<div className="bg-amber-100 border-l-4 border-amber-500 text-amber-700 p-4 mb-6 font-bold shadow-sm"> <div className="bg-amber-100 border-l-4 border-amber-500 text-amber-700 p-4 mb-6 font-bold shadow-sm">
THIS IS THE LAST QUESTION! THIS IS THE LAST QUESTION!
@@ -236,15 +255,13 @@ export const HostView: React.FC = () => {
</div> </div>
)} )}
{/* Controls based on Phase */} {/* Controls */}
<div className="flex flex-wrap gap-4 border-t border-slate-100 pt-6"> <div className="flex flex-wrap gap-4 border-t border-slate-100 pt-6">
{gameState.phase === GamePhase.LOBBY && ( {gameState.phase === GamePhase.LOBBY && (
<button onClick={startGame} className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-bold shadow-lg transition-all"> <button onClick={startGame} className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-bold shadow-lg transition-all">
START GAME START GAME
</button> </button>
)} )}
{/* Button logic for moving to next question OR finishing game */}
{(gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) && !isGameOver && ( {(gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) && !isGameOver && (
isLastQuestionPhase && gameState.phase === GamePhase.LEADERBOARD ? ( isLastQuestionPhase && gameState.phase === GamePhase.LEADERBOARD ? (
<button onClick={startCountdown} className="bg-indigo-800 hover:bg-indigo-900 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 animate-pulse"> <button onClick={startCountdown} className="bg-indigo-800 hover:bg-indigo-900 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 animate-pulse">
@@ -256,23 +273,18 @@ export const HostView: React.FC = () => {
</button> </button>
) )
)} )}
{gameState.phase === GamePhase.QUESTION_DISPLAY && ( {gameState.phase === GamePhase.QUESTION_DISPLAY && (
<button onClick={openBuzzers} className="bg-red-600 hover:bg-red-700 text-white px-8 py-3 rounded-lg font-bold shadow-lg animate-pulse"> <button onClick={openBuzzers} className="bg-red-600 hover:bg-red-700 text-white px-8 py-3 rounded-lg font-bold shadow-lg animate-pulse">
OPEN BUZZERS OPEN BUZZERS
</button> </button>
)} )}
{/* Skip/Reveal Button */}
{(gameState.phase === GamePhase.QUESTION_DISPLAY || gameState.phase === GamePhase.BUZZER_OPEN || gameState.phase === GamePhase.ADJUDICATION) && ( {(gameState.phase === GamePhase.QUESTION_DISPLAY || gameState.phase === GamePhase.BUZZER_OPEN || gameState.phase === GamePhase.ADJUDICATION) && (
<button onClick={skipQuestion} className="bg-slate-500 hover:bg-slate-600 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 ml-auto"> <button onClick={skipQuestion} className="bg-slate-500 hover:bg-slate-600 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 ml-auto">
<SkipForward className="w-4 h-4" /> Reveal Answer <SkipForward className="w-4 h-4" /> Reveal Answer
</button> </button>
)} )}
{gameState.phase === GamePhase.ANSWER_REVEAL && ( {gameState.phase === GamePhase.ANSWER_REVEAL && (
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
{/* UNDO BUTTON */}
{currentWinner && ( {currentWinner && (
<button <button
onClick={() => rectifyBuzz(currentWinner.id, 'WRONG')} onClick={() => rectifyBuzz(currentWinner.id, 'WRONG')}
@@ -282,7 +294,6 @@ export const HostView: React.FC = () => {
Undo Correct ({currentWinner.name}) Undo Correct ({currentWinner.name})
</button> </button>
)} )}
<button onClick={nextPhase} className="bg-slate-800 hover:bg-slate-900 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 ml-auto"> <button onClick={nextPhase} className="bg-slate-800 hover:bg-slate-900 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2 ml-auto">
<SkipForward className="w-4 h-4" /> <SkipForward className="w-4 h-4" />
{isLastQuestionPhase ? 'Finish Game' : 'Show Scores'} {isLastQuestionPhase ? 'Finish Game' : 'Show Scores'}
@@ -292,7 +303,7 @@ export const HostView: React.FC = () => {
</div> </div>
</div> </div>
{/* QUESTION QUEUE PREVIEW (Added for better Host visibility) */} {/* Queue & Buzzer Panels */}
{isPreGame && questions.length > 0 && ( {isPreGame && questions.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 mb-8"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 mb-8">
<div className="flex items-center gap-2 mb-3 text-slate-500 border-b border-slate-100 pb-2"> <div className="flex items-center gap-2 mb-3 text-slate-500 border-b border-slate-100 pb-2">
@@ -309,8 +320,6 @@ export const HostView: React.FC = () => {
</ul> </ul>
</div> </div>
)} )}
{/* Buzzer Queue Adjudication */}
{buzzQueue.length > 0 && ( {buzzQueue.length > 0 && (
<div className="bg-white rounded-xl shadow-md border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-md border border-slate-200 overflow-hidden">
<div className="bg-slate-50 px-6 py-3 border-b border-slate-200 flex justify-between items-center"> <div className="bg-slate-50 px-6 py-3 border-b border-slate-200 flex justify-between items-center">
@@ -321,7 +330,6 @@ export const HostView: React.FC = () => {
{buzzQueue.map((buzz, idx) => { {buzzQueue.map((buzz, idx) => {
const player = players.find(p => p.id === buzz.playerId); const player = players.find(p => p.id === buzz.playerId);
const isCurrent = idx === 0 && buzz.status === 'PENDING'; const isCurrent = idx === 0 && buzz.status === 'PENDING';
return ( return (
<div key={buzz.playerId} className={`px-6 py-4 flex items-center justify-between ${isCurrent ? 'bg-yellow-50' : ''}`}> <div key={buzz.playerId} className={`px-6 py-4 flex items-center justify-between ${isCurrent ? 'bg-yellow-50' : ''}`}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -331,7 +339,6 @@ export const HostView: React.FC = () => {
<div className="text-xs text-slate-500">{(buzz.timestamp % 10000)}ms</div> <div className="text-xs text-slate-500">{(buzz.timestamp % 10000)}ms</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{buzz.status === 'PENDING' ? ( {buzz.status === 'PENDING' ? (
isCurrent ? ( isCurrent ? (
@@ -357,7 +364,6 @@ export const HostView: React.FC = () => {
<span className={`text-xs font-bold px-2 py-1 rounded ${buzz.status === 'CORRECT' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}> <span className={`text-xs font-bold px-2 py-1 rounded ${buzz.status === 'CORRECT' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{buzz.status} {buzz.status}
</span> </span>
{/* Correction Button for Wrong Answers */}
{buzz.status === 'WRONG' && ( {buzz.status === 'WRONG' && (
<button <button
onClick={() => rectifyBuzz(buzz.playerId, 'CORRECT')} onClick={() => rectifyBuzz(buzz.playerId, 'CORRECT')}
@@ -379,7 +385,58 @@ export const HostView: React.FC = () => {
</div> </div>
)} )}
{/* PLAYERS TAB */} {/* SETTINGS TAB */}
{activeTab === 'SETTINGS' && (
<div className="max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6 text-slate-800">Host Configuration</h2>
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Public Join URL</label>
<input
type="text"
className="w-full p-3 border border-slate-300 rounded-lg text-slate-800 focus:ring-2 focus:ring-indigo-500 outline-none"
value={settingsForm.joinUrl}
onChange={e => setSettingsForm({ ...settingsForm, joinUrl: e.target.value })}
placeholder="https://your-site.com/#player"
/>
<p className="text-xs text-slate-500 mt-1">This URL is encoded into the QR code shown to players.</p>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Gemini API Key</label>
<input
type="password"
className="w-full p-3 border border-slate-300 rounded-lg text-slate-800 focus:ring-2 focus:ring-indigo-500 outline-none font-mono"
value={settingsForm.apiKey}
onChange={e => setSettingsForm({ ...settingsForm, apiKey: e.target.value })}
placeholder="AIzaSy..."
/>
<p className="text-xs text-slate-500 mt-1">Required for AI question generation.</p>
</div>
<div className="pt-4 border-t border-slate-100">
<label className="block text-sm font-bold text-slate-700 mb-2">Change Host Password</label>
<input
type="password"
className="w-full p-3 border border-slate-300 rounded-lg text-slate-800 focus:ring-2 focus:ring-indigo-500 outline-none"
value={settingsForm.newPassword}
onChange={e => setSettingsForm({ ...settingsForm, newPassword: e.target.value })}
placeholder="Leave empty to keep current password"
/>
</div>
<button
onClick={handleSaveSettings}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg shadow-md flex items-center justify-center gap-2"
>
<Save size={18} /> Save Settings
</button>
</div>
</div>
)}
{/* PLAYERS & LIBRARY TABS */}
{activeTab === 'PLAYERS' && ( {activeTab === 'PLAYERS' && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200"> <div className="bg-white rounded-xl shadow-sm border border-slate-200">
<table className="w-full text-left"> <table className="w-full text-left">
@@ -410,225 +467,147 @@ export const HostView: React.FC = () => {
</table> </table>
</div> </div>
)} )}
{activeTab === 'LIBRARY' && !editingGameId && (
{/* GAME LIBRARY TAB */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{activeTab === 'LIBRARY' && ( <div className="col-span-full flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-slate-800">Your Games</h2>
<div className="flex gap-2">
<input value={newGameName} onChange={e => setNewGameName(e.target.value)} placeholder="New Game Name..." className="px-4 py-2 rounded border border-slate-300 text-sm text-black bg-white" />
<button onClick={handleCreateGame} className="bg-indigo-600 text-white px-4 py-2 rounded text-sm font-bold hover:bg-indigo-700">Create</button>
</div>
</div>
{games.map(game => (
<div key={game.id} className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<h3 className="font-bold text-lg mb-1 text-slate-900">{game.name}</h3>
<p className="text-sm text-slate-500 mb-4">{game.questions.length} Questions</p>
<div className="flex gap-2 mt-2">
<button onClick={() => setEditingGameId(game.id)} className="flex-1 py-2 bg-slate-100 text-slate-600 rounded text-sm font-bold flex items-center justify-center gap-2"><Edit size={14}/> Edit</button>
<button onClick={() => handleLoadGame(game.id)} className="flex-1 py-2 bg-green-50 text-green-700 border border-green-200 rounded text-sm font-bold">Load</button>
<button onClick={() => deleteGame(game.id)} className="p-2 text-red-400 hover:bg-red-50 rounded"><Trash2 size={16} /></button>
</div>
</div>
))}
</div>
)}
{activeTab === 'LIBRARY' && editingGameId && (
<div className="space-y-6"> <div className="space-y-6">
{!editingGameId ? ( <button onClick={() => setEditingGameId(null)} className="flex items-center gap-2 text-slate-500 font-bold mb-4"><ArrowLeft size={16}/> Back to Library</button>
/* MODE: LIST GAMES */ <div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-xl p-6 text-white shadow-lg">
<> <div className="flex items-center gap-2 mb-4"><Sparkles className="w-5 h-5" /><h3 className="font-bold text-lg">AI Generator</h3></div>
<div className="flex justify-between items-center mb-6"> <div className="flex gap-2">
<h2 className="text-2xl font-bold text-slate-800">Your Games</h2> <input value={aiTopic} onChange={(e) => setAiTopic(e.target.value)} placeholder="Topic..." className="flex-1 px-4 py-2 rounded text-slate-900 bg-white outline-none" />
<div className="flex gap-2"> <button onClick={() => handleAiGenerate(editingGameId)} disabled={isGenerating} className="bg-white text-purple-700 px-4 py-2 rounded font-bold hover:bg-purple-50 disabled:opacity-50">{isGenerating ? '...' : 'Generate'}</button>
<input </div>
type="text" </div>
value={newGameName}
onChange={e => setNewGameName(e.target.value)} <div className="bg-white rounded-xl shadow-sm border border-slate-200">
placeholder="New Game Name..." <div className="p-4 border-b border-slate-100 flex justify-between items-center">
className="px-4 py-2 rounded border border-slate-300 text-sm text-black bg-white" <h3 className="font-bold text-slate-700">Questions</h3>
/> <button onClick={() => setIsAddingQuestion(!isAddingQuestion)} className="flex items-center gap-1 text-sm text-blue-600 font-bold hover:bg-blue-50 px-3 py-2 rounded"><Plus className="w-4 h-4" /> Add Manual</button>
</div>
{isAddingQuestion && (
<div className="p-4 bg-slate-50 border-b border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Question</label>
<input
placeholder="e.g. What is 2+2?"
className="p-2 border rounded text-black bg-white"
value={manualQ.text}
onChange={e => setManualQ({...manualQ, text: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Answer</label>
<input
placeholder="e.g. 4"
className="p-2 border rounded text-black bg-white"
value={manualQ.answer}
onChange={e => setManualQ({...manualQ, answer: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Points</label>
<input
type="number"
placeholder="10"
className="p-2 border rounded text-black bg-white"
value={manualQ.points}
onChange={e => setManualQ({...manualQ, points: Number(e.target.value)})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Category</label>
<input
placeholder="e.g. Math"
className="p-2 border rounded text-black bg-white"
value={manualQ.category}
onChange={e => setManualQ({...manualQ, category: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1 md:col-span-2">
<label className="text-xs font-bold text-slate-500">Media</label>
<div className="flex gap-2">
<input
type="text"
placeholder="YouTube Link or Image URL"
className="flex-1 p-2 border rounded text-black bg-white"
value={manualQ.mediaUrl}
onChange={e => setManualQ({...manualQ, mediaUrl: e.target.value})}
/>
<div className="relative">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<button className="h-full px-4 bg-slate-200 hover:bg-slate-300 text-slate-700 font-bold rounded flex items-center gap-2">
<Upload size={16} /> Upload Img
</button>
</div>
</div>
<p className="text-[10px] text-slate-400">Supports YouTube links or Image Uploads</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setIsAddingQuestion(false)} className="px-3 py-1 text-slate-500 text-sm">Cancel</button>
<button <button
onClick={handleCreateGame} onClick={() => handleAddManualQuestion(editingGameId)}
className="bg-indigo-600 text-white px-4 py-2 rounded text-sm font-bold hover:bg-indigo-700" className="px-3 py-1 bg-blue-600 text-white rounded text-sm font-bold"
> >
Create Save Question
</button> </button>
</div> </div>
</div> </div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.map(game => (
<div key={game.id} className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-4">
<div className="h-12 w-12 bg-indigo-100 text-indigo-600 rounded-lg flex items-center justify-center">
<Library className="w-6 h-6" />
</div>
<div className="flex gap-1">
<button onClick={() => setEditingGameId(game.id)} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => deleteGame(game.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<h3 className="font-bold text-lg mb-1 text-slate-900">{game.name}</h3>
<p className="text-sm text-slate-500 mb-4">{game.questions.length} Questions</p>
<button
onClick={() => handleLoadGame(game.id)}
className="w-full py-2 bg-green-50 text-green-700 border border-green-200 rounded font-bold text-sm hover:bg-green-100 flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" /> LOAD TO LIVE
</button>
</div>
))}
</div>
</>
) : (
/* MODE: EDIT GAME */
(() => {
const game = games.find(g => g.id === editingGameId);
if (!game) return null;
return ( <ul className="divide-y divide-slate-100">
<div className="space-y-6"> {games.find(g => g.id === editingGameId)?.questions.map((q, i) => (
<div className="flex items-center gap-4 mb-6"> <li key={q.id} className="p-4 hover:bg-slate-50 flex justify-between items-center group">
<button onClick={() => setEditingGameId(null)} className="p-2 hover:bg-slate-200 rounded-full"> <div className="flex-1">
<ArrowLeft className="w-5 h-5 text-slate-600" /> <div className="flex items-center gap-2 mb-1">
</button> <span className="font-bold text-slate-400 text-xs">Q{i+1}</span>
<input <span className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-500">{q.category}</span>
value={game.name} <span className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-500 font-mono">{q.points}pts</span>
onChange={e => updateGame(game.id, { name: e.target.value })}
className="text-2xl font-bold bg-transparent border-b border-transparent hover:border-slate-300 focus:border-indigo-600 focus:outline-none px-2 py-1 text-slate-900"
/>
<span className="ml-auto text-sm text-slate-500">{game.questions.length} Questions</span>
</div>
{/* AI Generator for this game */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-xl p-6 text-white shadow-lg">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5" />
<h3 className="font-bold text-lg">AI Question Generator</h3>
</div> </div>
<div className="flex gap-2"> <div className="font-medium text-slate-800 flex items-center gap-2">
<input {q.mediaUrl && <ImageIcon size={14} className="text-indigo-500" />}
type="text" {q.text}
value={aiTopic}
onChange={(e) => setAiTopic(e.target.value)}
placeholder="e.g. 80s Movies, World Capitals, Pokemon"
className="flex-1 px-4 py-2 rounded text-slate-900 bg-white outline-none"
/>
<button
onClick={() => handleAiGenerate(game.id)}
disabled={isGenerating}
className="bg-white text-purple-700 px-4 py-2 rounded font-bold hover:bg-purple-50 disabled:opacity-50"
>
{isGenerating ? 'Dreaming...' : 'Generate'}
</button>
</div> </div>
<div className="text-sm text-green-700">A: {q.answer}</div>
</div> </div>
<button
<div className="bg-white rounded-xl shadow-sm border border-slate-200"> onClick={() => handleDeleteQuestion(editingGameId, q.id)}
<div className="p-4 border-b border-slate-100 flex justify-between items-center"> className="opacity-0 group-hover:opacity-100 p-2 text-red-400 hover:text-red-600"
<h3 className="font-bold text-slate-700">Questions</h3> >
<button <Trash2 className="w-4 h-4" />
onClick={() => setIsAddingQuestion(!isAddingQuestion)} </button>
className="flex items-center gap-1 text-sm text-blue-600 font-bold hover:bg-blue-50 px-3 py-2 rounded" </li>
> ))}
<Plus className="w-4 h-4" /> Add Manual </ul>
</button> </div>
</div>
{/* Add Manual Form */}
{isAddingQuestion && (
<div className="p-4 bg-slate-50 border-b border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Question</label>
<input
placeholder="e.g. What is 2+2?"
className="p-2 border rounded text-black bg-white"
value={manualQ.text}
onChange={e => setManualQ({...manualQ, text: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Answer</label>
<input
placeholder="e.g. 4"
className="p-2 border rounded text-black bg-white"
value={manualQ.answer}
onChange={e => setManualQ({...manualQ, answer: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Points</label>
<input
type="number"
placeholder="10"
className="p-2 border rounded text-black bg-white"
value={manualQ.points}
onChange={e => setManualQ({...manualQ, points: Number(e.target.value)})}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-bold text-slate-500">Category</label>
<input
placeholder="e.g. Math"
className="p-2 border rounded text-black bg-white"
value={manualQ.category}
onChange={e => setManualQ({...manualQ, category: e.target.value})}
/>
</div>
<div className="flex flex-col gap-1 md:col-span-2">
<label className="text-xs font-bold text-slate-500">Media</label>
<div className="flex gap-2">
<input
type="text"
placeholder="YouTube Link or Image URL"
className="flex-1 p-2 border rounded text-black bg-white"
value={manualQ.mediaUrl}
onChange={e => setManualQ({...manualQ, mediaUrl: e.target.value})}
/>
<div className="relative">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<button className="h-full px-4 bg-slate-200 hover:bg-slate-300 text-slate-700 font-bold rounded flex items-center gap-2">
<Upload size={16} /> Upload Img
</button>
</div>
</div>
<p className="text-[10px] text-slate-400">Supports YouTube links or Image Uploads</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setIsAddingQuestion(false)} className="px-3 py-1 text-slate-500 text-sm">Cancel</button>
<button
onClick={() => handleAddManualQuestion(game.id)}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm font-bold"
>
Save Question
</button>
</div>
</div>
)}
<ul className="divide-y divide-slate-100">
{game.questions.length === 0 && (
<li className="p-8 text-center text-slate-400">No questions yet. Add some manually or use AI!</li>
)}
{game.questions.map((q, i) => (
<li key={q.id} className="p-4 hover:bg-slate-50 flex justify-between items-center group">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-slate-400 text-xs">Q{i+1}</span>
<span className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-500">{q.category}</span>
<span className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-500 font-mono">{q.points}pts</span>
</div>
<div className="font-medium text-slate-800 flex items-center gap-2">
{q.mediaUrl && <ImageIcon size={14} className="text-indigo-500" />}
{q.text}
</div>
<div className="text-sm text-green-700">A: {q.answer}</div>
</div>
<button
onClick={() => handleDeleteQuestion(game.id, q.id)}
className="opacity-0 group-hover:opacity-100 p-2 text-red-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
</div>
);
})()
)}
</div> </div>
)} )}
+5 -4
View File
@@ -6,7 +6,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from
import { Trophy, Zap, Users } from 'lucide-react'; import { Trophy, Zap, Users } from 'lucide-react';
export const SpectatorView: React.FC = () => { export const SpectatorView: React.FC = () => {
const { gameState, questions, teams, players, buzzQueue } = useGame(); const { gameState, questions, teams, players, buzzQueue, joinUrl } = useGame();
const currentQ = questions[gameState.currentQuestionIndex]; const currentQ = questions[gameState.currentQuestionIndex];
// Helper to get formatted leaderboard data // Helper to get formatted leaderboard data
@@ -29,10 +29,11 @@ export const SpectatorView: React.FC = () => {
<h1 className="text-6xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500 mb-8"> <h1 className="text-6xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500 mb-8">
JOIN THE QUIZ JOIN THE QUIZ
</h1> </h1>
<div className="bg-white p-4 inline-block rounded-xl mb-8"> <div className="bg-white p-4 inline-block rounded-xl mb-4">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https://quiz-app-demo.com" alt="QR Code" className="w-48 h-48" /> <img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(joinUrl)}`} alt="QR Code" className="w-48 h-48" />
</div> </div>
<p className="text-2xl text-slate-300 mb-8">Scan to Join</p> <p className="text-2xl text-slate-300 mb-2">Scan to Join</p>
<p className="text-lg text-slate-500 font-mono mb-8">{joinUrl}</p>
<div className="flex flex-wrap justify-center gap-4 max-w-4xl mx-auto"> <div className="flex flex-wrap justify-center gap-4 max-w-4xl mx-auto">
{players.filter(p => p.isApproved).map(p => ( {players.filter(p => p.isApproved).map(p => (
+264 -94
View File
@@ -1,22 +1,32 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { GamePhase } from '../types'; import { GamePhase } from '../types';
import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types'; import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
interface GameContextType { interface GameContextType {
// State // State
gameState: GameState; gameState: GameState;
activeGameName: string; // New: Track which game is loaded activeGameName: string;
joinUrl: string;
apiKey: string;
players: Player[]; players: Player[];
teams: Team[]; teams: Team[];
questions: Question[]; // The ACTIVE questions currently being played questions: Question[];
games: Game[]; // The LIBRARY of saved games games: Game[];
buzzQueue: BuzzerLog[]; buzzQueue: BuzzerLog[];
currentPlayerId: string | null; currentPlayerId: string | null;
// Auth & Sync Status
isHost: boolean;
isAuthenticated: boolean;
setIsHost: (isHost: boolean) => void;
login: (password: string) => Promise<boolean>;
updateSettings: (newApiKey: string, newJoinUrl: string, newPassword?: string) => Promise<void>;
isSyncing: boolean;
// Actions // Actions
addPlayer: (name: string, teamName: string) => void; addPlayer: (name: string, teamName: string) => void;
approvePlayer: (playerId: string) => void; approvePlayer: (playerId: string) => void;
removePlayer: (playerId: string) => void; // New action removePlayer: (playerId: string) => void;
startGame: () => void; startGame: () => void;
startCountdown: () => void; startCountdown: () => void;
openBuzzers: () => void; openBuzzers: () => void;
@@ -35,10 +45,13 @@ interface GameContextType {
updateGame: (gameId: string, updates: Partial<Game>) => void; updateGame: (gameId: string, updates: Partial<Game>) => void;
deleteGame: (gameId: string) => void; deleteGame: (gameId: string) => void;
loadGameToLive: (gameId: string) => void; loadGameToLive: (gameId: string) => void;
setJoinUrl: (url: string) => void;
} }
const GameContext = createContext<GameContextType | undefined>(undefined); const GameContext = createContext<GameContextType | undefined>(undefined);
const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php';
// Initial Mock Data // Initial Mock Data
const INITIAL_QUESTIONS: Question[] = [ const INITIAL_QUESTIONS: Question[] = [
{ id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" }, { id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" },
@@ -68,6 +81,11 @@ const INITIAL_GAMES: Game[] = [
]; ];
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isHost, setIsHost] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// --- GAME STATE ---
const [gameState, setGameState] = useState<GameState>({ const [gameState, setGameState] = useState<GameState>({
phase: GamePhase.LOBBY, phase: GamePhase.LOBBY,
currentQuestionIndex: -1, currentQuestionIndex: -1,
@@ -76,6 +94,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}); });
const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo"); const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo");
const [joinUrl, setJoinUrl] = useState<string>('');
const [apiKey, setApiKey] = useState<string>('');
const [players, setPlayers] = useState<Player[]>([]); const [players, setPlayers] = useState<Player[]>([]);
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [questions, setQuestionsQuestions] = useState<Question[]>(INITIAL_QUESTIONS); const [questions, setQuestionsQuestions] = useState<Question[]>(INITIAL_QUESTIONS);
@@ -83,46 +103,231 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]); const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]);
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null); const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
// Helper: Find or create team const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey });
const getOrCreateTeam = (name: string) => { useEffect(() => {
const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase()); stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey };
if (existing) return existing; }, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey]);
const newTeam: Team = { id: crypto.randomUUID(), name, score: 0 };
setTeams(prev => [...prev, newTeam]); // Initial Fetch for Clients (Spectators/Players) to get Join URL without login
return newTeam; useEffect(() => {
const fetchPublicSettings = async () => {
try {
const response = await fetch(`${API_URL}?action=getPublicSettings`);
const data = await response.json();
if (data.joinUrl) setJoinUrl(data.joinUrl);
else {
// Default if empty
if (typeof window !== 'undefined') {
setJoinUrl(`${window.location.origin}${window.location.pathname}#player`);
}
}
} catch (e) {
console.error("Failed to fetch public settings", e);
}
};
if (!isHost) fetchPublicSettings();
}, [isHost]);
// --- AUTHENTICATION ---
const login = async (password: string): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}?action=login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
setIsAuthenticated(true);
setApiKey(data.apiKey || '');
if (data.joinUrl) setJoinUrl(data.joinUrl);
return true;
}
return false;
} catch (e) {
console.error(e);
return false;
}
};
const updateSettings = async (newApiKey: string, newJoinUrl: string, newPassword?: string) => {
try {
await fetch(`${API_URL}?action=updateSettings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: newApiKey, joinUrl: newJoinUrl, newPassword })
});
setApiKey(newApiKey);
setJoinUrl(newJoinUrl);
} catch (e) {
console.error("Failed to update settings", e);
}
};
// --- SYNC ENGINE ---
useEffect(() => {
const syncInterval = setInterval(async () => {
setIsSyncing(true);
try {
if (isHost && isAuthenticated) {
// --- HOST LOGIC ---
const response = await fetch(`${API_URL}?action=getIntents`);
const intents = await response.json();
if (Array.isArray(intents) && intents.length > 0) {
console.log("Processing Intents:", intents);
intents.forEach((item: any) => {
const { type, payload } = item;
if (type === 'JOIN') {
const { name, teamName, tempId } = payload;
const currentTeams = stateRef.current.teams;
const existingTeam = currentTeams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
let teamId = existingTeam?.id;
if (!existingTeam) {
const newTeam: Team = { id: crypto.randomUUID(), name: teamName, score: 0 };
setTeams(prev => [...prev, newTeam]);
teamId = newTeam.id;
}
const exists = stateRef.current.players.some(p => p.name === name && p.teamId === teamId);
if (!exists && teamId) {
const newPlayer: Player = {
id: tempId || crypto.randomUUID(),
name,
teamId,
score: 0,
isApproved: true,
stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
};
setPlayers(prev => [...prev, newPlayer]);
}
}
else if (type === 'BUZZ') {
const { playerId } = payload;
const currentState = stateRef.current.gameState;
const currentQueue = stateRef.current.buzzQueue;
if (currentState.phase === GamePhase.BUZZER_OPEN && !currentQueue.find(b => b.playerId === playerId)) {
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: currentQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => {
const updated = [...prev, newBuzz];
if (updated.length === 1) {
setGameState(gs => ({ ...gs, phase: GamePhase.ADJUDICATION }));
}
return updated;
});
}
}
else if (type === 'LEAVE') {
const { playerId } = payload;
setPlayers(prev => prev.filter(p => p.id !== playerId));
}
});
}
const fullState = {
gameState: stateRef.current.gameState,
players: stateRef.current.players,
teams: stateRef.current.teams,
questions: stateRef.current.questions,
activeGameName: stateRef.current.activeGameName,
buzzQueue: stateRef.current.buzzQueue,
// Note: We don't push settings (joinUrl/apiKey) to game_state to avoid leaking credentials to clients polling getState
};
await fetch(`${API_URL}?action=pushState`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullState)
});
} else {
// --- CLIENT LOGIC ---
const response = await fetch(`${API_URL}?action=getState`);
const remoteState = await response.json();
if (remoteState && remoteState.gameState) {
setGameState(remoteState.gameState);
setPlayers(remoteState.players || []);
setTeams(remoteState.teams || []);
setQuestionsQuestions(remoteState.questions || []);
setActiveGameName(remoteState.activeGameName || "");
setBuzzQueue(remoteState.buzzQueue || []);
// Clients don't receive joinUrl from game_state loop, they get it from getPublicSettings
}
}
} catch (e) {
console.error("Sync Error:", e);
} finally {
setIsSyncing(false);
}
}, 500);
return () => clearInterval(syncInterval);
}, [isHost, isAuthenticated]);
// --- ACTIONS ---
const sendIntent = async (type: string, payload: any) => {
try {
await fetch(`${API_URL}?action=pushIntent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, payload })
});
} catch (e) {
console.error("Failed to send intent", e);
}
}; };
const addPlayer = (name: string, teamName: string) => { const addPlayer = (name: string, teamName: string) => {
const team = getOrCreateTeam(teamName); const tempId = crypto.randomUUID();
const newPlayer: Player = { setCurrentPlayerId(tempId);
id: crypto.randomUUID(), sendIntent('JOIN', { name, teamName, tempId });
name,
teamId: team.id,
score: 0,
isApproved: false, // Requires admin approval
stats: {
correctAnswers: 0,
totalBuzzes: 0,
bestReactionTime: null
}
};
setPlayers(prev => [...prev, newPlayer]);
if (!currentPlayerId) setCurrentPlayerId(newPlayer.id);
}; };
const removePlayer = (playerId: string) => { const removePlayer = (playerId: string) => {
setPlayers(prev => prev.filter(p => p.id !== playerId)); if (isHost) {
if (currentPlayerId === playerId) { setPlayers(prev => prev.filter(p => p.id !== playerId));
setCurrentPlayerId(null); } else {
sendIntent('LEAVE', { playerId });
if (currentPlayerId === playerId) setCurrentPlayerId(null);
} }
}; };
const approvePlayer = (playerId: string) => { const approvePlayer = (playerId: string) => {
if (!isHost) return;
setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p)); setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p));
}; };
const handleBuzz = (playerId: string) => {
if (isHost) {
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
if (buzzQueue.find(b => b.playerId === playerId)) return;
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: buzzQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => [...prev, newBuzz]);
if (buzzQueue.length === 0) {
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
}
} else {
sendIntent('BUZZ', { playerId });
}
};
const startGame = () => { const startGame = () => {
// Directly start Q1 Countdown, skipping initial Leaderboard if (!isHost) return;
setGameState({ setGameState({
phase: GamePhase.COUNTDOWN, phase: GamePhase.COUNTDOWN,
currentQuestionIndex: 0, currentQuestionIndex: 0,
@@ -133,16 +338,15 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
const startCountdown = () => { const startCountdown = () => {
if (!isHost) return;
let nextIndex = gameState.currentQuestionIndex; let nextIndex = gameState.currentQuestionIndex;
if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) { if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) {
nextIndex = gameState.currentQuestionIndex + 1; nextIndex = gameState.currentQuestionIndex + 1;
} }
if (nextIndex >= questions.length) { if (nextIndex >= questions.length) {
setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS })); setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS }));
return; return;
} }
setGameState({ setGameState({
phase: GamePhase.COUNTDOWN, phase: GamePhase.COUNTDOWN,
currentQuestionIndex: nextIndex, currentQuestionIndex: nextIndex,
@@ -153,6 +357,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
useEffect(() => { useEffect(() => {
if (!isHost) return;
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
if (gameState.phase === GamePhase.COUNTDOWN) { if (gameState.phase === GamePhase.COUNTDOWN) {
if (gameState.countdownValue > 0) { if (gameState.countdownValue > 0) {
@@ -164,60 +369,32 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
} }
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [gameState.phase, gameState.countdownValue]); }, [gameState.phase, gameState.countdownValue, isHost]);
const openBuzzers = () => { const openBuzzers = () => {
if (!isHost) return;
setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN, buzzerOpenTimestamp: Date.now() })); setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN, buzzerOpenTimestamp: Date.now() }));
}; };
const handleBuzz = (playerId: string) => {
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
if (buzzQueue.find(b => b.playerId === playerId)) return;
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: buzzQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => [...prev, newBuzz]);
if (buzzQueue.length === 0) {
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
}
};
const resolveBuzz = (playerId: string, correct: boolean) => { const resolveBuzz = (playerId: string, correct: boolean) => {
if (!isHost) return;
const currentQ = questions[gameState.currentQuestionIndex]; const currentQ = questions[gameState.currentQuestionIndex];
const player = players.find(p => p.id === playerId); const player = players.find(p => p.id === playerId);
// Update Stats logic
setPlayers(prev => prev.map(p => { setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p; if (p.id !== playerId) return p;
let newStats = { ...p.stats }; let newStats = { ...p.stats };
// Increment attempts (total buzzes)
newStats.totalBuzzes += 1; newStats.totalBuzzes += 1;
// Calculate reaction time if this buzz was the first one processed
// We only really care about updating reaction time if it was a successful/valid buzz attempt
if (gameState.buzzerOpenTimestamp) { if (gameState.buzzerOpenTimestamp) {
const reactionTime = Date.now() - gameState.buzzerOpenTimestamp; const reactionTime = Date.now() - gameState.buzzerOpenTimestamp;
if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) { if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) {
newStats.bestReactionTime = reactionTime; newStats.bestReactionTime = reactionTime;
} }
} }
if (correct) { if (correct) {
newStats.correctAnswers += 1; newStats.correctAnswers += 1;
return { return { ...p, score: p.score + currentQ.points, stats: newStats };
...p,
score: p.score + currentQ.points,
stats: newStats
};
} }
return { ...p, stats: newStats }; return { ...p, stats: newStats };
})); }));
@@ -237,6 +414,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => { const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => {
if (!isHost) return;
const currentQ = questions[gameState.currentQuestionIndex]; const currentQ = questions[gameState.currentQuestionIndex];
const player = players.find(p => p.id === playerId); const player = players.find(p => p.id === playerId);
if (!player || !player.teamId) return; if (!player || !player.teamId) return;
@@ -250,37 +428,31 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') { if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') {
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t)); setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t));
// Also correct stat if rectifying
setPlayers(prev => prev.map(p => p.id === playerId ? { setPlayers(prev => prev.map(p => p.id === playerId ? {
...p, ...p, score: p.score + points, stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
score: p.score + points,
stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
} : p)); } : p));
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL })); setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
} }
else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') { else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') {
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t)); setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t));
setPlayers(prev => prev.map(p => p.id === playerId ? { setPlayers(prev => prev.map(p => p.id === playerId ? {
...p, ...p, score: p.score - points, stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
score: p.score - points,
stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
} : p)); } : p));
const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING'); const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING');
setGameState(prev => ({ setGameState(prev => ({
...prev, ...prev, phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
})); }));
} }
}; };
const skipQuestion = () => { const skipQuestion = () => {
if (!isHost) return;
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL })); setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
setBuzzQueue([]); setBuzzQueue([]);
}; };
const nextPhase = () => { const nextPhase = () => {
if (!isHost) return;
if (gameState.phase === GamePhase.ANSWER_REVEAL) { if (gameState.phase === GamePhase.ANSWER_REVEAL) {
setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD })); setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD }));
} }
@@ -298,34 +470,22 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
const resetGame = () => { const resetGame = () => {
// "Soft Reset" - Keep players and teams, but reset their scores and stats if (!isHost) return;
setGameState({ setGameState({
phase: GamePhase.LOBBY, phase: GamePhase.LOBBY,
currentQuestionIndex: -1, currentQuestionIndex: -1,
countdownValue: 3, countdownValue: 3,
buzzerOpenTimestamp: null buzzerOpenTimestamp: null
}); });
// Reset Scores and Stats for Players
setPlayers(prev => prev.map(p => ({ setPlayers(prev => prev.map(p => ({
...p, ...p, score: 0, stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
score: 0,
stats: {
correctAnswers: 0,
totalBuzzes: 0,
bestReactionTime: null
}
}))); })));
// Reset Team Scores
setTeams(prev => prev.map(t => ({ ...t, score: 0 }))); setTeams(prev => prev.map(t => ({ ...t, score: 0 })));
setBuzzQueue([]); setBuzzQueue([]);
}; };
// --- LIBRARY ACTIONS ---
const createGame = (name: string) => { const createGame = (name: string) => {
if (!isHost) return;
const newGame: Game = { const newGame: Game = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name, name,
@@ -336,19 +496,21 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
const updateGame = (gameId: string, updates: Partial<Game>) => { const updateGame = (gameId: string, updates: Partial<Game>) => {
if (!isHost) return;
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g)); setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g));
}; };
const deleteGame = (gameId: string) => { const deleteGame = (gameId: string) => {
if (!isHost) return;
setGames(prev => prev.filter(g => g.id !== gameId)); setGames(prev => prev.filter(g => g.id !== gameId));
}; };
const loadGameToLive = (gameId: string) => { const loadGameToLive = (gameId: string) => {
if (!isHost) return;
const game = games.find(g => g.id === gameId); const game = games.find(g => g.id === gameId);
if (game) { if (game) {
setQuestionsQuestions([...game.questions]); setQuestionsQuestions([...game.questions]);
setActiveGameName(game.name); setActiveGameName(game.name);
setGameState({ setGameState({
phase: GamePhase.LOBBY, phase: GamePhase.LOBBY,
currentQuestionIndex: -1, currentQuestionIndex: -1,
@@ -356,7 +518,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
buzzerOpenTimestamp: null buzzerOpenTimestamp: null
}); });
setBuzzQueue([]); setBuzzQueue([]);
// NOTE: We do not clear players here anymore, allowing roster to persist between games
} }
}; };
@@ -364,12 +525,20 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
<GameContext.Provider value={{ <GameContext.Provider value={{
gameState, gameState,
activeGameName, activeGameName,
joinUrl,
apiKey,
players, players,
teams, teams,
questions, questions,
games, games,
buzzQueue, buzzQueue,
currentPlayerId, currentPlayerId,
isHost,
isAuthenticated,
setIsHost,
login,
updateSettings,
isSyncing,
addPlayer, addPlayer,
approvePlayer, approvePlayer,
removePlayer, removePlayer,
@@ -388,7 +557,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
createGame, createGame,
updateGame, updateGame,
deleteGame, deleteGame,
loadGameToLive loadGameToLive,
setJoinUrl
}}> }}>
{children} {children}
</GameContext.Provider> </GameContext.Provider>
+32
View File
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS `game_state` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`game_data` longtext NOT NULL,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `player_intents` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`type` varchar(50) NOT NULL,
`payload` longtext NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `settings` (
`id` int(11) NOT NULL,
`admin_password` varchar(255) NOT NULL,
`api_key` varchar(255) DEFAULT '',
`join_url` varchar(255) DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Initialize Game State
INSERT INTO `game_state` (`id`, `game_data`) VALUES (1, '{}') ON DUPLICATE KEY UPDATE `id`=1;
-- Initialize Settings with plaintext 'admin'.
-- api.php will detect this, log you in, and automatically hash it.
INSERT INTO `settings` (`id`, `admin_password`, `api_key`, `join_url`)
VALUES (1, 'admin', '', '')
ON DUPLICATE KEY UPDATE `admin_password`='admin';
+2 -5
View File
@@ -1,12 +1,9 @@
import { GoogleGenAI, Type } from "@google/genai"; import { GoogleGenAI, Type } from "@google/genai";
import type { Question } from "../types"; import type { Question } from "../types";
const generateQuestions = async (topic: string, count: number = 5): Promise<Question[]> => { const generateQuestions = async (topic: string, apiKey: string, count: number = 5): Promise<Question[]> => {
// Use process.env.API_KEY as per guidelines.
const apiKey = process.env.API_KEY;
if (!apiKey) { if (!apiKey) {
console.error("API Key is missing. Check your .env file."); console.error("API Key is missing. Please configure it in Host Settings.");
return []; return [];
} }
+9 -1
View File
@@ -1,3 +1,4 @@
export const GamePhase = { export const GamePhase = {
LOBBY: 'LOBBY', LOBBY: 'LOBBY',
COUNTDOWN: 'COUNTDOWN', COUNTDOWN: 'COUNTDOWN',
@@ -66,4 +67,11 @@ export interface GameState {
currentQuestionIndex: number; currentQuestionIndex: number;
countdownValue: number; countdownValue: number;
buzzerOpenTimestamp: number | null; // To calculate reaction time buzzerOpenTimestamp: number | null; // To calculate reaction time
} }
// Synchronization Types
export interface PlayerIntent {
type: 'JOIN' | 'BUZZ' | 'LEAVE';
payload: any;
created_at?: string;
}