diff --git a/App.tsx b/App.tsx index 8f0e380..1bcfac8 100644 --- a/App.tsx +++ b/App.tsx @@ -137,10 +137,10 @@ function App() { }); return ( -
+
-
+
{gameState === 'LANDING' || gameState === 'GENERATING' ? ( {auth.isAuthenticated && pendingQuizToSave && ( void; + apiKey: string | undefined; + onSave: (key: string | undefined) => Promise; + saving: boolean; + hasAIAccess: boolean; +} + +export const ApiKeyModal: React.FC = ({ + isOpen, + onClose, + apiKey, + onSave, + saving, + hasAIAccess, +}) => { + useBodyScrollLock(isOpen); + const [localApiKey, setLocalApiKey] = useState(apiKey || ''); + const [showApiKey, setShowApiKey] = useState(false); + + useEffect(() => { + if (isOpen) { + setLocalApiKey(apiKey || ''); + } + }, [isOpen, apiKey]); + + if (!isOpen) return null; + + const handleSave = async () => { + await onSave(localApiKey || undefined); + onClose(); + }; + + return ( + + e.stopPropagation()} + > +
+
+
+ +
+
+

Account Settings

+

Manage your API access

+
+
+ +
+ +
+ {hasAIAccess ? ( +
+

+ + Custom Gemini API Key +

+

+ Use your own API key for quiz generation. Leave empty to use the system key. +

+
+ setLocalApiKey(e.target.value)} + placeholder="AIza..." + className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + /> + +
+
+ ) : ( +
+ +

API access not available

+

Contact an administrator for access

+
+ )} +
+ +
+ + {hasAIAccess && ( + + )} +
+
+
+ ); +}; diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx index 6af93a8..39e1473 100644 --- a/components/AuthButton.tsx +++ b/components/AuthButton.tsx @@ -1,9 +1,31 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useAuth } from 'react-oidc-context'; -import { LogIn, LogOut, User, Loader2 } from 'lucide-react'; +import { LogIn, LogOut, User, Loader2, Key, ChevronDown } from 'lucide-react'; -export const AuthButton: React.FC = () => { +interface AuthButtonProps { + onAccountSettingsClick?: () => void; +} + +export const AuthButton: React.FC = ({ onAccountSettingsClick }) => { const auth = useAuth(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + + if (dropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [dropdownOpen]); if (auth.isLoading) { return ( @@ -24,20 +46,44 @@ export const AuthButton: React.FC = () => { if (auth.isAuthenticated) { return ( -
-
+
+
- + + {dropdownOpen && ( +
+ {onAccountSettingsClick && ( + + )} + +
+ )}
); } diff --git a/components/DisconnectedScreen.tsx b/components/DisconnectedScreen.tsx index 0596d5a..5077fd5 100644 --- a/components/DisconnectedScreen.tsx +++ b/components/DisconnectedScreen.tsx @@ -18,7 +18,7 @@ export const DisconnectedScreen: React.FC = ({ onGoHome, }) => { return ( -
+
= ({ onChange={(v) => update({ hostParticipates: v })} /> + } + iconActive={config.randomNamesEnabled} + label="Random Names" + description="Assign fun two-word names to players" + checked={config.randomNamesEnabled} + onChange={(v) => update({ randomNamesEnabled: v })} + tooltip="Players will be assigned random names like 'Brave Falcon' or 'Swift Tiger' instead of choosing their own nicknames." + /> + } iconActive={config.streakBonusEnabled} diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index 060de98..d68279d 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -40,35 +40,34 @@ export const GameScreen: React.FC = ({ return (
{/* Header */} -
-
+
+
{currentQuestionIndex + 1} / {totalQuestions}
- {/* Whimsical Timer */}
-
-
- {timeLeftSeconds} -
+
+
+ {timeLeftSeconds} +
-
+
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
{/* Question Area */} -
+
{question && ( -

+

{question.text}

@@ -76,7 +75,7 @@ export const GameScreen: React.FC = ({
{/* Answer Grid */} -
+
{displayOptions.map((option, idx) => { const ShapeIcon = SHAPES[option.shape]; const colorClass = COLORS[option.color]; diff --git a/components/HostReconnected.tsx b/components/HostReconnected.tsx index 3069ac0..4783fef 100644 --- a/components/HostReconnected.tsx +++ b/components/HostReconnected.tsx @@ -20,7 +20,7 @@ export const HostReconnected: React.FC = ({ onEndGame, }) => { return ( -
+
= ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { const auth = useAuth(); - const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); + const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN'); const [generateMode, setGenerateMode] = useState('topic'); const [topic, setTopic] = useState(''); const [pin, setPin] = useState(''); @@ -35,12 +38,18 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [useOcr, setUseOcr] = useState(false); const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); const [editingDefaultConfig, setEditingDefaultConfig] = useState(null); + const [preferencesOpen, setPreferencesOpen] = useState(false); + const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); + const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null); + const [checkingPin, setCheckingPin] = useState(false); const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/')); const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type)); const showOcrOption = hasImageFile || hasDocumentFile; const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); + const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); + const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey); const { quizzes, @@ -60,6 +69,30 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on } }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); + useEffect(() => { + const checkGamePin = async () => { + if (pin.trim().length === 6) { + setCheckingPin(true); + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; + const response = await fetch(`${backendUrl}/api/games/${pin.trim()}`); + if (response.ok) { + const data = await response.json(); + setGameInfo({ randomNamesEnabled: data.randomNamesEnabled, quizTitle: data.quizTitle }); + } else { + setGameInfo(null); + } + } catch { + setGameInfo(null); + } + setCheckingPin(false); + } else { + setGameInfo(null); + } + }; + checkGamePin(); + }, [pin]); + const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { const newFiles = Array.from(e.target.files); @@ -104,7 +137,8 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setUseOcr(false); }; - const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0; + const canGenerateInput = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0; + const canGenerate = canUseAI && canGenerateInput; const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -120,7 +154,9 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const handleJoinSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (pin.trim() && name.trim()) onJoin(pin, name); + if (pin.trim() && (gameInfo?.randomNamesEnabled || name.trim())) { + onJoin(pin, name.trim() || 'Player'); + } }; const handleLoadQuiz = async (id: string) => { @@ -137,37 +173,46 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }; return ( -
+
{auth.isAuthenticated && ( - + <> + + + )} - + setAccountSettingsOpen(true)} />
-
-
- +
+
+
-

Kaboot

-

The AI Quiz Party

+

Kaboot

+

The AI Quiz Party

-
+
-
- - - {generateMode === 'topic' ? ( - - setTopic(e.target.value)} - className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center" - disabled={isLoading} - /> - - ) : ( - -
document.getElementById('file-upload')?.click()} - className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${ - isDragging - ? 'border-theme-primary bg-theme-primary/5 scale-[1.02]' - : selectedFiles.length > 0 - ? 'border-theme-primary/50 bg-theme-primary/5' - : 'border-gray-300 hover:border-gray-400 hover:bg-gray-50' - }`} - > - - -
- -

- {isDragging ? 'Drop files here' : 'Drop files or click to browse'} -

-

PDF, DOCX, PPTX, XLSX, TXT, Images

-
-
- - {selectedFiles.length > 0 && ( -
-
- {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected - -
-
- {selectedFiles.map((file, index) => ( -
-
-
- {file.type.startsWith('image/') ? ( - - ) : ( - - )} -
- {file.name} -
- -
- ))} -
-
- )} - - {showOcrOption && ( - - )} -
- )} -
- -
-
- Questions - {questionCount} -
- setQuestionCount(Number(e.target.value))} - className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" - /> -
- - + +
+ + + {generateMode === 'topic' ? ( + + setTopic(e.target.value)} + className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center" + disabled={isLoading} + /> + + ) : ( + +
document.getElementById('file-upload')?.click()} + className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${ + isDragging + ? 'border-theme-primary bg-theme-primary/5 scale-[1.02]' + : selectedFiles.length > 0 + ? 'border-theme-primary/50 bg-theme-primary/5' + : 'border-gray-300 hover:border-gray-400 hover:bg-gray-50' + }`} + > + + +
+ +

+ {isDragging ? 'Drop files here' : 'Drop files or click to browse'} +

+

PDF, DOCX, PPTX, XLSX, TXT, Images

+
+
+ + {selectedFiles.length > 0 && ( +
+
+ {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected + +
+
+ {selectedFiles.map((file, index) => ( +
+
+
+ {file.type.startsWith('image/') ? ( + + ) : ( + + )} +
+ {file.name} +
+ +
+ ))} +
+
+ )} + + {showOcrOption && ( + + )} +
+ )} +
+ +
+
+ Questions + {questionCount} +
+ setQuestionCount(Number(e.target.value))} + className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + /> +
+ + + + +
+
+ OR +
+
)} - - - -
-
- OR -
-
- + - {auth.isAuthenticated && ( - + + )}
) : ( @@ -398,19 +460,27 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on onChange={(e) => setPin(e.target.value)} className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center" /> - setName(e.target.value)} - className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center" - /> + {gameInfo?.randomNamesEnabled ? ( +
+

+ You'll get a random name! +

+
+ ) : ( + setName(e.target.value)} + className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center" + /> + )} )} @@ -452,6 +522,26 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }} saving={savingConfig} /> + + setPreferencesOpen(false)} + preferences={preferences} + onSave={savePreferences} + onPreview={applyColorScheme} + saving={savingPrefs} + /> + + setAccountSettingsOpen(false)} + apiKey={preferences.geminiApiKey} + onSave={async (key) => { + await savePreferences({ ...preferences, geminiApiKey: key }); + }} + saving={savingPrefs} + hasAIAccess={hasAIAccess} + />
); }; diff --git a/components/Lobby.tsx b/components/Lobby.tsx index 972f783..e3c518f 100644 --- a/components/Lobby.tsx +++ b/components/Lobby.tsx @@ -11,23 +11,25 @@ interface LobbyProps { role: 'HOST' | 'CLIENT'; onStart: () => void; onEndGame?: () => void; + currentPlayerId?: string | null; } -export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame }) => { +export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId }) => { const isHost = role === 'HOST'; const realPlayers = players.filter(p => p.id !== 'host'); + const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null; return ( -
-
+
+
- Game PIN -
+ Game PIN +
{gamePin}
-
+
{quizTitle}
@@ -35,23 +37,23 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role,
-
+
{realPlayers.length} - Players + Players
-
+
{isHost ? ( <> -
+
{realPlayers.length === 0 && ( -
-
- +
+
+
-
Waiting for players to join...
+
Waiting for players to join...
)} {realPlayers.map((player) => ( @@ -60,7 +62,7 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, initial={{ scale: 0, rotate: -10 }} animate={{ scale: 1, rotate: 0 }} exit={{ scale: 0, opacity: 0 }} - className="bg-white text-black px-6 py-3 rounded-full font-black text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-3 border-b-4 border-gray-200" + className="bg-white text-black px-4 md:px-6 py-2 md:py-3 rounded-full font-black text-base md:text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-2 md:gap-3 border-b-4 border-gray-200" > {player.name} @@ -72,38 +74,45 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, {onEndGame && ( )} ) : ( -
+
- + {currentPlayer ? ( + + ) : ( + + )} -

You're in!

-

See your name on the big screen?

+

+ {currentPlayer?.name || "You're in!"} +

+

Waiting for the host to start...

)}
diff --git a/components/Podium.tsx b/components/Podium.tsx index 31526be..0030697 100644 --- a/components/Podium.tsx +++ b/components/Podium.tsx @@ -34,10 +34,10 @@ export const Podium: React.FC = ({ players, onRestart }) => { }, []); return ( -
-

Podium

+
+

Podium

-
+
{/* Second Place */} {second && ( void; + preferences: UserPreferences; + onSave: (prefs: UserPreferences) => Promise; + onPreview: (schemeId: string) => void; + saving: boolean; +} + +export const PreferencesModal: React.FC = ({ + isOpen, + onClose, + preferences, + onSave, + onPreview, + saving, +}) => { + useBodyScrollLock(isOpen); + const [localPrefs, setLocalPrefs] = useState(preferences); + + if (!isOpen) return null; + + const handleColorSelect = (schemeId: string) => { + setLocalPrefs(prev => ({ ...prev, colorScheme: schemeId })); + onPreview(schemeId); + }; + + const handleSave = async () => { + await onSave(localPrefs); + onClose(); + }; + + const handleClose = () => { + onPreview(preferences.colorScheme); + onClose(); + }; + + return ( + + e.stopPropagation()} + > +
+
+
+ +
+
+

Color Scheme

+

Customize your theme

+
+
+ +
+ +
+
+ {COLOR_SCHEMES.map((scheme) => ( + + ))} +
+

+ {COLOR_SCHEMES.find(s => s.id === localPrefs.colorScheme)?.name || 'Select a color'} +

+
+ +
+ + +
+
+
+ ); +}; diff --git a/components/QuizCreator.tsx b/components/QuizCreator.tsx index 7260cd1..acaebee 100644 --- a/components/QuizCreator.tsx +++ b/components/QuizCreator.tsx @@ -60,8 +60,8 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } }; return ( -
-
+
+

Create Quiz

@@ -79,7 +79,7 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel }
-
+
= ({ }; return ( -
-
+
+
-
+

Questions