Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a12341d4c | |||
| c5cf88491f |
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user