From 7cdd75ea83c30f83d3797d00404b927f2f334da4 Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 28 Jan 2026 17:11:29 -0800 Subject: [PATCH] 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. --- .gitignore | 24 ++ App.tsx | 101 ++++++ README.md | 25 +- components/HostView.tsx | 638 +++++++++++++++++++++++++++++++++++ components/PlayerView.tsx | 354 +++++++++++++++++++ components/Soundboard.tsx | 71 ++++ components/SpectatorView.tsx | 264 +++++++++++++++ context/GameContext.tsx | 401 ++++++++++++++++++++++ index.html | 57 ++++ index.tsx | 15 + metadata.json | 5 + package.json | 24 ++ services/geminiService.ts | 52 +++ tsconfig.json | 29 ++ types.ts | 67 ++++ vite.config.ts | 23 ++ 16 files changed, 2142 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/HostView.tsx create mode 100644 components/PlayerView.tsx create mode 100644 components/Soundboard.tsx create mode 100644 components/SpectatorView.tsx create mode 100644 context/GameContext.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/geminiService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..94495fb --- /dev/null +++ b/App.tsx @@ -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 ; + case 'PLAYER': return ; + case 'SPECTATOR': return ; + default: return ; + } + }; + + return ( + +
+ {/* Top Navigation Bar */} + + + {/* Main Content Area */} +
+ {renderView()} +
+
+
+ ); +}; + +interface NavButtonProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +} + +const NavButton: React.FC = ({ active, onClick, icon, label }) => ( + +); + +export default App; \ No newline at end of file diff --git a/README.md b/README.md index 2241000..30f7866 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# 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` diff --git a/components/HostView.tsx b/components/HostView.tsx new file mode 100644 index 0000000..8c46bd0 --- /dev/null +++ b/components/HostView.tsx @@ -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(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) => { + 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 ( +
+ {/* Top Bar */} +
+
+
+
QM
+

Host Dashboard

+
+ {/* Active Game Display */} +
+ + ACTIVE GAME: + {activeGameName} +
+
+ +
+ {isGameOver && ( +
+ GAME OVER +
+ )} +
+ STATUS: {gameState.phase} +
+ +
+
+ +
+ {/* Left Sidebar - Navigation & Soundboard */} + + + {/* Main Content Area */} +
+ + {/* GAME CONTROL TAB */} + {activeTab === 'GAME' && ( +
+ {/* LAST QUESTION WARNING */} + {isLastQuestionPhase && !isGameOver && ( +
+ ⚠️ THIS IS THE LAST QUESTION! +
+ )} + + {/* Current Question Card */} +
+
+

+ {isPreGame + ? 'Ready to Start' + : isGameOver ? 'Game Finished' : `Current Question (${gameState.currentQuestionIndex + 1}/${questions.length})` + } +

+ {isPreGame && ( + + {questions.length} Questions Queued + + )} +
+ + {currentQ ? ( + <> +
{currentQ.text}
+ {currentQ.mediaUrl && ( +
+ + Media Attached + +
{currentQ.mediaUrl.substring(0, 50)}...
+ {currentQ.mediaType === 'image' && ( + Preview + )} +
+ )} +
+ ANSWER: {currentQ.answer} +
+ + ) : ( +
+ {isPreGame ? "Game initialized. Waiting to start first question." : "Game Over. Check the final results."} +
+ )} + + {/* Controls based on Phase */} +
+ {gameState.phase === GamePhase.LOBBY && ( + + )} + + {/* Button logic for moving to next question OR finishing game */} + {(gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) && !isGameOver && ( + isLastQuestionPhase && gameState.phase === GamePhase.LEADERBOARD ? ( + + ) : ( + + ) + )} + + {gameState.phase === GamePhase.QUESTION_DISPLAY && ( + + )} + + {/* Skip/Reveal Button */} + {(gameState.phase === GamePhase.QUESTION_DISPLAY || gameState.phase === GamePhase.BUZZER_OPEN || gameState.phase === GamePhase.ADJUDICATION) && ( + + )} + + {gameState.phase === GamePhase.ANSWER_REVEAL && ( +
+ {/* UNDO BUTTON */} + {currentWinner && ( + + )} + + +
+ )} +
+
+ + {/* QUESTION QUEUE PREVIEW (Added for better Host visibility) */} + {isPreGame && questions.length > 0 && ( +
+
+ +

Loaded Queue

+
+
    + {questions.map((q, idx) => ( +
  • + {idx+1}. + {q.text} +
  • + ))} +
+
+ )} + + {/* Buzzer Queue Adjudication */} + {buzzQueue.length > 0 && ( +
+
+

Buzzer Queue

+ {buzzQueue.length} Buzzes +
+
+ {buzzQueue.map((buzz, idx) => { + const player = players.find(p => p.id === buzz.playerId); + const isCurrent = idx === 0 && buzz.status === 'PENDING'; + + return ( +
+
+
#{idx + 1}
+
+
{player?.name}
+
{(buzz.timestamp % 10000)}ms
+
+
+ +
+ {buzz.status === 'PENDING' ? ( + isCurrent ? ( + <> + + + + ) : ( + WAITING + ) + ) : ( +
+ + {buzz.status} + + {/* Correction Button for Wrong Answers */} + {buzz.status === 'WRONG' && ( + + )} +
+ )} +
+
+ ); + })} +
+
+ )} +
+ )} + + {/* PLAYERS TAB */} + {activeTab === 'PLAYERS' && ( +
+ + + + + + + + + + + {players.map(p => ( + + + + + + + ))} + +
NameTeamScoreStatus
{p.name}{teams.find(t => t.id === p.teamId)?.name || '-'}{p.score} + {p.isApproved ? ( + JOINED + ) : ( + + )} +
+
+ )} + + {/* GAME LIBRARY TAB */} + {activeTab === 'LIBRARY' && ( +
+ {!editingGameId ? ( + /* MODE: LIST GAMES */ + <> +
+

Your Games

+
+ setNewGameName(e.target.value)} + placeholder="New Game Name..." + className="px-4 py-2 rounded border border-slate-300 text-sm text-black bg-white" + /> + +
+
+ +
+ {games.map(game => ( +
+
+
+ +
+
+ + +
+
+

{game.name}

+

{game.questions.length} Questions

+ +
+ ))} +
+ + ) : ( + /* MODE: EDIT GAME */ + (() => { + const game = games.find(g => g.id === editingGameId); + if (!game) return null; + + return ( +
+
+ + updateGame(game.id, { name: e.target.value })} + className="text-2xl font-bold bg-transparent border-b border-transparent hover:border-slate-300 focus:border-indigo-600 focus:outline-none px-2 py-1 text-slate-900" + /> + {game.questions.length} Questions +
+ + {/* AI Generator for this game */} +
+
+ +

AI Question Generator

+
+
+ 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" + /> + +
+
+ +
+
+

Questions

+ +
+ + {/* Add Manual Form */} + {isAddingQuestion && ( +
+
+
+ + setManualQ({...manualQ, text: e.target.value})} + /> +
+
+ + setManualQ({...manualQ, answer: e.target.value})} + /> +
+
+ + setManualQ({...manualQ, points: Number(e.target.value)})} + /> +
+
+ + setManualQ({...manualQ, category: e.target.value})} + /> +
+
+ +
+ setManualQ({...manualQ, mediaUrl: e.target.value})} + /> +
+ + +
+
+

Supports YouTube links or Image Uploads

+
+
+
+ + +
+
+ )} + +
    + {game.questions.length === 0 && ( +
  • No questions yet. Add some manually or use AI!
  • + )} + {game.questions.map((q, i) => ( +
  • +
    +
    + Q{i+1} + {q.category} + {q.points}pts +
    +
    + {q.mediaUrl && } + {q.text} +
    +
    A: {q.answer}
    +
    + +
  • + ))} +
