feat: Implement host authentication and settings management
Introduces a new authentication flow for the host, requiring a password to access host-specific features. This commit also adds a dedicated settings section within the host view, allowing administrators to configure essential application parameters such as the API key for AI question generation and the join URL for players. The backend has been updated to include a new `settings` table in the database to persist these configurations. The Gemini service is refactored to accept the API key as a parameter, enhancing flexibility and security. UI components like `HostView` and `App.tsx` are modified to integrate the new authentication and settings management functionalities. Key changes include: - Password-based authentication for host access. - A new settings interface for API key and join URL management. - Database schema update with a `settings` table. - Refactoring `geminiService` to accept API key. - UI adjustments for login and settings screens.
This commit is contained in:
@@ -1,24 +1,68 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import { GameProvider, useGame } from './context/GameContext';
|
||||
import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw } 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 PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView })));
|
||||
const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView })));
|
||||
|
||||
// Inner Component to access Context
|
||||
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, isHost } = useGame();
|
||||
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');
|
||||
setIsHost(true); // Promote this session to Host Authority
|
||||
setIsHost(true);
|
||||
}
|
||||
else if (hash === '#player') {
|
||||
setView('PLAYER');
|
||||
@@ -42,7 +86,8 @@ const AppContent: 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 />;
|
||||
@@ -127,4 +172,4 @@ const NavButton: React.FC<NavButtonProps> = ({ active, onClick, icon, label }) =
|
||||
</button>
|
||||
);
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -22,7 +22,6 @@ $options = [
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
} catch (\PDOException $e) {
|
||||
// Fallback for when DB isn't configured yet so app doesn't crash completely
|
||||
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
@@ -43,21 +42,24 @@ try {
|
||||
echo $row['game_data'] ?: '{}';
|
||||
}
|
||||
elseif ($action === 'getIntents') {
|
||||
// Transaction to read and delete to ensure processed once
|
||||
$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"); // Clear queue after reading
|
||||
$pdo->exec("DELETE FROM player_intents");
|
||||
}
|
||||
$pdo->commit();
|
||||
|
||||
// Parse payloads
|
||||
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') {
|
||||
@@ -73,6 +75,52 @@ try {
|
||||
$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);
|
||||
|
||||
+219
-257
@@ -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, QrCode } 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, joinUrl,
|
||||
setJoinUrl, approvePlayer, startGame, startCountdown, openBuzzers,
|
||||
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,24 +209,6 @@ export const HostView: React.FC = () => {
|
||||
{/* GAME CONTROL TAB */}
|
||||
{activeTab === 'GAME' && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Session Settings */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 mb-6 flex items-center gap-4">
|
||||
<div className="bg-indigo-100 p-2 rounded-lg text-indigo-600">
|
||||
<QrCode size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase">Player Join URL (for QR Code)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={joinUrl}
|
||||
onChange={(e) => setJoinUrl(e.target.value)}
|
||||
className="w-full font-mono text-sm border-b border-slate-300 focus:border-indigo-600 outline-none bg-transparent py-1 text-slate-800"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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!
|
||||
@@ -253,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">
|
||||
@@ -273,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')}
|
||||
@@ -299,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'}
|
||||
@@ -309,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">
|
||||
@@ -326,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">
|
||||
@@ -338,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">
|
||||
@@ -348,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 ? (
|
||||
@@ -374,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')}
|
||||
@@ -396,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">
|
||||
@@ -427,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>
|
||||
)}
|
||||
|
||||
|
||||
+83
-66
@@ -1,12 +1,13 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { GamePhase } from '../types';
|
||||
import type { Player, Team, Question, BuzzerLog, GameState, Game, PlayerIntent } from '../types';
|
||||
import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
|
||||
|
||||
interface GameContextType {
|
||||
// State
|
||||
gameState: GameState;
|
||||
activeGameName: string;
|
||||
joinUrl: string;
|
||||
apiKey: string;
|
||||
players: Player[];
|
||||
teams: Team[];
|
||||
questions: Question[];
|
||||
@@ -14,13 +15,15 @@ interface GameContextType {
|
||||
buzzQueue: BuzzerLog[];
|
||||
currentPlayerId: string | null;
|
||||
|
||||
// Sync Status
|
||||
// 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
|
||||
setJoinUrl: (url: string) => void;
|
||||
addPlayer: (name: string, teamName: string) => void;
|
||||
approvePlayer: (playerId: string) => void;
|
||||
removePlayer: (playerId: string) => void;
|
||||
@@ -42,13 +45,11 @@ 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);
|
||||
|
||||
// --- API CONFIG ---
|
||||
// Assuming api.php is at the root. Change if in a subfolder.
|
||||
// If using Vite development server, you might need to point this to your actual PHP server URL.
|
||||
const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php';
|
||||
|
||||
// Initial Mock Data
|
||||
@@ -81,6 +82,7 @@ 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 ---
|
||||
@@ -93,6 +95,7 @@ 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);
|
||||
@@ -100,42 +103,83 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]);
|
||||
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||
|
||||
// Refs for accessing state inside intervals without dependencies
|
||||
const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl });
|
||||
const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey });
|
||||
useEffect(() => {
|
||||
stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl };
|
||||
}, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl]);
|
||||
stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey };
|
||||
}, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl, apiKey]);
|
||||
|
||||
// Set default Join URL on mount
|
||||
// Initial Fetch for Clients (Spectators/Players) to get Join URL without login
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = `${window.location.origin}${window.location.pathname}#player`;
|
||||
setJoinUrl(url);
|
||||
}
|
||||
}, []);
|
||||
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) {
|
||||
// --- HOST LOGIC: PULL INTENTS -> PROCESS -> PUSH STATE ---
|
||||
|
||||
// 1. Fetch Intents
|
||||
if (isHost && isAuthenticated) {
|
||||
// --- HOST LOGIC ---
|
||||
const response = await fetch(`${API_URL}?action=getIntents`);
|
||||
const intents = await response.json();
|
||||
|
||||
let stateChanged = false;
|
||||
|
||||
// 2. Process Intents
|
||||
if (Array.isArray(intents) && intents.length > 0) {
|
||||
console.log("Processing Intents:", intents);
|
||||
intents.forEach((item: any) => {
|
||||
const { type, payload } = item;
|
||||
|
||||
if (type === 'JOIN') {
|
||||
// Logic extracted from addPlayer
|
||||
const { name, teamName, tempId } = payload;
|
||||
const currentTeams = stateRef.current.teams;
|
||||
const existingTeam = currentTeams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
|
||||
@@ -147,7 +191,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
teamId = newTeam.id;
|
||||
}
|
||||
|
||||
// Check if player already exists to prevent dupes
|
||||
const exists = stateRef.current.players.some(p => p.name === name && p.teamId === teamId);
|
||||
if (!exists && teamId) {
|
||||
const newPlayer: Player = {
|
||||
@@ -155,12 +198,11 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
name,
|
||||
teamId,
|
||||
score: 0,
|
||||
isApproved: true, // Auto approve for now for smoother UX
|
||||
isApproved: true,
|
||||
stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
|
||||
};
|
||||
setPlayers(prev => [...prev, newPlayer]);
|
||||
}
|
||||
stateChanged = true;
|
||||
}
|
||||
else if (type === 'BUZZ') {
|
||||
const { playerId } = payload;
|
||||
@@ -176,24 +218,20 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
setBuzzQueue(prev => {
|
||||
const updated = [...prev, newBuzz];
|
||||
if (updated.length === 1) { // Only force update phase if it was the first buzz
|
||||
if (updated.length === 1) {
|
||||
setGameState(gs => ({ ...gs, phase: GamePhase.ADJUDICATION }));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
else if (type === 'LEAVE') {
|
||||
const { playerId } = payload;
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
stateChanged = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Push State (Always push to keep server alive with latest data, or at least periodically)
|
||||
// For now, we push every cycle to ensure consistency.
|
||||
const fullState = {
|
||||
gameState: stateRef.current.gameState,
|
||||
players: stateRef.current.players,
|
||||
@@ -201,7 +239,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
questions: stateRef.current.questions,
|
||||
activeGameName: stateRef.current.activeGameName,
|
||||
buzzQueue: stateRef.current.buzzQueue,
|
||||
joinUrl: stateRef.current.joinUrl
|
||||
// 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`, {
|
||||
@@ -211,7 +249,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
});
|
||||
|
||||
} else {
|
||||
// --- CLIENT LOGIC: PULL STATE -> UPDATE LOCAL ---
|
||||
// --- CLIENT LOGIC ---
|
||||
const response = await fetch(`${API_URL}?action=getState`);
|
||||
const remoteState = await response.json();
|
||||
|
||||
@@ -222,7 +260,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setQuestionsQuestions(remoteState.questions || []);
|
||||
setActiveGameName(remoteState.activeGameName || "");
|
||||
setBuzzQueue(remoteState.buzzQueue || []);
|
||||
setJoinUrl(remoteState.joinUrl || "");
|
||||
// Clients don't receive joinUrl from game_state loop, they get it from getPublicSettings
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -230,20 +268,10 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, 500); // 500ms polling rate
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(syncInterval);
|
||||
}, [isHost]); // Re-run effect if role changes
|
||||
|
||||
|
||||
// --- HOST HELPERS (Local) ---
|
||||
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;
|
||||
};
|
||||
}, [isHost, isAuthenticated]);
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
@@ -260,14 +288,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const addPlayer = (name: string, teamName: string) => {
|
||||
// If Host, do it immediately (Old Logic) - Although now logic is in Sync Loop
|
||||
// To make it unified, Host also sends intent OR we just trust the sync loop.
|
||||
// BUT, for "Host-Added Players", we can just do it locally.
|
||||
// For "Client Joining", they send intent.
|
||||
|
||||
// PLAYER-SIDE LOGIC:
|
||||
const tempId = crypto.randomUUID();
|
||||
setCurrentPlayerId(tempId); // Set ID immediately so UI shows "Waiting..."
|
||||
setCurrentPlayerId(tempId);
|
||||
sendIntent('JOIN', { name, teamName, tempId });
|
||||
};
|
||||
|
||||
@@ -275,7 +297,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
if (isHost) {
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
} else {
|
||||
// Player leaving
|
||||
sendIntent('LEAVE', { playerId });
|
||||
if (currentPlayerId === playerId) setCurrentPlayerId(null);
|
||||
}
|
||||
@@ -288,7 +309,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const handleBuzz = (playerId: string) => {
|
||||
if (isHost) {
|
||||
// Host manual buzz? Rare, but allowed.
|
||||
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
|
||||
if (buzzQueue.find(b => b.playerId === playerId)) return;
|
||||
const newBuzz: BuzzerLog = {
|
||||
@@ -302,12 +322,9 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
|
||||
}
|
||||
} else {
|
||||
// Client buzz
|
||||
sendIntent('BUZZ', { playerId });
|
||||
}
|
||||
};
|
||||
|
||||
// --- HOST ONLY ACTIONS (No change needed, just guard them) ---
|
||||
|
||||
const startGame = () => {
|
||||
if (!isHost) return;
|
||||
@@ -339,7 +356,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
// Countdown Timer Effect (Runs on Host Only to drive state)
|
||||
useEffect(() => {
|
||||
if (!isHost) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -443,7 +459,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const playAudio = (url: string, start: number = 0, end?: number) => {
|
||||
// Audio is mostly local for now
|
||||
const audio = new Audio(url);
|
||||
audio.currentTime = start;
|
||||
audio.play();
|
||||
@@ -469,8 +484,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
// --- LIBRARY ACTIONS (Local to Host) ---
|
||||
|
||||
const createGame = (name: string) => {
|
||||
if (!isHost) return;
|
||||
const newGame: Game = {
|
||||
@@ -513,6 +526,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
gameState,
|
||||
activeGameName,
|
||||
joinUrl,
|
||||
apiKey,
|
||||
players,
|
||||
teams,
|
||||
questions,
|
||||
@@ -520,9 +534,11 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
buzzQueue,
|
||||
currentPlayerId,
|
||||
isHost,
|
||||
isAuthenticated,
|
||||
setIsHost,
|
||||
login,
|
||||
updateSettings,
|
||||
isSyncing,
|
||||
setJoinUrl,
|
||||
addPlayer,
|
||||
approvePlayer,
|
||||
removePlayer,
|
||||
@@ -541,7 +557,8 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
createGame,
|
||||
updateGame,
|
||||
deleteGame,
|
||||
loadGameToLive
|
||||
loadGameToLive,
|
||||
setJoinUrl
|
||||
}}>
|
||||
{children}
|
||||
</GameContext.Provider>
|
||||
@@ -552,4 +569,4 @@ export const useGame = () => {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) throw new Error("useGame must be used within a GameProvider");
|
||||
return context;
|
||||
};
|
||||
};
|
||||
+15
-1
@@ -14,5 +14,19 @@ CREATE TABLE IF NOT EXISTS `player_intents` (
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Initialize with one row
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user