@@ -189,24 +209,6 @@ export const HostView: React.FC = () => {
{/* GAME CONTROL TAB */}
{activeTab === 'GAME' && (
- {/* Session Settings */}
-
-
-
-
-
-
- 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://..."
- />
-
-
-
- {/* LAST QUESTION WARNING */}
{isLastQuestionPhase && !isGameOver && (
⚠️ THIS IS THE LAST QUESTION!
@@ -253,15 +255,13 @@ export const HostView: React.FC = () => {
)}
- {/* Controls based on Phase */}
+ {/* Controls */}
{gameState.phase === GamePhase.LOBBY && (
)}
-
- {/* Button logic for moving to next question OR finishing game */}
{(gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) && !isGameOver && (
isLastQuestionPhase && gameState.phase === GamePhase.LEADERBOARD ? (
)
)}
-
{gameState.phase === GamePhase.QUESTION_DISPLAY && (
)}
-
- {/* Skip/Reveal Button */}
{(gameState.phase === GamePhase.QUESTION_DISPLAY || gameState.phase === GamePhase.BUZZER_OPEN || gameState.phase === GamePhase.ADJUDICATION) && (
)}
-
{gameState.phase === GamePhase.ANSWER_REVEAL && (
- {/* UNDO BUTTON */}
{currentWinner && (
)}
-
- {/* QUESTION QUEUE PREVIEW (Added for better Host visibility) */}
+ {/* Queue & Buzzer Panels */}
{isPreGame && questions.length > 0 && (
@@ -326,8 +320,6 @@ export const HostView: React.FC = () => {
)}
-
- {/* Buzzer Queue Adjudication */}
{buzzQueue.length > 0 && (
@@ -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 (
@@ -348,7 +339,6 @@ export const HostView: React.FC = () => {
{(buzz.timestamp % 10000)}ms
-
{buzz.status === 'PENDING' ? (
isCurrent ? (
@@ -374,7 +364,6 @@ export const HostView: React.FC = () => {
{buzz.status}
- {/* Correction Button for Wrong Answers */}
{buzz.status === 'WRONG' && (
)}
- {/* PLAYERS TAB */}
+ {/* SETTINGS TAB */}
+ {activeTab === 'SETTINGS' && (
+
+
Host Configuration
+
+
+
+
+
setSettingsForm({ ...settingsForm, joinUrl: e.target.value })}
+ placeholder="https://your-site.com/#player"
+ />
+
This URL is encoded into the QR code shown to players.
+
+
+
+
+
setSettingsForm({ ...settingsForm, apiKey: e.target.value })}
+ placeholder="AIzaSy..."
+ />
+
Required for AI question generation.
+
+
+
+
+ setSettingsForm({ ...settingsForm, newPassword: e.target.value })}
+ placeholder="Leave empty to keep current password"
+ />
+
+
+
+
+
+ )}
+
+ {/* PLAYERS & LIBRARY TABS */}
{activeTab === 'PLAYERS' && (
@@ -427,225 +467,147 @@ export const HostView: React.FC = () => {
)}
-
- {/* GAME LIBRARY TAB */}
- {activeTab === 'LIBRARY' && (
+ {activeTab === 'LIBRARY' && !editingGameId && (
+
+
+
Your Games
+
+ setNewGameName(e.target.value)} placeholder="New Game Name..." className="px-4 py-2 rounded border border-slate-300 text-sm text-black bg-white" />
+
+
+
+ {games.map(game => (
+
+
{game.name}
+
{game.questions.length} Questions
+
+
+
+
+
+
+ ))}
+
+ )}
+ {activeTab === 'LIBRARY' && editingGameId && (
- {!editingGameId ? (
- /* MODE: LIST GAMES */
- <>
-
-
Your Games
-
-
setNewGameName(e.target.value)}
- placeholder="New Game Name..."
- className="px-4 py-2 rounded border border-slate-300 text-sm text-black bg-white"
- />
+
+
+
AI Generator
+
+ setAiTopic(e.target.value)} placeholder="Topic..." className="flex-1 px-4 py-2 rounded text-slate-900 bg-white outline-none" />
+
+
+
+
+
+
+
Questions
+
+
+
+ {isAddingQuestion && (
+
+
+
+
+ setManualQ({...manualQ, text: e.target.value})}
+ />
+
+
+
+ setManualQ({...manualQ, answer: e.target.value})}
+ />
+
+
+
+ setManualQ({...manualQ, points: Number(e.target.value)})}
+ />
+
+
+
+ setManualQ({...manualQ, category: e.target.value})}
+ />
+
+
+
+
+
Supports YouTube links or Image Uploads
+
+
+
+
-
-
- {games.map(game => (
-
-
-
-
-
-
-
-
-
-
-
{game.name}
-
{game.questions.length} Questions
-
-
- ))}
-
- >
- ) : (
- /* MODE: EDIT GAME */
- (() => {
- const game = games.find(g => g.id === editingGameId);
- if (!game) return null;
+ )}
- return (
-
-
-
-
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"
- />
-
{game.questions.length} Questions
-
-
- {/* AI Generator for this game */}
-
-
-
-
AI Question Generator
+
+ {games.find(g => g.id === editingGameId)?.questions.map((q, i) => (
+ -
+
+
+ Q{i+1}
+ {q.category}
+ {q.points}pts
-
-
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"
- />
-
+
+ {q.mediaUrl && }
+ {q.text}
+
A: {q.answer}
-
-
-
-
Questions
-
-
-
- {/* Add Manual Form */}
- {isAddingQuestion && (
-
-
-
-
- setManualQ({...manualQ, text: e.target.value})}
- />
-
-
-
- setManualQ({...manualQ, answer: e.target.value})}
- />
-
-
-
- setManualQ({...manualQ, points: Number(e.target.value)})}
- />
-
-
-
- setManualQ({...manualQ, category: e.target.value})}
- />
-
-
-
-
-
Supports YouTube links or Image Uploads
-
-
-
-
-
-
-
- )}
-
-
- {game.questions.length === 0 && (
- - No questions yet. Add some manually or use AI!
- )}
- {game.questions.map((q, i) => (
- -
-
-
- Q{i+1}
- {q.category}
- {q.points}pts
-
-
- {q.mediaUrl && }
- {q.text}
-
-
A: {q.answer}
-
-
-
- ))}
-
-
-
- );
- })()
- )}
+
+
+ ))}
+
+
)}
diff --git a/context/GameContext.tsx b/context/GameContext.tsx
index 1b87f9c..a974edd 100644
--- a/context/GameContext.tsx
+++ b/context/GameContext.tsx
@@ -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
;
+ updateSettings: (newApiKey: string, newJoinUrl: string, newPassword?: string) => Promise;
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) => void;
deleteGame: (gameId: string) => void;
loadGameToLive: (gameId: string) => void;
+ setJoinUrl: (url: string) => void;
}
const GameContext = createContext(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("General Knowledge Demo");
const [joinUrl, setJoinUrl] = useState('');
+ const [apiKey, setApiKey] = useState('');
const [players, setPlayers] = useState([]);
const [teams, setTeams] = useState([]);
const [questions, setQuestionsQuestions] = useState(INITIAL_QUESTIONS);
@@ -100,42 +103,83 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [buzzQueue, setBuzzQueue] = useState([]);
const [currentPlayerId, setCurrentPlayerId] = useState(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 => {
+ 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;
@@ -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}
@@ -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;
-};
+};
\ No newline at end of file
diff --git a/database.sql b/database.sql
index 6966b39..44827f7 100644
--- a/database.sql
+++ b/database.sql
@@ -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';
diff --git a/services/geminiService.ts b/services/geminiService.ts
index 3888486..175e29c 100644
--- a/services/geminiService.ts
+++ b/services/geminiService.ts
@@ -1,12 +1,9 @@
import { GoogleGenAI, Type } from "@google/genai";
import type { Question } from "../types";
-const generateQuestions = async (topic: string, count: number = 5): Promise => {
- // 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 => {
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 [];
}