feat: Initialize QuizMaster Live project
This commit sets up the foundational structure for the QuizMaster Live application. It includes: - Initializing a new Vite project with React and TypeScript. - Configuring project dependencies and build tools (Vite, TypeScript). - Defining core application types for game state, players, questions, etc. - Setting up basic Tailwind CSS for styling and defining custom animations. - Integrating with the Google Gemini API for AI-powered question generation. - Configuring environment variables for API keys and defining the application's metadata. - Adding a README with setup instructions and local development guide.
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { GameProvider } from './context/GameContext';
|
||||||
|
import { HostView } from './components/HostView';
|
||||||
|
import { PlayerView } from './components/PlayerView';
|
||||||
|
import { SpectatorView } from './components/SpectatorView';
|
||||||
|
import { Monitor, Smartphone, LayoutDashboard } from 'lucide-react';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR');
|
||||||
|
|
||||||
|
// Simple hash routing for demo purposes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash === '#host') setView('HOST');
|
||||||
|
else if (hash === '#player') setView('PLAYER');
|
||||||
|
else setView('SPECTATOR');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleHashChange();
|
||||||
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => {
|
||||||
|
setView(newView);
|
||||||
|
window.location.hash = newView.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = () => {
|
||||||
|
switch (view) {
|
||||||
|
case 'HOST': return <HostView />;
|
||||||
|
case 'PLAYER': return <PlayerView />;
|
||||||
|
case 'SPECTATOR': return <SpectatorView />;
|
||||||
|
default: return <SpectatorView />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameProvider>
|
||||||
|
<div className="flex flex-col h-screen bg-slate-950 overflow-hidden">
|
||||||
|
{/* Top Navigation Bar */}
|
||||||
|
<nav className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shadow-md shrink-0 z-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center font-black italic text-white shadow-lg border border-white/10">Q</div>
|
||||||
|
<span className="font-bold text-lg tracking-wide text-slate-100 hidden sm:block">QuizMaster Live</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex bg-slate-800 p-1 rounded-lg border border-slate-700">
|
||||||
|
<NavButton
|
||||||
|
active={view === 'SPECTATOR'}
|
||||||
|
onClick={() => navigate('SPECTATOR')}
|
||||||
|
icon={<Monitor size={16} />}
|
||||||
|
label="Spectator"
|
||||||
|
/>
|
||||||
|
<NavButton
|
||||||
|
active={view === 'HOST'}
|
||||||
|
onClick={() => navigate('HOST')}
|
||||||
|
icon={<LayoutDashboard size={16} />}
|
||||||
|
label="Host"
|
||||||
|
/>
|
||||||
|
<NavButton
|
||||||
|
active={view === 'PLAYER'}
|
||||||
|
onClick={() => navigate('PLAYER')}
|
||||||
|
icon={<Smartphone size={16} />}
|
||||||
|
label="Player"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 relative w-full h-full overflow-hidden">
|
||||||
|
{renderView()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GameProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NavButtonProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavButton: React.FC<NavButtonProps> = ({ active, onClick, icon, label }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-indigo-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="hidden md:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
|
||||||
<h1>Built with AI Studio</h2>
|
|
||||||
|
|
||||||
<p>The fastest path from prompt to production with Gemini.</p>
|
|
||||||
|
|
||||||
<a href="https://aistudio.google.com/apps">Start building</a>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/drive/1SqJ0PHYQKhJ_BvMYaBB4wGX5IO6EPMfG
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { GamePhase, Game, Question } from '../types';
|
||||||
|
import { Soundboard } from './Soundboard';
|
||||||
|
import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, Save, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw } from 'lucide-react';
|
||||||
|
import { generateQuestions } from '../services/geminiService';
|
||||||
|
|
||||||
|
export const HostView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
gameState, activeGameName, players, teams, buzzQueue, questions, games,
|
||||||
|
approvePlayer, startGame, startCountdown, openBuzzers,
|
||||||
|
resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame,
|
||||||
|
createGame, updateGame, deleteGame, loadGameToLive
|
||||||
|
} = useGame();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY'>('GAME');
|
||||||
|
|
||||||
|
// Library State
|
||||||
|
const [editingGameId, setEditingGameId] = useState<string | null>(null);
|
||||||
|
const [newGameName, setNewGameName] = useState('');
|
||||||
|
|
||||||
|
// Editor State
|
||||||
|
const [aiTopic, setAiTopic] = useState('');
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [isAddingQuestion, setIsAddingQuestion] = useState(false);
|
||||||
|
const [manualQ, setManualQ] = useState({ text: '', answer: '', points: 10, category: '', mediaUrl: '' });
|
||||||
|
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
const handleCreateGame = () => {
|
||||||
|
if (newGameName.trim()) {
|
||||||
|
createGame(newGameName);
|
||||||
|
setNewGameName('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiGenerate = async (gameId: string) => {
|
||||||
|
if (!aiTopic) return;
|
||||||
|
setIsGenerating(true);
|
||||||
|
const newQuestions = await generateQuestions(aiTopic);
|
||||||
|
if (newQuestions.length > 0) {
|
||||||
|
const game = games.find(g => g.id === gameId);
|
||||||
|
if (game) {
|
||||||
|
updateGame(gameId, { questions: [...game.questions, ...newQuestions] });
|
||||||
|
}
|
||||||
|
alert(`Added ${newQuestions.length} questions!`);
|
||||||
|
setAiTopic('');
|
||||||
|
} else {
|
||||||
|
alert("Failed to generate. Check API Key or try again.");
|
||||||
|
}
|
||||||
|
setIsGenerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setManualQ(prev => ({ ...prev, mediaUrl: reader.result as string }));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddManualQuestion = (gameId: string) => {
|
||||||
|
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)) {
|
||||||
|
mediaType = 'image';
|
||||||
|
} else if (manualQ.mediaUrl.includes('youtube') || manualQ.mediaUrl.includes('youtu.be')) {
|
||||||
|
mediaType = 'video';
|
||||||
|
} else {
|
||||||
|
// Fallback assumption for uploads vs links
|
||||||
|
mediaType = 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuestion: Question = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: manualQ.text,
|
||||||
|
answer: manualQ.answer,
|
||||||
|
points: Number(manualQ.points),
|
||||||
|
category: manualQ.category || 'General',
|
||||||
|
mediaUrl: manualQ.mediaUrl,
|
||||||
|
mediaType
|
||||||
|
};
|
||||||
|
updateGame(gameId, { questions: [...game.questions, newQuestion] });
|
||||||
|
setManualQ({ text: '', answer: '', points: 10, category: '', mediaUrl: '' });
|
||||||
|
setIsAddingQuestion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteQuestion = (gameId: string, qId: string) => {
|
||||||
|
const game = games.find(g => g.id === gameId);
|
||||||
|
if (game) {
|
||||||
|
updateGame(gameId, { questions: game.questions.filter(q => q.id !== qId) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 currentWinnerBuzz = buzzQueue.find(b => b.status === 'CORRECT');
|
||||||
|
const currentWinner = currentWinnerBuzz ? players.find(p => p.id === currentWinnerBuzz.playerId) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-100 flex flex-col overflow-hidden text-slate-800">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<header className="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm z-20 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
<span className="text-sm font-bold text-indigo-900">{activeGameName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
{isGameOver && (
|
||||||
|
<div className="bg-red-600 text-white px-3 py-1 rounded font-bold animate-pulse">
|
||||||
|
GAME OVER
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3 py-1 bg-slate-100 rounded text-xs font-mono border border-slate-300">
|
||||||
|
STATUS: <span className="font-bold text-indigo-600">{gameState.phase}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetGame}
|
||||||
|
className="flex items-center gap-1 text-red-600 text-sm hover:underline hover:bg-red-50 px-2 py-1 rounded transition-colors"
|
||||||
|
title="Resets scores and game phase, keeps players"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} /> Restart Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left Sidebar - Navigation & Soundboard */}
|
||||||
|
<aside className="w-64 bg-slate-900 text-white flex flex-col overflow-y-auto">
|
||||||
|
<nav className="p-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('GAME')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded transition-colors ${activeTab === 'GAME' ? 'bg-indigo-600' : 'hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" /> Game Control
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('PLAYERS')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded transition-colors ${activeTab === 'PLAYERS' ? 'bg-indigo-600' : 'hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" /> Players ({players.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('LIBRARY'); setEditingGameId(null); }}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded transition-colors ${activeTab === 'LIBRARY' ? 'bg-indigo-600' : 'hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Library className="w-4 h-4" /> Game Library
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto p-4 border-t border-slate-800">
|
||||||
|
<Soundboard />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
|
||||||
|
{/* GAME CONTROL TAB */}
|
||||||
|
{activeTab === 'GAME' && (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* LAST QUESTION WARNING */}
|
||||||
|
{isLastQuestionPhase && !isGameOver && (
|
||||||
|
<div className="bg-amber-100 border-l-4 border-amber-500 text-amber-700 p-4 mb-6 font-bold shadow-sm">
|
||||||
|
⚠️ THIS IS THE LAST QUESTION!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Question Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-md border border-slate-200 mb-8">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<h2 className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
{isPreGame
|
||||||
|
? 'Ready to Start'
|
||||||
|
: isGameOver ? 'Game Finished' : `Current Question (${gameState.currentQuestionIndex + 1}/${questions.length})`
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
{isPreGame && (
|
||||||
|
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded font-bold">
|
||||||
|
{questions.length} Questions Queued
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentQ ? (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold mb-4">{currentQ.text}</div>
|
||||||
|
{currentQ.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-xs font-bold bg-slate-100 px-2 py-1 rounded text-slate-500 flex w-fit items-center gap-1">
|
||||||
|
<ImageIcon size={12}/> Media Attached
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-blue-600 truncate mt-1 max-w-md">{currentQ.mediaUrl.substring(0, 50)}...</div>
|
||||||
|
{currentQ.mediaType === 'image' && (
|
||||||
|
<img src={currentQ.mediaUrl} alt="Preview" className="h-20 w-auto mt-2 rounded border border-slate-200" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-green-50 px-4 py-3 rounded border border-green-200 text-green-800 font-mono mb-6">
|
||||||
|
ANSWER: {currentQ.answer}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-400 italic mb-6">
|
||||||
|
{isPreGame ? "Game initialized. Waiting to start first question." : "Game Over. Check the final results."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls based on Phase */}
|
||||||
|
<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">
|
||||||
|
<Trophy className="w-4 h-4" /> VIEW FINAL PODIUM
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={startCountdown} className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-bold shadow-lg flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4" /> Start Question {gameState.currentQuestionIndex + 2}
|
||||||
|
</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')}
|
||||||
|
className="bg-amber-100 hover:bg-amber-200 text-amber-800 px-4 py-3 rounded-lg font-bold border border-amber-300 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QUESTION QUEUE PREVIEW (Added for better Host visibility) */}
|
||||||
|
{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">
|
||||||
|
<List size={16} />
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider">Loaded Queue</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 max-h-48 overflow-y-auto pr-2">
|
||||||
|
{questions.map((q, idx) => (
|
||||||
|
<li key={q.id} className="text-sm flex items-start gap-2 text-slate-600">
|
||||||
|
<span className="font-mono font-bold text-slate-300 w-5">{idx+1}.</span>
|
||||||
|
<span className="truncate">{q.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
<h3 className="font-bold text-slate-700">Buzzer Queue</h3>
|
||||||
|
<span className="text-xs bg-slate-200 px-2 py-1 rounded text-slate-600">{buzzQueue.length} Buzzes</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{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">
|
||||||
|
<div className="font-mono font-bold text-slate-400">#{idx + 1}</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-800">{player?.name}</div>
|
||||||
|
<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 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => resolveBuzz(buzz.playerId, true)}
|
||||||
|
className="flex items-center gap-1 bg-green-100 text-green-700 px-3 py-1 rounded hover:bg-green-200 font-bold border border-green-300"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" /> CORRECT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => resolveBuzz(buzz.playerId, false)}
|
||||||
|
className="flex items-center gap-1 bg-red-100 text-red-700 px-3 py-1 rounded hover:bg-red-200 font-bold border border-red-300"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" /> WRONG
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400 font-bold">WAITING</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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')}
|
||||||
|
title="Mark as Correct"
|
||||||
|
className="p-1 text-slate-400 hover:text-green-600 hover:bg-green-50 rounded"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PLAYERS TAB */}
|
||||||
|
{activeTab === 'PLAYERS' && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-bold">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3">Name</th>
|
||||||
|
<th className="px-6 py-3">Team</th>
|
||||||
|
<th className="px-6 py-3">Score</th>
|
||||||
|
<th className="px-6 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-slate-900">
|
||||||
|
{players.map(p => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td className="px-6 py-4 font-bold">{p.name}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{teams.find(t => t.id === p.teamId)?.name || '-'}</td>
|
||||||
|
<td className="px-6 py-4 font-mono">{p.score}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{p.isApproved ? (
|
||||||
|
<span className="text-green-600 text-xs font-bold bg-green-100 px-2 py-1 rounded">JOINED</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => approvePlayer(p.id)} className="text-blue-600 text-xs font-bold bg-blue-100 px-2 py-1 rounded hover:bg-blue-200">APPROVE</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GAME LIBRARY TAB */}
|
||||||
|
{activeTab === 'LIBRARY' && (
|
||||||
|
<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={handleCreateGame}
|
||||||
|
className="bg-indigo-600 text-white px-4 py-2 rounded text-sm font-bold hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { GamePhase } from '../types';
|
||||||
|
import { Trophy, Zap, Target, LogOut, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export const PlayerView: React.FC = () => {
|
||||||
|
const { gameState, players, teams, currentPlayerId, addPlayer, removePlayer, buzzQueue, handleBuzz, questions } = useGame();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [team, setTeam] = useState('');
|
||||||
|
const [hasJoined, setHasJoined] = useState(false);
|
||||||
|
|
||||||
|
// Auto-detect join state if currentPlayerId is set externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPlayerId) {
|
||||||
|
setHasJoined(true);
|
||||||
|
}
|
||||||
|
}, [currentPlayerId]);
|
||||||
|
|
||||||
|
const currentPlayer = players.find(p => p.id === currentPlayerId);
|
||||||
|
const currentTeam = teams.find(t => t.id === currentPlayer?.teamId);
|
||||||
|
|
||||||
|
// Handle case where player was removed (kicked or hard reset)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasJoined && !currentPlayer) {
|
||||||
|
setHasJoined(false);
|
||||||
|
setName('');
|
||||||
|
setTeam('');
|
||||||
|
}
|
||||||
|
}, [currentPlayer, hasJoined]);
|
||||||
|
|
||||||
|
const myBuzz = buzzQueue.find(b => b.playerId === currentPlayerId);
|
||||||
|
const isLockedOut = myBuzz && (myBuzz.status === 'WRONG' || (buzzQueue.length > 0 && buzzQueue[0].playerId !== currentPlayerId && myBuzz.status === 'PENDING'));
|
||||||
|
const isMyTurn = myBuzz && myBuzz.status === 'PENDING' && buzzQueue[0].playerId === currentPlayerId;
|
||||||
|
const isCorrect = myBuzz && myBuzz.status === 'CORRECT';
|
||||||
|
const isWrong = myBuzz && myBuzz.status === 'WRONG';
|
||||||
|
|
||||||
|
// Calculate Rank
|
||||||
|
const sortedTeams = [...teams].sort((a, b) => b.score - a.score);
|
||||||
|
const myRank = currentTeam ? sortedTeams.findIndex(t => t.id === currentTeam.id) + 1 : '-';
|
||||||
|
|
||||||
|
const currentQ = questions[gameState.currentQuestionIndex];
|
||||||
|
|
||||||
|
// Helper for YouTube ID
|
||||||
|
const getYoutubeId = (url: string) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name && team) {
|
||||||
|
addPlayer(name, team);
|
||||||
|
setHasJoined(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = () => {
|
||||||
|
if (currentPlayerId) {
|
||||||
|
removePlayer(currentPlayerId);
|
||||||
|
setHasJoined(false);
|
||||||
|
setName('');
|
||||||
|
setTeam('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasJoined) {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-900 p-6 flex flex-col justify-center overflow-y-auto">
|
||||||
|
<div className="max-w-md mx-auto w-full bg-white rounded-2xl p-8 shadow-xl">
|
||||||
|
<h1 className="text-3xl font-black text-slate-800 mb-2">Join Quiz</h1>
|
||||||
|
<p className="text-slate-500 mb-6">Enter your details to enter the lobby.</p>
|
||||||
|
<form onSubmit={handleJoin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-700 mb-1">Your Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-lg text-black bg-white"
|
||||||
|
placeholder="e.g. Maverick"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-700 mb-1">Team Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={team}
|
||||||
|
onChange={(e) => setTeam(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-lg text-black bg-white"
|
||||||
|
placeholder="e.g. Top Guns"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xl rounded-lg transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
ENTER LOBBY
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPlayer && !currentPlayer.isApproved) {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-900 flex flex-col items-center justify-center p-6 text-center overflow-y-auto">
|
||||||
|
<div className="animate-pulse mb-6 text-6xl">⏳</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Waiting for Host...</h2>
|
||||||
|
<p className="text-slate-400 mb-8">Sit tight! You'll be in shortly.</p>
|
||||||
|
<button onClick={handleLeave} className="text-slate-500 text-sm underline hover:text-white">Cancel & Leave</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EXPLICIT FEEDBACK SCREENS ---
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-green-600 flex flex-col items-center justify-center p-6 text-center animate-fade-in">
|
||||||
|
<div className="bg-white/20 p-8 rounded-full mb-6">
|
||||||
|
<span className="text-6xl">🎉</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-black text-white mb-2">CORRECT!</h1>
|
||||||
|
<p className="text-green-100 text-xl font-medium">You got the points!</p>
|
||||||
|
<div className="mt-8 text-white/80 font-mono text-sm">Wait for next question...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWrong) {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-red-600 flex flex-col items-center justify-center p-6 text-center animate-fade-in">
|
||||||
|
<div className="bg-white/20 p-8 rounded-full mb-6">
|
||||||
|
<span className="text-6xl">❌</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-black text-white mb-2">WRONG!</h1>
|
||||||
|
<p className="text-red-100 text-xl font-medium">Better luck next time.</p>
|
||||||
|
<div className="mt-8 text-white/80 font-mono text-sm">You are locked out for this question.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FINAL STATS VIEW ---
|
||||||
|
if (gameState.phase === GamePhase.FINAL_STATS) {
|
||||||
|
const winningTeam = sortedTeams[0];
|
||||||
|
|
||||||
|
let fastestPlayer = null;
|
||||||
|
let fastestTime = Infinity;
|
||||||
|
players.forEach(p => {
|
||||||
|
if (p.stats.bestReactionTime && p.stats.bestReactionTime < fastestTime) {
|
||||||
|
fastestTime = p.stats.bestReactionTime;
|
||||||
|
fastestPlayer = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const topPlayers = [...players].sort((a,b) => b.score - a.score).slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-900 flex flex-col overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-800 p-4 shadow-md text-center shrink-0 flex justify-between items-center">
|
||||||
|
<h1 className="text-white font-black text-xl tracking-widest uppercase flex-1">Game Over</h1>
|
||||||
|
<button onClick={handleLeave} className="text-slate-400 p-2"><LogOut size={20} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 flex flex-col gap-6 items-center">
|
||||||
|
{/* Winning Team Trophy */}
|
||||||
|
<div className="bg-gradient-to-b from-yellow-500/20 to-slate-800 w-full max-w-sm rounded-2xl p-6 border border-yellow-500/30 flex flex-col items-center text-center shadow-lg relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 w-full h-1 bg-gradient-to-r from-transparent via-yellow-400 to-transparent"></div>
|
||||||
|
<Trophy className="text-yellow-400 w-16 h-16 mb-4 drop-shadow-[0_0_15px_rgba(250,204,21,0.5)]" />
|
||||||
|
<div className="text-yellow-100 text-sm font-bold uppercase tracking-wider mb-1">Winning Team</div>
|
||||||
|
<div className="text-3xl font-black text-white mb-2">{winningTeam?.name || "No Winner"}</div>
|
||||||
|
<div className="bg-yellow-500/20 px-4 py-1 rounded-full text-yellow-300 font-mono font-bold text-lg border border-yellow-500/50">
|
||||||
|
{winningTeam?.score || 0} PTS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fastest Finger */}
|
||||||
|
{fastestPlayer && (
|
||||||
|
<div className="bg-slate-800 w-full max-w-sm rounded-xl p-4 border border-slate-700 flex items-center gap-4 shadow-md">
|
||||||
|
<div className="bg-blue-900/50 p-3 rounded-lg text-blue-400">
|
||||||
|
<Zap className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-400 text-xs font-bold uppercase">Fastest Buzzer</div>
|
||||||
|
<div className="text-white font-bold text-lg">{fastestPlayer.name}</div>
|
||||||
|
<div className="text-blue-400 font-mono text-sm">{(fastestTime / 1000).toFixed(2)}s reaction</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Players Table */}
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h3 className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-3">Top 5 Players</h3>
|
||||||
|
<div className="bg-slate-800 rounded-xl overflow-hidden border border-slate-700">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-900/50 text-slate-500 text-xs uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3 text-right">Score</th>
|
||||||
|
<th className="px-4 py-3 text-right">Acc</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700 text-white">
|
||||||
|
{topPlayers.map((p, i) => (
|
||||||
|
<tr key={p.id} className={p.id === currentPlayerId ? 'bg-indigo-900/30' : ''}>
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-400">#{i + 1}</td>
|
||||||
|
<td className="px-4 py-3 font-medium truncate max-w-[100px]">{p.name}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono">{p.score}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-slate-400 text-xs">
|
||||||
|
{p.stats.correctAnswers}/{p.stats.totalBuzzes}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REGULAR GAMEPLAY UI ---
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-800 flex flex-col overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-900 p-4 shadow-md flex justify-between items-center z-10 shrink-0 sticky top-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-bold text-lg">{currentPlayer?.name}</div>
|
||||||
|
<div className="text-slate-400 text-sm font-bold">{currentTeam?.name || 'No Team'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<div className="bg-blue-900 px-3 py-1 rounded text-blue-200 font-mono font-bold text-sm mb-1">
|
||||||
|
{currentTeam?.score || 0} PTS
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-bold uppercase">
|
||||||
|
Rank #{myRank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLeave}
|
||||||
|
className="bg-slate-800 p-2 rounded text-slate-400 hover:text-red-400 ml-2"
|
||||||
|
title="Leave Game"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Action Area */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6 relative">
|
||||||
|
|
||||||
|
{/* Dynamic Question Text & Media on Mobile */}
|
||||||
|
{gameState.phase !== GamePhase.LOBBY && gameState.phase !== GamePhase.LEADERBOARD && gameState.phase !== GamePhase.FINAL_STATS && currentQ && (
|
||||||
|
<div className="absolute top-4 left-4 right-4 text-center">
|
||||||
|
<p className="text-slate-300 text-sm uppercase tracking-widest mb-2">Current Question</p>
|
||||||
|
|
||||||
|
{/* Media Preview on Player Device */}
|
||||||
|
{currentQ.mediaUrl && (
|
||||||
|
<div className="mb-4 w-full max-w-[200px] mx-auto rounded-lg overflow-hidden shadow-lg border border-slate-600 bg-black">
|
||||||
|
{currentQ.mediaType === 'video' ? (
|
||||||
|
<div className="aspect-video">
|
||||||
|
<iframe
|
||||||
|
className="w-full h-full"
|
||||||
|
src={`https://www.youtube.com/embed/${getYoutubeId(currentQ.mediaUrl)}?autoplay=0`}
|
||||||
|
frameBorder="0"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img src={currentQ.mediaUrl} alt="Visual Clue" className="w-full h-auto object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-white font-medium leading-snug">
|
||||||
|
{currentQ.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The Buzzer Logic */}
|
||||||
|
{gameState.phase === GamePhase.BUZZER_OPEN ? (
|
||||||
|
<button
|
||||||
|
onClick={() => currentPlayerId && handleBuzz(currentPlayerId)}
|
||||||
|
className="w-64 h-64 rounded-full bg-red-600 shadow-[0_10px_0_rgb(153,27,27)] active:shadow-none active:translate-y-2 transition-all flex items-center justify-center border-8 border-red-800 z-20 mt-32"
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-black text-white tracking-widest">BUZZ!</span>
|
||||||
|
</button>
|
||||||
|
) : gameState.phase === GamePhase.ADJUDICATION ? (
|
||||||
|
// State during adjudication
|
||||||
|
myBuzz ? (
|
||||||
|
isMyTurn ? (
|
||||||
|
<div className="w-full h-full bg-green-600 absolute inset-0 flex flex-col items-center justify-center animate-pulse z-30">
|
||||||
|
<span className="text-9xl mb-4">🎤</span>
|
||||||
|
<h2 className="text-4xl font-black text-white uppercase">Your Turn!</h2>
|
||||||
|
<p className="text-green-200 mt-2">Answer the host now.</p>
|
||||||
|
</div>
|
||||||
|
) : isLockedOut ? (
|
||||||
|
<div className="flex flex-col items-center text-slate-500 mt-32">
|
||||||
|
<div className="text-6xl mb-4">🔒</div>
|
||||||
|
<span className="text-xl font-bold">LOCKED OUT</span>
|
||||||
|
<span className="text-sm mt-2">Another player is answering...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center mt-32">
|
||||||
|
<div className="text-6xl text-yellow-500 mb-4 font-bold">#{myBuzz.order}</div>
|
||||||
|
<span className="text-white text-xl">In Queue...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500 font-bold text-xl mt-32">Locked</div>
|
||||||
|
)
|
||||||
|
) : (gameState.phase === GamePhase.LEADERBOARD) ? (
|
||||||
|
// Leaderboard View for Players
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h3 className="text-white text-center font-bold text-xl mb-4 uppercase tracking-wider">Top Teams</h3>
|
||||||
|
<div className="bg-slate-700 rounded-xl overflow-hidden shadow-lg border border-slate-600">
|
||||||
|
{sortedTeams.slice(0, 5).map((t, idx) => (
|
||||||
|
<div key={t.id} className={`flex justify-between items-center p-4 border-b border-slate-600 ${t.id === currentTeam?.id ? 'bg-indigo-900/50' : ''}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`font-black w-6 ${idx === 0 ? 'text-yellow-400' : 'text-slate-400'}`}>#{idx + 1}</span>
|
||||||
|
<span className={`font-bold ${t.id === currentTeam?.id ? 'text-white' : 'text-slate-300'}`}>{t.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-white">{t.score}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Default Idle State
|
||||||
|
<div className="text-slate-500 text-center mt-32">
|
||||||
|
{gameState.phase === GamePhase.LOBBY && <p>Waiting for game to start...</p>}
|
||||||
|
{gameState.phase === GamePhase.COUNTDOWN && <p className="text-6xl text-white font-mono animate-ping">{gameState.countdownValue}</p>}
|
||||||
|
{gameState.phase === GamePhase.QUESTION_DISPLAY && <p>Listen carefully...</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
<div className="p-4 text-center text-slate-500 text-xs shrink-0">
|
||||||
|
Phase: {gameState.phase}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Volume2, Bell, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Soundboard: React.FC = () => {
|
||||||
|
const playTone = (freq: number, type: 'sine' | 'square' | 'sawtooth' | 'triangle', duration: number) => {
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!AudioContext) return;
|
||||||
|
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||||
|
|
||||||
|
// Envelope
|
||||||
|
gain.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playSound = (type: 'DING' | 'BUZZ' | 'THEME') => {
|
||||||
|
if (type === 'DING') {
|
||||||
|
// High pitch double beep
|
||||||
|
playTone(1200, 'sine', 1);
|
||||||
|
setTimeout(() => playTone(1600, 'sine', 1), 100);
|
||||||
|
} else if (type === 'BUZZ') {
|
||||||
|
// Low pitch sawtooth
|
||||||
|
playTone(150, 'sawtooth', 0.8);
|
||||||
|
} else if (type === 'THEME') {
|
||||||
|
// Simple arpeggio jingle
|
||||||
|
[440, 554, 659, 880].forEach((freq, i) => {
|
||||||
|
setTimeout(() => playTone(freq, 'sine', 0.6), i * 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800 p-4 rounded-lg border border-slate-700">
|
||||||
|
<h3 className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-3">Soundboard</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => playSound('DING')}
|
||||||
|
className="flex flex-col items-center justify-center p-3 bg-green-600 hover:bg-green-500 rounded text-white transition-colors active:scale-95 transform"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5 mb-1" />
|
||||||
|
<span className="text-xs font-bold">DING</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => playSound('BUZZ')}
|
||||||
|
className="flex flex-col items-center justify-center p-3 bg-red-600 hover:bg-red-500 rounded text-white transition-colors active:scale-95 transform"
|
||||||
|
>
|
||||||
|
<XCircle className="w-5 h-5 mb-1" />
|
||||||
|
<span className="text-xs font-bold">BUZZ</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => playSound('THEME')}
|
||||||
|
className="flex flex-col items-center justify-center p-3 bg-indigo-600 hover:bg-indigo-500 rounded text-white transition-colors active:scale-95 transform"
|
||||||
|
>
|
||||||
|
<Volume2 className="w-5 h-5 mb-1" />
|
||||||
|
<span className="text-xs font-bold">THEME</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { GamePhase } from '../types';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { Trophy, Zap, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export const SpectatorView: React.FC = () => {
|
||||||
|
const { gameState, questions, teams, players, buzzQueue } = useGame();
|
||||||
|
const currentQ = questions[gameState.currentQuestionIndex];
|
||||||
|
|
||||||
|
// Helper to get formatted leaderboard data
|
||||||
|
const leaderboardData = [...teams].sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const COLORS = ['#F59E0B', '#3B82F6', '#10B981', '#EC4899', '#8B5CF6'];
|
||||||
|
|
||||||
|
// Helper for YouTube ID extraction
|
||||||
|
const getYoutubeId = (url: string) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (gameState.phase) {
|
||||||
|
case GamePhase.LOBBY:
|
||||||
|
return (
|
||||||
|
<div className="text-center animate-fade-in">
|
||||||
|
<h1 className="text-6xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500 mb-8">
|
||||||
|
JOIN THE QUIZ
|
||||||
|
</h1>
|
||||||
|
<div className="bg-white p-4 inline-block rounded-xl mb-8">
|
||||||
|
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https://quiz-app-demo.com" alt="QR Code" className="w-48 h-48" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl text-slate-300 mb-8">Scan to Join</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 max-w-4xl mx-auto">
|
||||||
|
{players.filter(p => p.isApproved).map(p => (
|
||||||
|
<div key={p.id} className="bg-slate-800 border border-slate-600 px-6 py-3 rounded-full text-xl font-bold text-white animate-bounce-in">
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GamePhase.COUNTDOWN:
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-[20rem] font-black text-white animate-ping">
|
||||||
|
{gameState.countdownValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GamePhase.QUESTION_DISPLAY:
|
||||||
|
case GamePhase.BUZZER_OPEN:
|
||||||
|
case GamePhase.ADJUDICATION:
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto p-8 flex flex-col h-full">
|
||||||
|
<div className="mb-4 flex justify-between items-center text-slate-400 text-2xl font-mono uppercase tracking-widest shrink-0">
|
||||||
|
<span>{currentQ?.category || 'General'}</span>
|
||||||
|
<span>{currentQ?.points} PTS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
|
<h2 className="text-5xl md:text-7xl font-bold text-white leading-tight mb-8 text-center drop-shadow-xl">
|
||||||
|
{currentQ?.text}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Media Display */}
|
||||||
|
{currentQ?.mediaUrl && (
|
||||||
|
<div className="w-full max-w-3xl aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border-4 border-slate-700 mb-8">
|
||||||
|
{currentQ.mediaType === 'video' ? (
|
||||||
|
<iframe
|
||||||
|
className="w-full h-full"
|
||||||
|
src={`https://www.youtube.com/embed/${getYoutubeId(currentQ.mediaUrl)}?autoplay=1&mute=0`}
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={currentQ.mediaUrl} alt="Question Media" className="w-full h-full object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buzzer Status for Audience */}
|
||||||
|
<div className="flex justify-center gap-4 min-h-[100px] shrink-0 mt-8">
|
||||||
|
{buzzQueue.map((buzz, idx) => {
|
||||||
|
const player = players.find(p => p.id === buzz.playerId);
|
||||||
|
let bg = 'bg-slate-700';
|
||||||
|
if (buzz.status === 'CORRECT') bg = 'bg-green-600';
|
||||||
|
if (buzz.status === 'WRONG') bg = 'bg-red-600';
|
||||||
|
if (buzz.status === 'PENDING' && idx === 0) bg = 'bg-yellow-500 animate-pulse';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={buzz.playerId} className={`${bg} transition-all duration-300 px-8 py-4 rounded-xl flex items-center gap-4 border-2 border-white/20`}>
|
||||||
|
<span className="text-3xl font-black text-white">#{idx + 1}</span>
|
||||||
|
<span className="text-2xl text-white font-bold">{player?.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GamePhase.ANSWER_REVEAL:
|
||||||
|
return (
|
||||||
|
<div className="text-center max-w-5xl mx-auto">
|
||||||
|
<h3 className="text-4xl text-slate-400 mb-8">The answer is...</h3>
|
||||||
|
<div className="text-6xl md:text-8xl font-black text-green-400 bg-slate-800/50 p-12 rounded-3xl border border-green-500/30 backdrop-blur-sm animate-fade-in-up">
|
||||||
|
{currentQ?.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GamePhase.LEADERBOARD:
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto h-[80vh] flex flex-col">
|
||||||
|
<h2 className="text-5xl font-bold text-white text-center mb-8">LEADERBOARD</h2>
|
||||||
|
<div className="flex-1 w-full bg-slate-800/50 rounded-2xl p-8 border border-slate-700">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={leaderboardData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis dataKey="name" type="category" width={150} tick={{fill: 'white', fontSize: 20}} />
|
||||||
|
<Tooltip cursor={{fill: 'transparent'}} contentStyle={{backgroundColor: '#1e293b', color: '#fff', border: 'none'}} />
|
||||||
|
<Bar dataKey="score" radius={[0, 10, 10, 0]}>
|
||||||
|
{leaderboardData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GamePhase.FINAL_STATS:
|
||||||
|
const sortedTeams = [...teams].sort((a,b) => b.score - a.score);
|
||||||
|
const winningTeam = sortedTeams[0];
|
||||||
|
const otherTeams = sortedTeams.slice(1);
|
||||||
|
|
||||||
|
// Top 5 Players
|
||||||
|
const topPlayers = [...players].sort((a,b) => b.score - a.score).slice(0, 5);
|
||||||
|
|
||||||
|
// Fastest Buzzer Calculation
|
||||||
|
let fastestPlayer = null;
|
||||||
|
let fastestTime = Infinity;
|
||||||
|
players.forEach(p => {
|
||||||
|
if (p.stats.bestReactionTime && p.stats.bestReactionTime < fastestTime) {
|
||||||
|
fastestTime = p.stats.bestReactionTime;
|
||||||
|
fastestPlayer = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center w-full max-w-6xl mx-auto pb-10 overflow-y-auto h-full">
|
||||||
|
<h1 className="text-5xl font-black text-white mb-8 tracking-widest uppercase opacity-50">Game Over</h1>
|
||||||
|
|
||||||
|
{/* 1. Grand Champion Section */}
|
||||||
|
<div className="flex justify-center mb-12">
|
||||||
|
<div className="bg-gradient-to-b from-yellow-500/20 to-slate-900 px-20 py-10 rounded-3xl border-t-8 border-yellow-400 transform scale-110 shadow-[0_0_50px_rgba(234,179,8,0.3)] relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-yellow-300 opacity-50"></div>
|
||||||
|
<Trophy className="w-24 h-24 text-yellow-400 mx-auto mb-6 drop-shadow-lg" />
|
||||||
|
<div className="text-yellow-200 font-bold uppercase tracking-[0.2em] mb-2 text-xl">Grand Champion</div>
|
||||||
|
<div className="text-6xl font-black text-white mb-4">{winningTeam?.name || 'No Winner'}</div>
|
||||||
|
<div className="text-4xl text-yellow-400 font-bold font-mono">{winningTeam?.score || 0} PTS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||||
|
{/* 2. Remaining Teams Leaderboard */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-white text-xl font-bold uppercase tracking-wider mb-6 flex items-center justify-center gap-2">
|
||||||
|
<Users className="text-slate-400" /> Team Standings
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{otherTeams.length > 0 ? (
|
||||||
|
otherTeams.map((team, idx) => (
|
||||||
|
<div key={team.id} className="flex items-center justify-between bg-slate-800 p-4 rounded-xl border-l-4 border-slate-600">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-2xl font-black text-slate-500">#{idx + 2}</span>
|
||||||
|
<span className="text-xl font-bold text-white">{team.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-mono text-slate-300">{team.score}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500 italic p-4">No other teams participated.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Player Accolades */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Fastest Buzzer Card */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-900/50 to-slate-800/50 rounded-2xl p-6 border border-blue-500/30 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-blue-600 p-4 rounded-full text-white shadow-lg">
|
||||||
|
<Zap size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-blue-300 font-bold uppercase tracking-wider text-sm">Fastest Finger</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{fastestPlayer?.name || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl font-mono font-bold text-blue-400">
|
||||||
|
{fastestPlayer && fastestTime !== Infinity ? (fastestTime / 1000).toFixed(2) : '-'}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 5 Players Table */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl overflow-hidden border border-slate-700">
|
||||||
|
<div className="bg-slate-900/50 p-4 border-b border-slate-700">
|
||||||
|
<h3 className="text-white font-bold uppercase tracking-wider text-center">Top 5 Players</h3>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="text-slate-500 text-xs uppercase bg-slate-900/30">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3">Rank</th>
|
||||||
|
<th className="px-6 py-3">Player</th>
|
||||||
|
<th className="px-6 py-3 text-right">Score</th>
|
||||||
|
<th className="px-6 py-3 text-right">Acc</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700 text-white text-sm">
|
||||||
|
{topPlayers.map((p, i) => (
|
||||||
|
<tr key={p.id} className="hover:bg-slate-700/30">
|
||||||
|
<td className="px-6 py-4 font-mono text-slate-400">#{i + 1}</td>
|
||||||
|
<td className="px-6 py-4 font-bold">{p.name}</td>
|
||||||
|
<td className="px-6 py-4 text-right font-mono text-lg">{p.score}</td>
|
||||||
|
<td className="px-6 py-4 text-right text-slate-400">
|
||||||
|
{p.stats.correctAnswers}/{p.stats.totalBuzzes}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-slate-900 flex flex-col items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||||
|
{/* Ambient Background Elements */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-600 rounded-full blur-[150px]" />
|
||||||
|
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-600 rounded-full blur-[150px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="z-10 w-full h-full flex flex-col justify-center">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { GamePhase, Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
|
||||||
|
|
||||||
|
interface GameContextType {
|
||||||
|
// State
|
||||||
|
gameState: GameState;
|
||||||
|
activeGameName: string; // New: Track which game is loaded
|
||||||
|
players: Player[];
|
||||||
|
teams: Team[];
|
||||||
|
questions: Question[]; // The ACTIVE questions currently being played
|
||||||
|
games: Game[]; // The LIBRARY of saved games
|
||||||
|
buzzQueue: BuzzerLog[];
|
||||||
|
currentPlayerId: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addPlayer: (name: string, teamName: string) => void;
|
||||||
|
approvePlayer: (playerId: string) => void;
|
||||||
|
removePlayer: (playerId: string) => void; // New action
|
||||||
|
startGame: () => void;
|
||||||
|
startCountdown: () => void;
|
||||||
|
openBuzzers: () => void;
|
||||||
|
handleBuzz: (playerId: string) => void;
|
||||||
|
resolveBuzz: (playerId: string, correct: boolean) => void;
|
||||||
|
rectifyBuzz: (playerId: string, newStatus: 'CORRECT' | 'WRONG') => void;
|
||||||
|
skipQuestion: () => void;
|
||||||
|
nextPhase: () => void;
|
||||||
|
setQuestions: (qs: Question[]) => void;
|
||||||
|
setCurrentPlayer: (id: string) => void;
|
||||||
|
playAudio: (url: string, start?: number, end?: number) => void;
|
||||||
|
resetGame: () => void;
|
||||||
|
|
||||||
|
// Library Actions
|
||||||
|
createGame: (name: string) => void;
|
||||||
|
updateGame: (gameId: string, updates: Partial<Game>) => void;
|
||||||
|
deleteGame: (gameId: string) => void;
|
||||||
|
loadGameToLive: (gameId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameContext = createContext<GameContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Initial Mock Data
|
||||||
|
const INITIAL_QUESTIONS: Question[] = [
|
||||||
|
{ id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" },
|
||||||
|
{ id: '2', text: "Which element has the chemical symbol 'O'?", answer: "Oxygen", points: 10, category: "Science" },
|
||||||
|
{ id: '3', text: "Who wrote 'Romeo and Juliet'?", answer: "William Shakespeare", points: 20, category: "Literature" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_GAMES: Game[] = [
|
||||||
|
{
|
||||||
|
id: 'demo-1',
|
||||||
|
name: 'General Knowledge Demo',
|
||||||
|
description: 'A quick test of random facts.',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
questions: INITIAL_QUESTIONS
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-2',
|
||||||
|
name: 'Science & Nature',
|
||||||
|
description: 'Physics, Biology, and Chemistry basics.',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
questions: [
|
||||||
|
{ id: 's1', text: 'What is the speed of light?', answer: '299,792,458 m/s', points: 20, category: 'Physics' },
|
||||||
|
{ id: 's2', text: 'What is the powerhouse of the cell?', answer: 'Mitochondria', points: 10, category: 'Biology' },
|
||||||
|
{ id: 's3', text: 'What planet is known as the Red Planet?', answer: 'Mars', points: 10, category: 'Astronomy' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [gameState, setGameState] = useState<GameState>({
|
||||||
|
phase: GamePhase.LOBBY,
|
||||||
|
currentQuestionIndex: -1,
|
||||||
|
countdownValue: 3,
|
||||||
|
buzzerOpenTimestamp: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo");
|
||||||
|
const [players, setPlayers] = useState<Player[]>([]);
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
const [questions, setQuestionsQuestions] = useState<Question[]>(INITIAL_QUESTIONS);
|
||||||
|
const [games, setGames] = useState<Game[]>(INITIAL_GAMES);
|
||||||
|
const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]);
|
||||||
|
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Helper: Find or create team
|
||||||
|
const getOrCreateTeam = (name: string) => {
|
||||||
|
const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (existing) return existing;
|
||||||
|
const newTeam: Team = { id: crypto.randomUUID(), name, score: 0 };
|
||||||
|
setTeams(prev => [...prev, newTeam]);
|
||||||
|
return newTeam;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlayer = (name: string, teamName: string) => {
|
||||||
|
const team = getOrCreateTeam(teamName);
|
||||||
|
const newPlayer: Player = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
teamId: team.id,
|
||||||
|
score: 0,
|
||||||
|
isApproved: false, // Requires admin approval
|
||||||
|
stats: {
|
||||||
|
correctAnswers: 0,
|
||||||
|
totalBuzzes: 0,
|
||||||
|
bestReactionTime: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setPlayers(prev => [...prev, newPlayer]);
|
||||||
|
if (!currentPlayerId) setCurrentPlayerId(newPlayer.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlayer = (playerId: string) => {
|
||||||
|
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||||
|
if (currentPlayerId === playerId) {
|
||||||
|
setCurrentPlayerId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvePlayer = (playerId: string) => {
|
||||||
|
setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startGame = () => {
|
||||||
|
// Directly start Q1 Countdown, skipping initial Leaderboard
|
||||||
|
setGameState({
|
||||||
|
phase: GamePhase.COUNTDOWN,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
countdownValue: 3,
|
||||||
|
buzzerOpenTimestamp: null
|
||||||
|
});
|
||||||
|
setBuzzQueue([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCountdown = () => {
|
||||||
|
let nextIndex = gameState.currentQuestionIndex;
|
||||||
|
if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) {
|
||||||
|
nextIndex = gameState.currentQuestionIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex >= questions.length) {
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGameState({
|
||||||
|
phase: GamePhase.COUNTDOWN,
|
||||||
|
currentQuestionIndex: nextIndex,
|
||||||
|
countdownValue: 3,
|
||||||
|
buzzerOpenTimestamp: null
|
||||||
|
});
|
||||||
|
setBuzzQueue([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
if (gameState.phase === GamePhase.COUNTDOWN) {
|
||||||
|
if (gameState.countdownValue > 0) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
setGameState(prev => ({ ...prev, countdownValue: prev.countdownValue - 1 }));
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.QUESTION_DISPLAY }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [gameState.phase, gameState.countdownValue]);
|
||||||
|
|
||||||
|
const openBuzzers = () => {
|
||||||
|
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 currentQ = questions[gameState.currentQuestionIndex];
|
||||||
|
const player = players.find(p => p.id === playerId);
|
||||||
|
|
||||||
|
// Update Stats logic
|
||||||
|
setPlayers(prev => prev.map(p => {
|
||||||
|
if (p.id !== playerId) return p;
|
||||||
|
|
||||||
|
let newStats = { ...p.stats };
|
||||||
|
// Increment attempts (total buzzes)
|
||||||
|
newStats.totalBuzzes += 1;
|
||||||
|
|
||||||
|
// Calculate reaction time if this buzz was the first one processed
|
||||||
|
// We only really care about updating reaction time if it was a successful/valid buzz attempt
|
||||||
|
if (gameState.buzzerOpenTimestamp) {
|
||||||
|
const reactionTime = Date.now() - gameState.buzzerOpenTimestamp;
|
||||||
|
if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) {
|
||||||
|
newStats.bestReactionTime = reactionTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
|
newStats.correctAnswers += 1;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
score: p.score + currentQ.points,
|
||||||
|
stats: newStats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...p, stats: newStats };
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
|
setBuzzQueue(prev => prev.map(b => b.playerId === playerId ? { ...b, status: 'CORRECT' } : b));
|
||||||
|
if (player && player.teamId) {
|
||||||
|
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + currentQ.points } : t));
|
||||||
|
}
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||||
|
} else {
|
||||||
|
setBuzzQueue(prev => prev.map(b => b.playerId === playerId ? { ...b, status: 'WRONG' } : b));
|
||||||
|
const remaining = buzzQueue.filter(b => b.playerId !== playerId && b.status === 'PENDING');
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => {
|
||||||
|
const currentQ = questions[gameState.currentQuestionIndex];
|
||||||
|
const player = players.find(p => p.id === playerId);
|
||||||
|
if (!player || !player.teamId) return;
|
||||||
|
|
||||||
|
const oldBuzz = buzzQueue.find(b => b.playerId === playerId);
|
||||||
|
if (!oldBuzz) return;
|
||||||
|
|
||||||
|
setBuzzQueue(prev => prev.map(b => b.playerId === playerId ? { ...b, status: newStatus } : b));
|
||||||
|
|
||||||
|
const points = currentQ.points;
|
||||||
|
|
||||||
|
if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') {
|
||||||
|
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t));
|
||||||
|
// Also correct stat if rectifying
|
||||||
|
setPlayers(prev => prev.map(p => p.id === playerId ? {
|
||||||
|
...p,
|
||||||
|
score: p.score + points,
|
||||||
|
stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
|
||||||
|
} : p));
|
||||||
|
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||||
|
}
|
||||||
|
else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') {
|
||||||
|
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t));
|
||||||
|
setPlayers(prev => prev.map(p => p.id === playerId ? {
|
||||||
|
...p,
|
||||||
|
score: p.score - points,
|
||||||
|
stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
|
||||||
|
} : p));
|
||||||
|
|
||||||
|
const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING');
|
||||||
|
setGameState(prev => ({
|
||||||
|
...prev,
|
||||||
|
phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipQuestion = () => {
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||||
|
setBuzzQueue([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPhase = () => {
|
||||||
|
if (gameState.phase === GamePhase.ANSWER_REVEAL) {
|
||||||
|
setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playAudio = (url: string, start: number = 0, end?: number) => {
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.currentTime = start;
|
||||||
|
audio.play();
|
||||||
|
if (end) {
|
||||||
|
setTimeout(() => {
|
||||||
|
audio.pause();
|
||||||
|
}, (end - start) * 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGame = () => {
|
||||||
|
// "Soft Reset" - Keep players and teams, but reset their scores and stats
|
||||||
|
setGameState({
|
||||||
|
phase: GamePhase.LOBBY,
|
||||||
|
currentQuestionIndex: -1,
|
||||||
|
countdownValue: 3,
|
||||||
|
buzzerOpenTimestamp: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset Scores and Stats for Players
|
||||||
|
setPlayers(prev => prev.map(p => ({
|
||||||
|
...p,
|
||||||
|
score: 0,
|
||||||
|
stats: {
|
||||||
|
correctAnswers: 0,
|
||||||
|
totalBuzzes: 0,
|
||||||
|
bestReactionTime: null
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Reset Team Scores
|
||||||
|
setTeams(prev => prev.map(t => ({ ...t, score: 0 })));
|
||||||
|
|
||||||
|
setBuzzQueue([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- LIBRARY ACTIONS ---
|
||||||
|
|
||||||
|
const createGame = (name: string) => {
|
||||||
|
const newGame: Game = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
questions: []
|
||||||
|
};
|
||||||
|
setGames(prev => [...prev, newGame]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGame = (gameId: string, updates: Partial<Game>) => {
|
||||||
|
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGame = (gameId: string) => {
|
||||||
|
setGames(prev => prev.filter(g => g.id !== gameId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGameToLive = (gameId: string) => {
|
||||||
|
const game = games.find(g => g.id === gameId);
|
||||||
|
if (game) {
|
||||||
|
setQuestionsQuestions([...game.questions]);
|
||||||
|
setActiveGameName(game.name);
|
||||||
|
|
||||||
|
setGameState({
|
||||||
|
phase: GamePhase.LOBBY,
|
||||||
|
currentQuestionIndex: -1,
|
||||||
|
countdownValue: 3,
|
||||||
|
buzzerOpenTimestamp: null
|
||||||
|
});
|
||||||
|
setBuzzQueue([]);
|
||||||
|
// NOTE: We do not clear players here anymore, allowing roster to persist between games
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameContext.Provider value={{
|
||||||
|
gameState,
|
||||||
|
activeGameName,
|
||||||
|
players,
|
||||||
|
teams,
|
||||||
|
questions,
|
||||||
|
games,
|
||||||
|
buzzQueue,
|
||||||
|
currentPlayerId,
|
||||||
|
addPlayer,
|
||||||
|
approvePlayer,
|
||||||
|
removePlayer,
|
||||||
|
startGame,
|
||||||
|
startCountdown,
|
||||||
|
openBuzzers,
|
||||||
|
handleBuzz,
|
||||||
|
resolveBuzz,
|
||||||
|
rectifyBuzz,
|
||||||
|
skipQuestion,
|
||||||
|
nextPhase,
|
||||||
|
setQuestions: setQuestionsQuestions,
|
||||||
|
setCurrentPlayer: setCurrentPlayerId,
|
||||||
|
playAudio,
|
||||||
|
resetGame,
|
||||||
|
createGame,
|
||||||
|
updateGame,
|
||||||
|
deleteGame,
|
||||||
|
loadGameToLive
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</GameContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGame = () => {
|
||||||
|
const context = useContext(GameContext);
|
||||||
|
if (!context) throw new Error("useGame must be used within a GameProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>QuizMaster Live</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
animation: {
|
||||||
|
'bounce-in': 'bounceIn 0.5s cubic-bezier(0.8, 0, 1, 1)',
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-out',
|
||||||
|
'fade-in-up': 'fadeInUp 0.5s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
bounceIn: {
|
||||||
|
'0%, 100%': { transform: 'scale(1)' },
|
||||||
|
'50%': { transform: 'scale(1.1)' },
|
||||||
|
},
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
fadeInUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Custom utility for full-screen text handling */
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||||
|
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
|
||||||
|
"react/": "https://esm.sh/react@^19.2.4/",
|
||||||
|
"react": "https://esm.sh/react@^19.2.4",
|
||||||
|
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||||
|
"recharts": "https://esm.sh/recharts@^3.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "QuizMaster Live",
|
||||||
|
"description": "A real-time, multi-role quiz show platform. Features a Host dashboard for game management (with AI question generation), a mobile-first Player buzzer interface, and a cinematic Spectator display.",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "quizmaster-live",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"@google/genai": "^1.38.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { GoogleGenAI, Type } from "@google/genai";
|
||||||
|
import { Question } from "../types";
|
||||||
|
|
||||||
|
const generateQuestions = async (topic: string, count: number = 5): Promise<Question[]> => {
|
||||||
|
if (!process.env.API_KEY) {
|
||||||
|
console.error("API Key is missing");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: "gemini-3-flash-preview",
|
||||||
|
contents: `Generate ${count} trivia questions about "${topic}". The questions should be suitable for a pub quiz.`,
|
||||||
|
config: {
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
responseSchema: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
text: { type: Type.STRING, description: "The question text" },
|
||||||
|
answer: { type: Type.STRING, description: "The correct answer" },
|
||||||
|
points: { type: Type.INTEGER, description: "Points value, usually 10, 20, or 30" },
|
||||||
|
category: { type: Type.STRING, description: "Short category name" }
|
||||||
|
},
|
||||||
|
required: ["text", "answer", "points", "category"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.text) {
|
||||||
|
const rawData = JSON.parse(response.text);
|
||||||
|
// Map to our internal ID structure
|
||||||
|
return rawData.map((q: any) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: q.text,
|
||||||
|
answer: q.answer,
|
||||||
|
points: q.points,
|
||||||
|
category: q.category
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gemini generation error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { generateQuestions };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
export enum GamePhase {
|
||||||
|
LOBBY = 'LOBBY',
|
||||||
|
COUNTDOWN = 'COUNTDOWN',
|
||||||
|
QUESTION_DISPLAY = 'QUESTION_DISPLAY',
|
||||||
|
BUZZER_OPEN = 'BUZZER_OPEN',
|
||||||
|
ADJUDICATION = 'ADJUDICATION',
|
||||||
|
ANSWER_REVEAL = 'ANSWER_REVEAL',
|
||||||
|
LEADERBOARD = 'LEADERBOARD',
|
||||||
|
FINAL_STATS = 'FINAL_STATS'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerStats {
|
||||||
|
correctAnswers: number;
|
||||||
|
totalBuzzes: number;
|
||||||
|
bestReactionTime: number | null; // in ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
teamId?: string;
|
||||||
|
score: number;
|
||||||
|
isApproved: boolean;
|
||||||
|
buzzerTimestamp?: number; // MS timestamp of when they buzzed
|
||||||
|
stats: PlayerStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
answer: string;
|
||||||
|
points: number;
|
||||||
|
category?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaType?: 'image' | 'video';
|
||||||
|
audioUrl?: string;
|
||||||
|
audioStart?: number;
|
||||||
|
audioEnd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuzzerLog {
|
||||||
|
playerId: string;
|
||||||
|
timestamp: number;
|
||||||
|
order: number;
|
||||||
|
status: 'PENDING' | 'CORRECT' | 'WRONG';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
phase: GamePhase;
|
||||||
|
currentQuestionIndex: number;
|
||||||
|
countdownValue: number;
|
||||||
|
buzzerOpenTimestamp: number | null; // To calculate reaction time
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user