diff --git a/App.tsx b/App.tsx index 25c7fde..85c8d06 100644 --- a/App.tsx +++ b/App.tsx @@ -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 ( +
+
+
+ +
+

Host Access

+
+ 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 &&

{error}

} + +
+
+
+ ); +}; + 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 ; + case 'HOST': + return isAuthenticated ? : ; case 'PLAYER': return ; case 'SPECTATOR': return ; default: return ; @@ -127,4 +172,4 @@ const NavButton: React.FC = ({ active, onClick, icon, label }) = ); -export default App; +export default App; \ No newline at end of file diff --git a/api.php b/api.php index 81e052d..acc701a 100644 --- a/api.php +++ b/api.php @@ -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); diff --git a/components/HostView.tsx b/components/HostView.tsx index 50bec51..dc23c27 100644 --- a/components/HostView.tsx +++ b/components/HostView.tsx @@ -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(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 = () => {
QM

Host Dashboard

- {/* Active Game Display */}
ACTIVE GAME: @@ -155,7 +167,7 @@ export const HostView: React.FC = () => {
- {/* Left Sidebar - Navigation & Soundboard */} + {/* Left Sidebar */}