Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a12341d4c | |||
| c5cf88491f |
@@ -1,28 +1,83 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import { GameProvider } from './context/GameContext';
|
||||
import { Monitor, Smartphone, LayoutDashboard, Loader2 } from 'lucide-react';
|
||||
import { GameProvider, useGame } from './context/GameContext';
|
||||
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 PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView })));
|
||||
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');
|
||||
|
||||
// Simple hash routing for demo purposes
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash === '#host') setView('HOST');
|
||||
else if (hash === '#player') setView('PLAYER');
|
||||
else setView('SPECTATOR');
|
||||
if (hash === '#host') {
|
||||
setView('HOST');
|
||||
setIsHost(true);
|
||||
}
|
||||
else if (hash === '#player') {
|
||||
setView('PLAYER');
|
||||
setIsHost(false);
|
||||
}
|
||||
else {
|
||||
setView('SPECTATOR');
|
||||
setIsHost(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleHashChange();
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
}, [setIsHost]);
|
||||
|
||||
const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => {
|
||||
setView(newView);
|
||||
@@ -31,7 +86,8 @@ const App: React.FC = () => {
|
||||
|
||||
const renderView = () => {
|
||||
switch (view) {
|
||||
case 'HOST': return <HostView />;
|
||||
case 'HOST':
|
||||
return isAuthenticated ? <HostView /> : <LoginScreen />;
|
||||
case 'PLAYER': return <PlayerView />;
|
||||
case 'SPECTATOR': return <SpectatorView />;
|
||||
default: return <SpectatorView />;
|
||||
@@ -39,13 +95,15 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<GameProvider>
|
||||
<div className="flex flex-col h-screen bg-slate-950 overflow-hidden">
|
||||
{/* 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">
|
||||
<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>
|
||||
<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 className="flex bg-slate-800 p-1 rounded-lg border border-slate-700">
|
||||
@@ -82,6 +140,13 @@ const App: React.FC = () => {
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<GameProvider>
|
||||
<AppContent />
|
||||
</GameProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -3,18 +3,18 @@ import { useGame } from '../context/GameContext';
|
||||
import { GamePhase } from '../types';
|
||||
import type { Question } from '../types';
|
||||
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';
|
||||
|
||||
export const HostView: React.FC = () => {
|
||||
const {
|
||||
gameState, activeGameName, players, teams, buzzQueue, questions, games,
|
||||
gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl, apiKey,
|
||||
approvePlayer, startGame, startCountdown, openBuzzers,
|
||||
resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame,
|
||||
createGame, updateGame, deleteGame, loadGameToLive
|
||||
createGame, updateGame, deleteGame, loadGameToLive, updateSettings
|
||||
} = useGame();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY'>('GAME');
|
||||
const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY' | 'SETTINGS'>('GAME');
|
||||
|
||||
// Library State
|
||||
const [editingGameId, setEditingGameId] = useState<string | null>(null);
|
||||
@@ -26,10 +26,21 @@ export const HostView: React.FC = () => {
|
||||
const [isAddingQuestion, setIsAddingQuestion] = useState(false);
|
||||
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 isPreGame = gameState.currentQuestionIndex === -1;
|
||||
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;
|
||||
|
||||
// --- Handlers ---
|
||||
@@ -44,7 +55,7 @@ export const HostView: React.FC = () => {
|
||||
const handleAiGenerate = async (gameId: string) => {
|
||||
if (!aiTopic) return;
|
||||
setIsGenerating(true);
|
||||
const newQuestions = await generateQuestions(aiTopic);
|
||||
const newQuestions = await generateQuestions(aiTopic, apiKey); // Pass apiKey
|
||||
if (newQuestions.length > 0) {
|
||||
const game = games.find(g => g.id === gameId);
|
||||
if (game) {
|
||||
@@ -53,7 +64,7 @@ export const HostView: React.FC = () => {
|
||||
alert(`Added ${newQuestions.length} questions!`);
|
||||
setAiTopic('');
|
||||
} else {
|
||||
alert("Failed to generate. Check API Key or try again.");
|
||||
alert("Failed to generate. Check API Key in Settings.");
|
||||
}
|
||||
setIsGenerating(false);
|
||||
};
|
||||
@@ -73,7 +84,6 @@ export const HostView: React.FC = () => {
|
||||
if (!manualQ.text || !manualQ.answer) return;
|
||||
const game = games.find(g => g.id === gameId);
|
||||
if (game) {
|
||||
// Detect Media Type
|
||||
let mediaType: 'image' | 'video' | undefined = undefined;
|
||||
if (manualQ.mediaUrl) {
|
||||
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')) {
|
||||
mediaType = 'video';
|
||||
} else {
|
||||
// Fallback assumption for uploads vs links
|
||||
mediaType = 'image';
|
||||
}
|
||||
}
|
||||
@@ -109,12 +118,16 @@ export const HostView: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleLoadGame = (gameId: string) => {
|
||||
// Removed confirm() as it can block execution or be annoying
|
||||
loadGameToLive(gameId);
|
||||
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 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>
|
||||
<h1 className="font-bold text-lg text-slate-700">Host Dashboard</h1>
|
||||
</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">
|
||||
<Library size={16} className="text-indigo-600" />
|
||||
<span className="text-xs text-indigo-400 font-bold uppercase">ACTIVE GAME:</span>
|
||||
@@ -155,7 +167,7 @@ export const HostView: React.FC = () => {
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<nav className="p-4 space-y-2">
|
||||
<button
|
||||
@@ -176,6 +188,14 @@ export const HostView: React.FC = () => {
|
||||
>
|
||||
<Library className="w-4 h-4" /> Game Library
|
||||
</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>
|
||||
|
||||
<div className="mt-auto p-4 border-t border-slate-800">
|
||||
@@ -189,7 +209,6 @@ export const HostView: React.FC = () => {
|
||||
{/* GAME CONTROL TAB */}
|
||||
{activeTab === 'GAME' && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* LAST QUESTION WARNING */}
|
||||
{isLastQuestionPhase && !isGameOver && (
|
||||
<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!
|
||||
@@ -236,15 +255,13 @@ export const HostView: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls based on Phase */}
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap gap-4 border-t border-slate-100 pt-6">
|
||||
{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">
|
||||
START GAME
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Button logic for moving to next question OR finishing game */}
|
||||
{(gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) && !isGameOver && (
|
||||
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">
|
||||
@@ -256,23 +273,18 @@ export const HostView: React.FC = () => {
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{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">
|
||||
OPEN BUZZERS
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Skip/Reveal Button */}
|
||||
{(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">
|
||||
<SkipForward className="w-4 h-4" /> Reveal Answer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{gameState.phase === GamePhase.ANSWER_REVEAL && (
|
||||
<div className="flex w-full justify-between items-center">
|
||||
{/* UNDO BUTTON */}
|
||||
{currentWinner && (
|
||||
<button
|
||||
onClick={() => rectifyBuzz(currentWinner.id, 'WRONG')}
|
||||
@@ -282,7 +294,6 @@ export const HostView: React.FC = () => {
|
||||
Undo Correct ({currentWinner.name})
|
||||
</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">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
{isLastQuestionPhase ? 'Finish Game' : 'Show Scores'}
|
||||
@@ -292,7 +303,7 @@ export const HostView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QUESTION QUEUE PREVIEW (Added for better Host visibility) */}
|
||||
{/* Queue & Buzzer Panels */}
|
||||
{isPreGame && questions.length > 0 && (
|
||||
<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">
|
||||
@@ -309,8 +320,6 @@ export const HostView: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buzzer Queue Adjudication */}
|
||||
{buzzQueue.length > 0 && (
|
||||
<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">
|
||||
@@ -321,7 +330,6 @@ export const HostView: React.FC = () => {
|
||||
{buzzQueue.map((buzz, idx) => {
|
||||
const player = players.find(p => p.id === buzz.playerId);
|
||||
const isCurrent = idx === 0 && buzz.status === 'PENDING';
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -331,7 +339,6 @@ export const HostView: React.FC = () => {
|
||||
<div className="text-xs text-slate-500">{(buzz.timestamp % 10000)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{buzz.status === 'PENDING' ? (
|
||||
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'}`}>
|
||||
{buzz.status}
|
||||
</span>
|
||||
{/* Correction Button for Wrong Answers */}
|
||||
{buzz.status === 'WRONG' && (
|
||||
<button
|
||||
onClick={() => rectifyBuzz(buzz.playerId, 'CORRECT')}
|
||||
@@ -379,7 +385,58 @@ export const HostView: React.FC = () => {
|
||||
</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' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200">
|
||||
<table className="w-full text-left">
|
||||
@@ -410,225 +467,147 @@ export const HostView: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GAME LIBRARY TAB */}
|
||||
{activeTab === 'LIBRARY' && (
|
||||
{activeTab === 'LIBRARY' && !editingGameId && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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">
|
||||
{!editingGameId ? (
|
||||
/* MODE: LIST GAMES */
|
||||
<>
|
||||
<div className="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
|
||||
type="text"
|
||||
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={() => setEditingGameId(null)} className="flex items-center gap-2 text-slate-500 font-bold mb-4"><ArrowLeft size={16}/> Back to Library</button>
|
||||
<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 gap-2">
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<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
|
||||
onClick={handleCreateGame}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded text-sm font-bold hover:bg-indigo-700"
|
||||
onClick={() => handleAddManualQuestion(editingGameId)}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm font-bold"
|
||||
>
|
||||
Create
|
||||
Save Question
|
||||
</button>
|
||||
</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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button onClick={() => setEditingGameId(null)} className="p-2 hover:bg-slate-200 rounded-full">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<input
|
||||
value={game.name}
|
||||
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>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{games.find(g => g.id === editingGameId)?.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="flex gap-2">
|
||||
<input
|
||||
type="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 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>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteQuestion(editingGameId, 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from
|
||||
import { Trophy, Zap, Users } from 'lucide-react';
|
||||
|
||||
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];
|
||||
|
||||
// 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">
|
||||
JOIN THE QUIZ
|
||||
</h1>
|
||||
<div className="bg-white p-4 inline-block rounded-xl mb-8">
|
||||
<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" />
|
||||
<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=${encodeURIComponent(joinUrl)}`} alt="QR Code" className="w-48 h-48" />
|
||||
</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">
|
||||
{players.filter(p => p.isApproved).map(p => (
|
||||
|
||||
+264
-94
@@ -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 type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
|
||||
|
||||
interface GameContextType {
|
||||
// State
|
||||
gameState: GameState;
|
||||
activeGameName: string; // New: Track which game is loaded
|
||||
activeGameName: string;
|
||||
joinUrl: string;
|
||||
apiKey: string;
|
||||
players: Player[];
|
||||
teams: Team[];
|
||||
questions: Question[]; // The ACTIVE questions currently being played
|
||||
games: Game[]; // The LIBRARY of saved games
|
||||
questions: Question[];
|
||||
games: Game[];
|
||||
buzzQueue: BuzzerLog[];
|
||||
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
|
||||
addPlayer: (name: string, teamName: string) => void;
|
||||
approvePlayer: (playerId: string) => void;
|
||||
removePlayer: (playerId: string) => void; // New action
|
||||
removePlayer: (playerId: string) => void;
|
||||
startGame: () => void;
|
||||
startCountdown: () => void;
|
||||
openBuzzers: () => void;
|
||||
@@ -35,10 +45,13 @@ interface GameContextType {
|
||||
updateGame: (gameId: string, updates: Partial<Game>) => void;
|
||||
deleteGame: (gameId: string) => void;
|
||||
loadGameToLive: (gameId: string) => void;
|
||||
setJoinUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextType | undefined>(undefined);
|
||||
|
||||
const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php';
|
||||
|
||||
// Initial Mock Data
|
||||
const INITIAL_QUESTIONS: Question[] = [
|
||||
{ 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 }) => {
|
||||
const [isHost, setIsHost] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// --- GAME STATE ---
|
||||
const [gameState, setGameState] = useState<GameState>({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
@@ -76,6 +94,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
});
|
||||
|
||||
const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo");
|
||||
const [joinUrl, setJoinUrl] = useState<string>('');
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
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 [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||
|
||||
// Helper: Find or create team
|
||||
const getOrCreateTeam = (name: string) => {
|
||||
const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) return existing;
|
||||
const newTeam: Team = { id: crypto.randomUUID(), name, score: 0 };
|
||||
setTeams(prev => [...prev, newTeam]);
|
||||
return newTeam;
|
||||
const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey });
|
||||
useEffect(() => {
|
||||
stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey };
|
||||
}, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey]);
|
||||
|
||||
// Initial Fetch for Clients (Spectators/Players) to get Join URL without login
|
||||
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 team = getOrCreateTeam(teamName);
|
||||
const newPlayer: Player = {
|
||||
id: crypto.randomUUID(),
|
||||
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 tempId = crypto.randomUUID();
|
||||
setCurrentPlayerId(tempId);
|
||||
sendIntent('JOIN', { name, teamName, tempId });
|
||||
};
|
||||
|
||||
const removePlayer = (playerId: string) => {
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
if (currentPlayerId === playerId) {
|
||||
setCurrentPlayerId(null);
|
||||
if (isHost) {
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
} else {
|
||||
sendIntent('LEAVE', { playerId });
|
||||
if (currentPlayerId === playerId) setCurrentPlayerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const approvePlayer = (playerId: string) => {
|
||||
if (!isHost) return;
|
||||
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 = () => {
|
||||
// Directly start Q1 Countdown, skipping initial Leaderboard
|
||||
if (!isHost) return;
|
||||
setGameState({
|
||||
phase: GamePhase.COUNTDOWN,
|
||||
currentQuestionIndex: 0,
|
||||
@@ -133,16 +338,15 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const startCountdown = () => {
|
||||
if (!isHost) return;
|
||||
let nextIndex = gameState.currentQuestionIndex;
|
||||
if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) {
|
||||
nextIndex = gameState.currentQuestionIndex + 1;
|
||||
}
|
||||
|
||||
if (nextIndex >= questions.length) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS }));
|
||||
return;
|
||||
}
|
||||
|
||||
setGameState({
|
||||
phase: GamePhase.COUNTDOWN,
|
||||
currentQuestionIndex: nextIndex,
|
||||
@@ -153,6 +357,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHost) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (gameState.phase === GamePhase.COUNTDOWN) {
|
||||
if (gameState.countdownValue > 0) {
|
||||
@@ -164,60 +369,32 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [gameState.phase, gameState.countdownValue]);
|
||||
}, [gameState.phase, gameState.countdownValue, isHost]);
|
||||
|
||||
const openBuzzers = () => {
|
||||
if (!isHost) return;
|
||||
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) => {
|
||||
if (!isHost) return;
|
||||
const currentQ = questions[gameState.currentQuestionIndex];
|
||||
const player = players.find(p => p.id === playerId);
|
||||
|
||||
// Update Stats logic
|
||||
setPlayers(prev => prev.map(p => {
|
||||
if (p.id !== playerId) return p;
|
||||
|
||||
let newStats = { ...p.stats };
|
||||
// Increment attempts (total buzzes)
|
||||
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) {
|
||||
const reactionTime = Date.now() - gameState.buzzerOpenTimestamp;
|
||||
if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) {
|
||||
newStats.bestReactionTime = reactionTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
newStats.correctAnswers += 1;
|
||||
return {
|
||||
...p,
|
||||
score: p.score + currentQ.points,
|
||||
stats: newStats
|
||||
};
|
||||
return { ...p, score: p.score + currentQ.points, 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') => {
|
||||
if (!isHost) return;
|
||||
const currentQ = questions[gameState.currentQuestionIndex];
|
||||
const player = players.find(p => p.id === playerId);
|
||||
if (!player || !player.teamId) return;
|
||||
@@ -250,37 +428,31 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') {
|
||||
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 ? {
|
||||
...p,
|
||||
score: p.score + points,
|
||||
stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
|
||||
...p, score: p.score + points, stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
|
||||
} : p));
|
||||
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||
}
|
||||
else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') {
|
||||
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t));
|
||||
setPlayers(prev => prev.map(p => p.id === playerId ? {
|
||||
...p,
|
||||
score: p.score - points,
|
||||
stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
|
||||
...p, score: p.score - points, stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
|
||||
} : p));
|
||||
|
||||
const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING');
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
|
||||
...prev, phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const skipQuestion = () => {
|
||||
if (!isHost) return;
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
const nextPhase = () => {
|
||||
if (!isHost) return;
|
||||
if (gameState.phase === GamePhase.ANSWER_REVEAL) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD }));
|
||||
}
|
||||
@@ -298,34 +470,22 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const resetGame = () => {
|
||||
// "Soft Reset" - Keep players and teams, but reset their scores and stats
|
||||
if (!isHost) return;
|
||||
setGameState({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
countdownValue: 3,
|
||||
buzzerOpenTimestamp: null
|
||||
});
|
||||
|
||||
// Reset Scores and Stats for Players
|
||||
setPlayers(prev => prev.map(p => ({
|
||||
...p,
|
||||
score: 0,
|
||||
stats: {
|
||||
correctAnswers: 0,
|
||||
totalBuzzes: 0,
|
||||
bestReactionTime: null
|
||||
}
|
||||
...p, score: 0, stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
|
||||
})));
|
||||
|
||||
// Reset Team Scores
|
||||
setTeams(prev => prev.map(t => ({ ...t, score: 0 })));
|
||||
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
// --- LIBRARY ACTIONS ---
|
||||
|
||||
const createGame = (name: string) => {
|
||||
if (!isHost) return;
|
||||
const newGame: Game = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
@@ -336,19 +496,21 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const updateGame = (gameId: string, updates: Partial<Game>) => {
|
||||
if (!isHost) return;
|
||||
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g));
|
||||
};
|
||||
|
||||
const deleteGame = (gameId: string) => {
|
||||
if (!isHost) return;
|
||||
setGames(prev => prev.filter(g => g.id !== gameId));
|
||||
};
|
||||
|
||||
const loadGameToLive = (gameId: string) => {
|
||||
if (!isHost) return;
|
||||
const game = games.find(g => g.id === gameId);
|
||||
if (game) {
|
||||
setQuestionsQuestions([...game.questions]);
|
||||
setActiveGameName(game.name);
|
||||
|
||||
setGameState({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
@@ -356,7 +518,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
buzzerOpenTimestamp: null
|
||||
});
|
||||
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={{
|
||||
gameState,
|
||||
activeGameName,
|
||||
joinUrl,
|
||||
apiKey,
|
||||
players,
|
||||
teams,
|
||||
questions,
|
||||
games,
|
||||
buzzQueue,
|
||||
currentPlayerId,
|
||||
isHost,
|
||||
isAuthenticated,
|
||||
setIsHost,
|
||||
login,
|
||||
updateSettings,
|
||||
isSyncing,
|
||||
addPlayer,
|
||||
approvePlayer,
|
||||
removePlayer,
|
||||
@@ -388,7 +557,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
createGame,
|
||||
updateGame,
|
||||
deleteGame,
|
||||
loadGameToLive
|
||||
loadGameToLive,
|
||||
setJoinUrl
|
||||
}}>
|
||||
{children}
|
||||
</GameContext.Provider>
|
||||
|
||||
@@ -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';
|
||||
@@ -1,12 +1,9 @@
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import type { Question } from "../types";
|
||||
|
||||
const generateQuestions = async (topic: string, count: number = 5): Promise<Question[]> => {
|
||||
// Use process.env.API_KEY as per guidelines.
|
||||
const apiKey = process.env.API_KEY;
|
||||
|
||||
const generateQuestions = async (topic: string, apiKey: string, count: number = 5): Promise<Question[]> => {
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
export const GamePhase = {
|
||||
LOBBY: 'LOBBY',
|
||||
COUNTDOWN: 'COUNTDOWN',
|
||||
@@ -66,4 +67,11 @@ export interface GameState {
|
||||
currentQuestionIndex: number;
|
||||
countdownValue: number;
|
||||
buzzerOpenTimestamp: number | null; // To calculate reaction time
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronization Types
|
||||
export interface PlayerIntent {
|
||||
type: 'JOIN' | 'BUZZ' | 'LEAVE';
|
||||
payload: any;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user