From 4688a7355949ad414bff34157eb254675ee09d8e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 15 Jan 2026 14:49:10 -0700 Subject: [PATCH] Add api key and sorting on scoreboard --- components/ApiKeyModal.tsx | 320 ++++++++++++++++++++++++------ components/Landing.tsx | 40 +++- components/PlayerAvatar.tsx | 25 +-- components/Scoreboard.tsx | 375 ++++++++++++++++++++++++------------ hooks/useGame.ts | 50 +++-- hooks/useUserPreferences.ts | 3 + index.html | 1 + package-lock.json | 15 ++ package.json | 2 + public/favicon.svg | 15 ++ server/src/db/connection.ts | 18 ++ server/src/routes/users.ts | 29 ++- services/geminiService.ts | 118 +++++++++++- types.ts | 7 +- 14 files changed, 791 insertions(+), 227 deletions(-) create mode 100644 public/favicon.svg diff --git a/components/ApiKeyModal.tsx b/components/ApiKeyModal.tsx index 89e3228..72b1c28 100644 --- a/components/ApiKeyModal.tsx +++ b/components/ApiKeyModal.tsx @@ -1,9 +1,14 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; -import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react'; +import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } from 'lucide-react'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import type { AIProvider, UserPreferences } from '../types'; +interface OpenAIModel { + id: string; + owned_by: string; +} + interface ApiKeyModalProps { isOpen: boolean; onClose: () => void; @@ -24,28 +29,96 @@ export const ApiKeyModal: React.FC = ({ useBodyScrollLock(isOpen); const [localProvider, setLocalProvider] = useState(preferences.aiProvider || 'gemini'); const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || ''); + const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || ''); const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || ''); const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || ''); + const [localOpenAIKey, setLocalOpenAIKey] = useState(preferences.openAIApiKey || ''); + const [localOpenAIModel, setLocalOpenAIModel] = useState(preferences.openAIModel || ''); const [showGeminiKey, setShowGeminiKey] = useState(false); const [showOpenRouterKey, setShowOpenRouterKey] = useState(false); + const [showOpenAIKey, setShowOpenAIKey] = useState(false); + + const [openAIModels, setOpenAIModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + const [modelSearchQuery, setModelSearchQuery] = useState(''); + const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); + const modelDropdownRef = useRef(null); useEffect(() => { if (isOpen) { setLocalProvider(preferences.aiProvider || 'gemini'); setLocalGeminiKey(preferences.geminiApiKey || ''); + setLocalGeminiModel(preferences.geminiModel || ''); setLocalOpenRouterKey(preferences.openRouterApiKey || ''); setLocalOpenRouterModel(preferences.openRouterModel || ''); + setLocalOpenAIKey(preferences.openAIApiKey || ''); + setLocalOpenAIModel(preferences.openAIModel || ''); + setModelSearchQuery(''); + setIsModelDropdownOpen(false); } }, [isOpen, preferences]); + useEffect(() => { + const fetchOpenAIModels = async () => { + if (!localOpenAIKey || localOpenAIKey.length < 10) { + setOpenAIModels([]); + return; + } + + setLoadingModels(true); + try { + const response = await fetch('https://api.openai.com/v1/models', { + headers: { + 'Authorization': `Bearer ${localOpenAIKey}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + const models = (data.data as (OpenAIModel & { created: number })[]) + .filter(m => !m.id.includes('realtime') && !m.id.includes('audio') && !m.id.includes('tts') && !m.id.includes('whisper') && !m.id.includes('dall-e') && !m.id.includes('embedding')) + .sort((a, b) => b.created - a.created); + setOpenAIModels(models); + } else { + setOpenAIModels([]); + } + } catch { + setOpenAIModels([]); + } finally { + setLoadingModels(false); + } + }; + + const debounceTimer = setTimeout(fetchOpenAIModels, 500); + return () => clearTimeout(debounceTimer); + }, [localOpenAIKey]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) { + setIsModelDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const filteredModels = openAIModels.filter(model => + model.id.toLowerCase().includes(modelSearchQuery.toLowerCase()) + ); + if (!isOpen) return null; const handleSave = async () => { await onSave({ aiProvider: localProvider, geminiApiKey: localGeminiKey || undefined, + geminiModel: localGeminiModel || undefined, openRouterApiKey: localOpenRouterKey || undefined, openRouterModel: localOpenRouterModel || undefined, + openAIApiKey: localOpenAIKey || undefined, + openAIModel: localOpenAIModel || undefined, }); onClose(); }; @@ -62,10 +135,10 @@ export const ApiKeyModal: React.FC = ({ initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} - className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-hidden" + className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-visible" onClick={(e) => e.stopPropagation()} > -
+
@@ -84,15 +157,13 @@ export const ApiKeyModal: React.FC = ({
- {hasAIAccess ? ( - <> -
+
+
- {localProvider === 'gemini' ? ( -
- -

- Leave empty to use the system key. -

-
- setLocalGeminiKey(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" - /> - + {localProvider === 'gemini' && ( + <> +
+ +

+ Get your key from{' '} + + aistudio.google.com/apikey + + {hasAIAccess ? ' or leave empty to use the system key.' : '.'} +

+
+ setLocalGeminiKey(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" + /> + +
-
- ) : ( +
+ +

+ Default: gemini-3-flash-preview +

+ setLocalGeminiModel(e.target.value)} + placeholder="gemini-3-flash-preview" + className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + /> +
+ + )} + + {localProvider === 'openai' && ( + <> +
+ +

+ Get your key from{' '} + + platform.openai.com/api-keys + +

+
+ setLocalOpenAIKey(e.target.value)} + placeholder="sk-..." + 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" + /> + +
+
+
+ +

+ {localOpenAIKey ? 'Select a model from your account' : 'Enter API key to load models'} +

+
+
localOpenAIKey && setIsModelDropdownOpen(!isModelDropdownOpen)} + > + + {localOpenAIModel || 'gpt-5-mini'} + + {loadingModels ? ( + + ) : ( + + )} +
+ + {isModelDropdownOpen && openAIModels.length > 0 && ( +
+
+
+ + setModelSearchQuery(e.target.value)} + placeholder="Search models..." + className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + autoFocus + /> +
+
+
+ {filteredModels.length > 0 ? ( + filteredModels.map((model) => ( + + )) + ) : ( +
+ No models found +
+ )} +
+
+ )} +
+
+ + )} + + {localProvider === 'openrouter' && ( <>
)} - - ) : ( -
- -

API access not available

-

Contact an administrator for access

-
- )}
@@ -207,22 +413,20 @@ export const ApiKeyModal: React.FC = ({ > Cancel - {hasAIAccess && ( - - )} +
diff --git a/components/Landing.tsx b/components/Landing.tsx index 92e0c9f..41edd05 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -25,7 +25,9 @@ interface LandingProps { useOcr?: boolean; aiProvider?: AIProvider; apiKey?: string; + geminiModel?: string; openRouterModel?: string; + openAIModel?: string; }) => void; onCreateManual: () => void; onLoadQuiz: (quiz: Quiz, quizId?: string) => void; @@ -113,9 +115,11 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); - const hasValidApiKey = preferences.aiProvider === 'openrouter' - ? !!preferences.openRouterApiKey - : (hasAIAccess || !!preferences.geminiApiKey); + const hasValidApiKey = (() => { + if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey; + if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey; + return hasAIAccess || !!preferences.geminiApiKey; + })(); const canUseAI = auth.isAuthenticated && hasValidApiKey; const { @@ -219,9 +223,14 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on e.preventDefault(); if (canGenerate && !isLoading) { const aiProvider = preferences.aiProvider || 'gemini'; - const apiKey = aiProvider === 'openrouter' - ? preferences.openRouterApiKey - : preferences.geminiApiKey; + let apiKey: string | undefined; + if (aiProvider === 'openrouter') { + apiKey = preferences.openRouterApiKey; + } else if (aiProvider === 'openai') { + apiKey = preferences.openAIApiKey; + } else { + apiKey = preferences.geminiApiKey; + } onGenerate({ topic: generateMode === 'topic' ? topic.trim() : undefined, @@ -230,7 +239,9 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined, aiProvider, apiKey, + geminiModel: aiProvider === 'gemini' ? preferences.geminiModel : undefined, openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined, + openAIModel: aiProvider === 'openai' ? preferences.openAIModel : undefined, }); } }; @@ -518,6 +529,23 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on )} + {!canUseAI && ( + + )} + ) : ( -
+
Waiting for host...
diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 090a654..6ea27ec 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -390,16 +390,20 @@ export const useGame = () => { } setIsReconnecting(true); + setRole('HOST'); + setGamePin(session.pin); + setGameState('DISCONNECTED'); // Show loading state on disconnected screen + setCurrentPlayerName("Host"); + const hostData = await fetchHostSession(session.pin, session.hostSecret); if (!hostData) { clearStoredSession(); setIsReconnecting(false); + setGameState('LANDING'); return; } - setRole('HOST'); - setGamePin(session.pin); setHostSecret(session.hostSecret); setQuiz(hostData.quiz); setGameConfig(hostData.gameConfig); @@ -422,6 +426,7 @@ export const useGame = () => { } } else { setPlayers([]); + setCurrentPlayerName('Host'); // Ensure name is set for DisconnectedScreen } setupHostPeer(session.pin, async (peerId) => { @@ -452,6 +457,7 @@ export const useGame = () => { setRole('CLIENT'); setGamePin(session.pin); setCurrentPlayerName(session.playerName); + setGameState('WAITING_TO_REJOIN'); // Show "Welcome Back" screen early const gameInfo = await fetchGameInfo(session.pin); @@ -464,6 +470,7 @@ export const useGame = () => { if (gameInfo.gameState === 'PODIUM') { clearStoredSession(); setIsReconnecting(false); + setGameState('LANDING'); return; } @@ -493,30 +500,34 @@ export const useGame = () => { const hostMatch = path.match(/^\/host\/(\d+)$/); const playMatch = path.match(/^\/play\/(\d+)$/); - - if (hostMatch) { - const pin = hostMatch[1]; - const session = getStoredSession(); - if (session && session.pin === pin && session.role === 'HOST') { - isInitializingFromUrl.current = true; + const session = getStoredSession(); + const pinFromUrl = hostMatch ? hostMatch[1] : (playMatch ? playMatch[1] : null); + + if (pinFromUrl && session && session.pin === pinFromUrl) { + isInitializingFromUrl.current = true; + if (session.role === 'HOST') { await reconnectAsHost(session); - isInitializingFromUrl.current = false; } else { - navigate('/', { replace: true }); + await reconnectAsClient(session); } + isInitializingFromUrl.current = false; return; } - + + if (hostMatch) { + navigate('/', { replace: true }); + return; + } + if (playMatch) { - const pin = playMatch[1]; - const session = getStoredSession(); - if (session && session.pin === pin && session.role === 'CLIENT') { + const urlPin = playMatch[1]; + if (session && session.pin === urlPin && session.role === 'CLIENT' && session.playerName) { isInitializingFromUrl.current = true; await reconnectAsClient(session); isInitializingFromUrl.current = false; - } else { - setGamePin(pin); + return; } + setGamePin(urlPin); return; } @@ -563,7 +574,6 @@ export const useGame = () => { return; } - const session = getStoredSession(); if (session) { if (session.role === 'HOST') { reconnectAsHost(session); @@ -605,9 +615,11 @@ export const useGame = () => { questionCount?: number; files?: File[]; useOcr?: boolean; - aiProvider?: 'gemini' | 'openrouter'; + aiProvider?: 'gemini' | 'openrouter' | 'openai'; apiKey?: string; + geminiModel?: string; openRouterModel?: string; + openAIModel?: string; }) => { try { setGameState('GENERATING'); @@ -627,7 +639,9 @@ export const useGame = () => { documents, aiProvider: options.aiProvider, apiKey: options.apiKey, + geminiModel: options.geminiModel, openRouterModel: options.openRouterModel, + openAIModel: options.openAIModel, }; const generatedQuiz = await generateQuiz(generateOptions); diff --git a/hooks/useUserPreferences.ts b/hooks/useUserPreferences.ts index ee23703..de8f16d 100644 --- a/hooks/useUserPreferences.ts +++ b/hooks/useUserPreferences.ts @@ -45,8 +45,11 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { colorScheme: data.colorScheme || 'blue', aiProvider: data.aiProvider || 'gemini', geminiApiKey: data.geminiApiKey || undefined, + geminiModel: data.geminiModel || undefined, openRouterApiKey: data.openRouterApiKey || undefined, openRouterModel: data.openRouterModel || undefined, + openAIApiKey: data.openAIApiKey || undefined, + openAIModel: data.openAIModel || undefined, }; setPreferences(prefs); setHasAIAccess(data.hasAIAccess || false); diff --git a/index.html b/index.html index 925b482..32e3406 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + Kaboot