+
+
+ ); + })() + )} +
+ )} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/PlayerView.tsx b/components/PlayerView.tsx new file mode 100644 index 0000000..2eef2ad --- /dev/null +++ b/components/PlayerView.tsx @@ -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 ( +
+
+

Join Quiz

+

Enter your details to enter the lobby.

+
+
+ + 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 + /> +
+
+ + 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 + /> +
+ +
+
+
+ ); + } + + if (currentPlayer && !currentPlayer.isApproved) { + return ( +
+
+

Waiting for Host...

+

Sit tight! You'll be in shortly.

+ +
+ ); + } + + // --- EXPLICIT FEEDBACK SCREENS --- + + if (isCorrect) { + return ( +
+
+ 🎉 +
+

CORRECT!

+

You got the points!

+
Wait for next question...
+
+ ); + } + + if (isWrong) { + return ( +
+
+ +
+

WRONG!

+

Better luck next time.

+
You are locked out for this question.
+
+ ); + } + + // --- 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 ( +
+ {/* Header */} +
+

Game Over

+ +
+ +
+ {/* Winning Team Trophy */} +
+
+ +
Winning Team
+
{winningTeam?.name || "No Winner"}
+
+ {winningTeam?.score || 0} PTS +
+
+ + {/* Fastest Finger */} + {fastestPlayer && ( +
+
+ +
+
+
Fastest Buzzer
+
{fastestPlayer.name}
+
{(fastestTime / 1000).toFixed(2)}s reaction
+
+
+ )} + + {/* Top Players Table */} +
+

Top 5 Players

+
+ + + + + + + + + + + {topPlayers.map((p, i) => ( + + + + + + + ))} + +
RankNameScoreAcc
#{i + 1}{p.name}{p.score} + {p.stats.correctAnswers}/{p.stats.totalBuzzes} +
+
+
+
+
+ ); + } + + // --- REGULAR GAMEPLAY UI --- + return ( +
+ {/* Header */} +
+
+
{currentPlayer?.name}
+
{currentTeam?.name || 'No Team'}
+
+
+
+
+ {currentTeam?.score || 0} PTS +
+
+ Rank #{myRank} +
+
+ +
+
+ + {/* Main Action Area */} +
+ + {/* Dynamic Question Text & Media on Mobile */} + {gameState.phase !== GamePhase.LOBBY && gameState.phase !== GamePhase.LEADERBOARD && gameState.phase !== GamePhase.FINAL_STATS && currentQ && ( +
+

Current Question

+ + {/* Media Preview on Player Device */} + {currentQ.mediaUrl && ( +
+ {currentQ.mediaType === 'video' ? ( +
+