Add api key and sorting on scoreboard

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 14:49:10 -07:00
commit 4688a73559
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
14 changed files with 791 additions and 227 deletions

View file

@ -1,9 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion'; 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 { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { AIProvider, UserPreferences } from '../types'; import type { AIProvider, UserPreferences } from '../types';
interface OpenAIModel {
id: string;
owned_by: string;
}
interface ApiKeyModalProps { interface ApiKeyModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@ -24,28 +29,96 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
useBodyScrollLock(isOpen); useBodyScrollLock(isOpen);
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini'); const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || ''); const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || ''); const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || '');
const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || ''); const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || '');
const [localOpenAIKey, setLocalOpenAIKey] = useState(preferences.openAIApiKey || '');
const [localOpenAIModel, setLocalOpenAIModel] = useState(preferences.openAIModel || '');
const [showGeminiKey, setShowGeminiKey] = useState(false); const [showGeminiKey, setShowGeminiKey] = useState(false);
const [showOpenRouterKey, setShowOpenRouterKey] = useState(false); const [showOpenRouterKey, setShowOpenRouterKey] = useState(false);
const [showOpenAIKey, setShowOpenAIKey] = useState(false);
const [openAIModels, setOpenAIModels] = useState<OpenAIModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini'); setLocalProvider(preferences.aiProvider || 'gemini');
setLocalGeminiKey(preferences.geminiApiKey || ''); setLocalGeminiKey(preferences.geminiApiKey || '');
setLocalGeminiModel(preferences.geminiModel || '');
setLocalOpenRouterKey(preferences.openRouterApiKey || ''); setLocalOpenRouterKey(preferences.openRouterApiKey || '');
setLocalOpenRouterModel(preferences.openRouterModel || ''); setLocalOpenRouterModel(preferences.openRouterModel || '');
setLocalOpenAIKey(preferences.openAIApiKey || '');
setLocalOpenAIModel(preferences.openAIModel || '');
setModelSearchQuery('');
setIsModelDropdownOpen(false);
} }
}, [isOpen, preferences]); }, [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; if (!isOpen) return null;
const handleSave = async () => { const handleSave = async () => {
await onSave({ await onSave({
aiProvider: localProvider, aiProvider: localProvider,
geminiApiKey: localGeminiKey || undefined, geminiApiKey: localGeminiKey || undefined,
geminiModel: localGeminiModel || undefined,
openRouterApiKey: localOpenRouterKey || undefined, openRouterApiKey: localOpenRouterKey || undefined,
openRouterModel: localOpenRouterModel || undefined, openRouterModel: localOpenRouterModel || undefined,
openAIApiKey: localOpenAIKey || undefined,
openAIModel: localOpenAIModel || undefined,
}); });
onClose(); onClose();
}; };
@ -62,10 +135,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900"> <div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900 rounded-t-2xl">
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl"> <div className="p-2 bg-white/20 rounded-xl">
<Key size={24} /> <Key size={24} />
@ -84,15 +157,13 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div> </div>
<div className="p-6 bg-gray-50 space-y-4"> <div className="p-6 bg-gray-50 space-y-4">
{hasAIAccess ? ( <div>
<>
<div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label> <label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl"> <div className="flex bg-gray-200 p-1 rounded-xl">
<button <button
type="button" type="button"
onClick={() => setLocalProvider('gemini')} onClick={() => setLocalProvider('gemini')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${ className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'gemini' localProvider === 'gemini'
? 'bg-white shadow-sm text-gray-800' ? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
@ -100,10 +171,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
> >
Gemini Gemini
</button> </button>
<button
type="button"
onClick={() => setLocalProvider('openai')}
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openai'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
}`}
>
OpenAI
</button>
<button <button
type="button" type="button"
onClick={() => setLocalProvider('openrouter')} onClick={() => setLocalProvider('openrouter')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${ className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openrouter' localProvider === 'openrouter'
? 'bg-white shadow-sm text-gray-800' ? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
@ -114,32 +196,164 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div> </div>
</div> </div>
{localProvider === 'gemini' ? ( {localProvider === 'gemini' && (
<div> <>
<label className="block font-bold text-gray-800 mb-2"> <div>
Gemini API Key <label className="block font-bold text-gray-800 mb-2">
</label> Gemini API Key
<p className="text-sm text-gray-500 mb-2"> </label>
Leave empty to use the system key. <p className="text-sm text-gray-500 mb-2">
</p> Get your key from{' '}
<div className="relative"> <a
<input href="https://aistudio.google.com/apikey"
type={showGeminiKey ? 'text' : 'password'} target="_blank"
value={localGeminiKey} rel="noopener noreferrer"
onChange={(e) => setLocalGeminiKey(e.target.value)} className="text-theme-primary hover:underline"
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" aistudio.google.com/apikey
/> </a>
<button {hasAIAccess ? ' or leave empty to use the system key.' : '.'}
type="button" </p>
onClick={() => setShowGeminiKey(!showGeminiKey)} <div className="relative">
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" <input
> type={showGeminiKey ? 'text' : 'password'}
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />} value={localGeminiKey}
</button> onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div> </div>
</div> <div>
) : ( <label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
Default: gemini-3-flash-preview
</p>
<input
type="text"
value={localGeminiModel}
onChange={(e) => 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"
/>
</div>
</>
)}
{localProvider === 'openai' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
OpenAI API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
platform.openai.com/api-keys
</a>
</p>
<div className="relative">
<input
type={showOpenAIKey ? 'text' : 'password'}
value={localOpenAIKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowOpenAIKey(!showOpenAIKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showOpenAIKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
{localOpenAIKey ? 'Select a model from your account' : 'Enter API key to load models'}
</p>
<div className="relative" ref={modelDropdownRef}>
<div
className={`w-full px-4 py-3 rounded-xl border-2 flex items-center justify-between cursor-pointer transition-colors ${
isModelDropdownOpen ? 'border-theme-primary' : 'border-gray-200'
} ${!localOpenAIKey ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
onClick={() => localOpenAIKey && setIsModelDropdownOpen(!isModelDropdownOpen)}
>
<span className={localOpenAIModel ? 'text-gray-800' : 'text-gray-400'}>
{localOpenAIModel || 'gpt-5-mini'}
</span>
{loadingModels ? (
<Loader2 size={18} className="animate-spin text-gray-400" />
) : (
<ChevronDown size={18} className={`text-gray-400 transition-transform ${isModelDropdownOpen ? 'rotate-180' : ''}`} />
)}
</div>
{isModelDropdownOpen && openAIModels.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border-2 border-gray-200 rounded-xl shadow-lg overflow-hidden">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={modelSearchQuery}
onChange={(e) => 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
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{filteredModels.length > 0 ? (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors ${
localOpenAIModel === model.id ? 'bg-theme-primary/10 text-theme-primary font-medium' : 'text-gray-700'
}`}
onClick={() => {
setLocalOpenAIModel(model.id);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}}
>
{model.id}
</button>
))
) : (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
No models found
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
)}
{localProvider === 'openrouter' && (
<> <>
<div> <div>
<label className="block font-bold text-gray-800 mb-2"> <label className="block font-bold text-gray-800 mb-2">
@ -190,14 +404,6 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div> </div>
</> </>
)} )}
</>
) : (
<div className="text-center py-4">
<Key size={32} className="mx-auto mb-3 text-gray-400" />
<p className="text-gray-500 font-bold">API access not available</p>
<p className="text-sm text-gray-400 mt-1">Contact an administrator for access</p>
</div>
)}
</div> </div>
<div className="p-6 border-t border-gray-100 flex gap-3"> <div className="p-6 border-t border-gray-100 flex gap-3">
@ -207,22 +413,20 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
> >
Cancel Cancel
</button> </button>
{hasAIAccess && ( <button
<button onClick={handleSave}
onClick={handleSave} disabled={saving}
disabled={saving} className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2" >
> {saving ? (
{saving ? ( <>
<> <Loader2 size={20} className="animate-spin" />
<Loader2 size={20} className="animate-spin" /> Saving...
Saving... </>
</> ) : (
) : ( 'Save'
'Save' )}
)} </button>
</button>
)}
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>

View file

@ -25,7 +25,9 @@ interface LandingProps {
useOcr?: boolean; useOcr?: boolean;
aiProvider?: AIProvider; aiProvider?: AIProvider;
apiKey?: string; apiKey?: string;
geminiModel?: string;
openRouterModel?: string; openRouterModel?: string;
openAIModel?: string;
}) => void; }) => void;
onCreateManual: () => void; onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz, quizId?: string) => void; onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
@ -113,9 +115,11 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const hasValidApiKey = preferences.aiProvider === 'openrouter' const hasValidApiKey = (() => {
? !!preferences.openRouterApiKey if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey;
: (hasAIAccess || !!preferences.geminiApiKey); if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey;
return hasAIAccess || !!preferences.geminiApiKey;
})();
const canUseAI = auth.isAuthenticated && hasValidApiKey; const canUseAI = auth.isAuthenticated && hasValidApiKey;
const { const {
@ -219,9 +223,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
e.preventDefault(); e.preventDefault();
if (canGenerate && !isLoading) { if (canGenerate && !isLoading) {
const aiProvider = preferences.aiProvider || 'gemini'; const aiProvider = preferences.aiProvider || 'gemini';
const apiKey = aiProvider === 'openrouter' let apiKey: string | undefined;
? preferences.openRouterApiKey if (aiProvider === 'openrouter') {
: preferences.geminiApiKey; apiKey = preferences.openRouterApiKey;
} else if (aiProvider === 'openai') {
apiKey = preferences.openAIApiKey;
} else {
apiKey = preferences.geminiApiKey;
}
onGenerate({ onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined, topic: generateMode === 'topic' ? topic.trim() : undefined,
@ -230,7 +239,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined, useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined,
aiProvider, aiProvider,
apiKey, apiKey,
geminiModel: aiProvider === 'gemini' ? preferences.geminiModel : undefined,
openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined, openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined,
openAIModel: aiProvider === 'openai' ? preferences.openAIModel : undefined,
}); });
} }
}; };
@ -518,6 +529,23 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</> </>
)} )}
{!canUseAI && (
<button
onClick={() => setSearchParams(buildCleanParams({ modal: 'account' }))}
className="w-full p-4 bg-theme-primary/5 border-2 border-theme-primary/20 rounded-2xl text-left hover:border-theme-primary hover:bg-theme-primary/10 hover:shadow-lg hover:scale-[1.02] transition-all group"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-theme-primary/15 rounded-xl group-hover:bg-theme-primary/25 transition-colors">
<Sparkles size={20} className="text-theme-primary" />
</div>
<div>
<p className="font-bold text-gray-800">AI Quiz Generation Available</p>
<p className="text-sm text-gray-500">Configure your API key in settings to get started</p>
</div>
</div>
</button>
)}
<button <button
onClick={onCreateManual} onClick={onCreateManual}
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2" className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"

View file

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import chroma from 'chroma-js';
import { import {
Cat, Dog, Bird, Fish, Rabbit, Turtle, Cat, Dog, Bird, Fish, Rabbit, Turtle,
Ghost, Skull, Heart, Star, Moon, Sun, Ghost, Skull, Heart, Star, Moon, Sun,
@ -15,20 +16,13 @@ const ICONS = [
Rocket, Plane, Car, Bike, Train, Ship Rocket, Plane, Car, Bike, Train, Ship
]; ];
const GRADIENT_PAIRS = [ export const getAvatarColors = (seed: number): [string, string] => {
['#f472b6', '#c026d3'], const hue = Math.floor(seed * 360);
['#fb923c', '#ea580c'], const primaryColor = chroma.hsl(hue, 0.85, 0.55);
['#facc15', '#ca8a04'], const secondaryColor = chroma.hsl((hue + 35) % 360, 0.8, 0.45);
['#4ade80', '#16a34a'],
['#2dd4bf', '#0d9488'], return [primaryColor.hex(), secondaryColor.hex()];
['#38bdf8', '#0284c7'], };
['#818cf8', '#6366f1'],
['#f87171', '#dc2626'],
['#a78bfa', '#7c3aed'],
['#fb7185', '#e11d48'],
['#34d399', '#059669'],
['#60a5fa', '#2563eb'],
];
interface PlayerAvatarProps { interface PlayerAvatarProps {
seed: number; seed: number;
@ -38,10 +32,9 @@ interface PlayerAvatarProps {
export const PlayerAvatar: React.FC<PlayerAvatarProps> = ({ seed, size = 24, className = '' }) => { export const PlayerAvatar: React.FC<PlayerAvatarProps> = ({ seed, size = 24, className = '' }) => {
const iconIndex = Math.floor(seed * 1000) % ICONS.length; const iconIndex = Math.floor(seed * 1000) % ICONS.length;
const gradientIndex = Math.floor(seed * 10000) % GRADIENT_PAIRS.length;
const Icon = ICONS[iconIndex]; const Icon = ICONS[iconIndex];
const [colorFrom, colorTo] = GRADIENT_PAIRS[gradientIndex]; const [colorFrom, colorTo] = getAvatarColors(seed);
const gradientId = `avatar-gradient-${seed.toString().replace('.', '-')}`; const gradientId = `avatar-gradient-${seed.toString().replace('.', '-')}`;
return ( return (

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Player, PointsBreakdown } from '../types'; import { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion'; import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react'; import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar'; import { PlayerAvatar, getAvatarColors } from './PlayerAvatar';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => { const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
const spring = useSpring(0, { duration }); const spring = useSpring(0, { duration });
@ -53,126 +53,153 @@ const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, del
interface PlayerRowProps { interface PlayerRowProps {
player: Player & { displayName: string }; player: Player & { displayName: string };
index: number;
maxScore: number; maxScore: number;
rank: number;
currentScore: number;
phase: number;
baseDelay: number;
} }
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => { const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
const [phase, setPhase] = useState(0);
const breakdown = player.pointsBreakdown; const breakdown = player.pointsBreakdown;
const baseDelay = index * 0.3;
useEffect(() => { const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000)); const isFirst = rank === 1;
if (breakdown) { const isSecond = rank === 2;
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000)); const isThird = rank === 3;
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000)); const isTop3 = rank <= 3;
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
}
return () => timers.forEach(clearTimeout); let rankStyles = "bg-white border-gray-100";
}, [baseDelay, breakdown]); let rankBadgeStyles = "bg-gray-100 text-gray-500";
const getDisplayScore = () => { if (isFirst) {
if (!breakdown) return player.previousScore; rankStyles = "bg-gradient-to-r from-yellow-50 to-amber-100 border-amber-300 shadow-lg scale-[1.02] z-10";
rankBadgeStyles = "bg-gradient-to-br from-yellow-400 to-amber-600 text-white shadow-sm";
let score = player.previousScore; } else if (isSecond) {
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty; rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0";
if (phase >= 2) score += breakdown.streakBonus; rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm";
if (phase >= 3) score += breakdown.comebackBonus; } else if (isThird) {
if (phase >= 4) score += breakdown.firstCorrectBonus; rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0";
return Math.max(0, score); rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm";
}; }
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} layout
animate={{ opacity: 1, x: 0 }} layoutId={player.id}
transition={{ delay: baseDelay, duration: 0.4 }} initial={{ opacity: 0, y: 20 }}
className="flex flex-col gap-2 py-3" animate={{ opacity: 1, y: 0 }}
transition={{
layout: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.3 }
}}
className={`flex items-center gap-3 md:gap-4 p-3 md:p-4 rounded-xl md:rounded-2xl border-2 ${rankStyles}`}
> >
<div className="flex items-center justify-between gap-3 w-full"> <div className={`flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-full font-black text-lg md:text-xl shrink-0 ${rankBadgeStyles}`}>
<div className="flex items-center gap-3 min-w-0"> {rank}
<PlayerAvatar seed={player.avatarSeed} size={32} /> </div>
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
<div className="flex-1 min-w-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 w-full">
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<PlayerAvatar seed={player.avatarSeed} size={isTop3 ? 40 : 32} />
{isFirst && (
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: -15 }}
transition={{ delay: 0.5, type: 'spring' }}
className="absolute -top-3 -left-2 drop-shadow-md"
>
<Crown size={24} className="fill-yellow-400 text-yellow-600" />
</motion.div>
)}
</div>
<span className={`font-black font-display truncate ${isTop3 ? 'text-xl' : 'text-lg'}`}>
{player.displayName}
</span>
</div>
<span className={`font-black font-display ${isTop3 ? 'text-3xl text-theme-primary' : 'text-2xl text-gray-700'}`}>
<AnimatedNumber value={currentScore} />
</span>
</div> </div>
<span className="font-black text-2xl font-display">
<AnimatedNumber value={getDisplayScore()} />
</span>
</div>
<div className="w-full h-10 md:h-12 bg-gray-100 rounded-full overflow-hidden relative"> <div className="w-full h-8 md:h-10 bg-black/5 rounded-full overflow-hidden relative">
<motion.div <motion.div
className="h-full rounded-full" className="h-full rounded-full"
style={{ backgroundColor: player.color }} style={{ background: `linear-gradient(90deg, ${getAvatarColors(player.avatarSeed)[0]}, ${getAvatarColors(player.avatarSeed)[1]})` }}
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }} animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }} transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
/> />
</div> </div>
<div className="flex flex-wrap items-center gap-2 w-full justify-start"> <div className="flex flex-wrap items-center gap-2 w-full justify-start h-6">
<AnimatePresence> <AnimatePresence mode='popLayout'>
{breakdown === null && ( {breakdown === null && (
<motion.span <motion.span
initial={{ opacity: 0 }} key="no-answer"
animate={{ opacity: 1 }} initial={{ opacity: 0 }}
transition={{ delay: baseDelay + 0.3 }} animate={{ opacity: 1 }}
className="text-gray-400 font-medium text-sm" exit={{ opacity: 0 }}
> transition={{ delay: baseDelay + 0.3 }}
No answer className="text-gray-400 font-medium text-sm"
</motion.span> >
)} No answer
</motion.span>
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && ( {breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge points={breakdown.penalty} delay={0} /> <PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
)} )}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && ( {breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div <motion.div
initial={{ scale: 0, opacity: 0 }} key="base-points"
animate={{ scale: 1, opacity: 1 }} initial={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }} animate={{ scale: 1, opacity: 1 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm" transition={{ type: 'spring', stiffness: 500, damping: 15 }}
> className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500 text-white font-bold text-xs md:text-sm"
<span>+{breakdown.basePoints}</span> >
</motion.div> <span>+{breakdown.basePoints}</span>
)} </motion.div>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && ( {breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge <BonusBadge
points={breakdown.streakBonus} key="streak"
label="Streak" points={breakdown.streakBonus}
icon={<Flame size={14} />} label="Streak"
color="bg-amber-500" icon={<Flame size={12} />}
delay={0} color="bg-amber-500"
/> delay={0}
)} />
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && ( {breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge <BonusBadge
points={breakdown.comebackBonus} key="comeback"
label="Comeback" points={breakdown.comebackBonus}
icon={<Rocket size={14} />} label="Comeback"
color="bg-blue-500" icon={<Rocket size={12} />}
delay={0} color="bg-blue-500"
/> delay={0}
)} />
)}
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && ( {breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge <BonusBadge
points={breakdown.firstCorrectBonus} key="first-correct"
label="First!" points={breakdown.firstCorrectBonus}
icon={<Zap size={14} />} label="First!"
color="bg-yellow-500" icon={<Zap size={12} />}
delay={0} color="bg-yellow-500"
/> delay={0}
)} />
</AnimatePresence> )}
</AnimatePresence>
</div>
</div> </div>
</motion.div> </motion.div>
); );
@ -185,38 +212,144 @@ interface ScoreboardProps {
currentPlayerId: string | null; currentPlayerId: string | null;
} }
interface AnimatedPlayerState extends Player {
displayName: string;
currentScore: number;
phase: number;
initialIndex: number;
}
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => { export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => {
const playersWithDisplayName = players.map(p => ({ // Initialize players sorted by previousScore to start
...p, const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name const playersWithMeta = players.map(p => ({
})); ...p,
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score); displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name,
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1); currentScore: p.previousScore,
phase: 0,
}));
// Sort by previous score initially (descending)
// Add a secondary sort by ID to ensure stable sorting
return playersWithMeta
.sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id))
.map((p, index) => ({
...p,
initialIndex: index // Store the initial rank for staggered animation timing
}));
});
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
// We use the initial state to set up the timers
// We can't use animatedPlayers in the dependency array or it will loop
// But we need a reference to the initial setup to schedule things.
// The state initializer runs once, so we need to reconstruct that list or trust the state is fresh on mount.
// However, inside useEffect, animatedPlayers might change if we include it in deps.
// We want to schedule based on the INITIAL state.
// Let's grab the initial list again to be safe and consistent with the initializer logic
const initialPlayers = players.map(p => ({
...p,
previousScore: p.previousScore,
pointsBreakdown: p.pointsBreakdown
})).sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id));
initialPlayers.forEach((player, initialIndex) => {
const breakdown = player.pointsBreakdown;
const baseDelay = initialIndex * 0.1;
// Helper to update state
const updatePlayerState = (phase: number, scoreToAdd: number) => {
setAnimatedPlayers(prev => {
const updated = prev.map(p => {
if (p.id !== player.id) return p;
return {
...p,
phase,
currentScore: p.currentScore + scoreToAdd
};
});
// Re-sort on every update
return updated.sort((a, b) => (b.currentScore - a.currentScore) || a.id.localeCompare(b.id));
});
};
if (!breakdown) return;
// Phase 1: Base Points + Penalty
// (baseDelay + 0.2)s
timers.push(setTimeout(() => {
const points = breakdown.basePoints - breakdown.penalty;
updatePlayerState(1, points);
}, (baseDelay + 0.2) * 1000));
// Phase 2: Streak
// (baseDelay + 0.8)s
if (breakdown.streakBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(2, breakdown.streakBonus);
}, (baseDelay + 0.8) * 1000));
}
// Phase 3: Comeback
// (baseDelay + 1.2)s
if (breakdown.comebackBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(3, breakdown.comebackBonus);
}, (baseDelay + 1.2) * 1000));
}
// Phase 4: First Correct
// (baseDelay + 1.6)s
if (breakdown.firstCorrectBonus > 0) {
timers.push(setTimeout(() => {
updatePlayerState(4, breakdown.firstCorrectBonus);
}, (baseDelay + 1.6) * 1000));
}
});
return () => timers.forEach(clearTimeout);
}, []); // Run once on mount
// Calculate max score based on FINAL scores (so the bar scale is consistent/correct relative to the winner)
const maxScore = Math.max(...players.map(p => p.score), 1);
return ( return (
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden"> <div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden bg-theme-bg">
<header className="text-center mb-4 md:mb-8 shrink-0"> <header className="text-center mb-4 md:mb-8 shrink-0">
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1> <h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header> </header>
<div className="flex-1 min-h-0 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto"> <div className="flex-1 min-h-0 bg-white/95 backdrop-blur-sm rounded-2xl md:rounded-[3rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] p-4 md:p-8 text-gray-900 max-w-4xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto custom-scrollbar">
<div className="space-y-2"> <LayoutGroup>
{sortedPlayers.map((player, index) => ( <div className="space-y-3">
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} /> {animatedPlayers.map((player, index) => (
))} <PlayerRow
</div> key={player.id}
player={player}
maxScore={maxScore}
rank={index + 1}
currentScore={player.currentScore}
phase={player.phase}
baseDelay={player.initialIndex * 0.1}
/>
))}
</div>
</LayoutGroup>
</div> </div>
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto shrink-0"> <div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20">
{isHost ? ( {isHost ? (
<button <button
onClick={onNext} onClick={onNext}
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all" className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all flex items-center gap-2"
> >
Next Next <Trophy size={24} />
</button> </button>
) : ( ) : (
<div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20"> <div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20 shadow-lg text-white">
<Loader2 className="animate-spin w-6 h-6 md:w-8 md:h-8" /> <Loader2 className="animate-spin w-6 h-6 md:w-8 md:h-8" />
<span className="text-base md:text-xl font-bold">Waiting for host...</span> <span className="text-base md:text-xl font-bold">Waiting for host...</span>
</div> </div>

View file

@ -390,16 +390,20 @@ export const useGame = () => {
} }
setIsReconnecting(true); 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); const hostData = await fetchHostSession(session.pin, session.hostSecret);
if (!hostData) { if (!hostData) {
clearStoredSession(); clearStoredSession();
setIsReconnecting(false); setIsReconnecting(false);
setGameState('LANDING');
return; return;
} }
setRole('HOST');
setGamePin(session.pin);
setHostSecret(session.hostSecret); setHostSecret(session.hostSecret);
setQuiz(hostData.quiz); setQuiz(hostData.quiz);
setGameConfig(hostData.gameConfig); setGameConfig(hostData.gameConfig);
@ -422,6 +426,7 @@ export const useGame = () => {
} }
} else { } else {
setPlayers([]); setPlayers([]);
setCurrentPlayerName('Host'); // Ensure name is set for DisconnectedScreen
} }
setupHostPeer(session.pin, async (peerId) => { setupHostPeer(session.pin, async (peerId) => {
@ -452,6 +457,7 @@ export const useGame = () => {
setRole('CLIENT'); setRole('CLIENT');
setGamePin(session.pin); setGamePin(session.pin);
setCurrentPlayerName(session.playerName); setCurrentPlayerName(session.playerName);
setGameState('WAITING_TO_REJOIN'); // Show "Welcome Back" screen early
const gameInfo = await fetchGameInfo(session.pin); const gameInfo = await fetchGameInfo(session.pin);
@ -464,6 +470,7 @@ export const useGame = () => {
if (gameInfo.gameState === 'PODIUM') { if (gameInfo.gameState === 'PODIUM') {
clearStoredSession(); clearStoredSession();
setIsReconnecting(false); setIsReconnecting(false);
setGameState('LANDING');
return; return;
} }
@ -493,30 +500,34 @@ export const useGame = () => {
const hostMatch = path.match(/^\/host\/(\d+)$/); const hostMatch = path.match(/^\/host\/(\d+)$/);
const playMatch = path.match(/^\/play\/(\d+)$/); const playMatch = path.match(/^\/play\/(\d+)$/);
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);
} else {
await reconnectAsClient(session);
}
isInitializingFromUrl.current = false;
return;
}
if (hostMatch) { if (hostMatch) {
const pin = hostMatch[1]; navigate('/', { replace: true });
const session = getStoredSession();
if (session && session.pin === pin && session.role === 'HOST') {
isInitializingFromUrl.current = true;
await reconnectAsHost(session);
isInitializingFromUrl.current = false;
} else {
navigate('/', { replace: true });
}
return; return;
} }
if (playMatch) { if (playMatch) {
const pin = playMatch[1]; const urlPin = playMatch[1];
const session = getStoredSession(); if (session && session.pin === urlPin && session.role === 'CLIENT' && session.playerName) {
if (session && session.pin === pin && session.role === 'CLIENT') {
isInitializingFromUrl.current = true; isInitializingFromUrl.current = true;
await reconnectAsClient(session); await reconnectAsClient(session);
isInitializingFromUrl.current = false; isInitializingFromUrl.current = false;
} else { return;
setGamePin(pin);
} }
setGamePin(urlPin);
return; return;
} }
@ -563,7 +574,6 @@ export const useGame = () => {
return; return;
} }
const session = getStoredSession();
if (session) { if (session) {
if (session.role === 'HOST') { if (session.role === 'HOST') {
reconnectAsHost(session); reconnectAsHost(session);
@ -605,9 +615,11 @@ export const useGame = () => {
questionCount?: number; questionCount?: number;
files?: File[]; files?: File[];
useOcr?: boolean; useOcr?: boolean;
aiProvider?: 'gemini' | 'openrouter'; aiProvider?: 'gemini' | 'openrouter' | 'openai';
apiKey?: string; apiKey?: string;
geminiModel?: string;
openRouterModel?: string; openRouterModel?: string;
openAIModel?: string;
}) => { }) => {
try { try {
setGameState('GENERATING'); setGameState('GENERATING');
@ -627,7 +639,9 @@ export const useGame = () => {
documents, documents,
aiProvider: options.aiProvider, aiProvider: options.aiProvider,
apiKey: options.apiKey, apiKey: options.apiKey,
geminiModel: options.geminiModel,
openRouterModel: options.openRouterModel, openRouterModel: options.openRouterModel,
openAIModel: options.openAIModel,
}; };
const generatedQuiz = await generateQuiz(generateOptions); const generatedQuiz = await generateQuiz(generateOptions);

View file

@ -45,8 +45,11 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
colorScheme: data.colorScheme || 'blue', colorScheme: data.colorScheme || 'blue',
aiProvider: data.aiProvider || 'gemini', aiProvider: data.aiProvider || 'gemini',
geminiApiKey: data.geminiApiKey || undefined, geminiApiKey: data.geminiApiKey || undefined,
geminiModel: data.geminiModel || undefined,
openRouterApiKey: data.openRouterApiKey || undefined, openRouterApiKey: data.openRouterApiKey || undefined,
openRouterModel: data.openRouterModel || undefined, openRouterModel: data.openRouterModel || undefined,
openAIApiKey: data.openAIApiKey || undefined,
openAIModel: data.openAIModel || undefined,
}; };
setPreferences(prefs); setPreferences(prefs);
setHasAIAccess(data.hasAIAccess || false); setHasAIAccess(data.hasAIAccess || false);

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Kaboot</title> <title>Kaboot</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>

15
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chroma-js": "^3.2.0",
"framer-motion": "^12.26.1", "framer-motion": "^12.26.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
@ -30,6 +31,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.17",
@ -1727,6 +1729,13 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/chroma-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.2.tgz",
"integrity": "sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -2202,6 +2211,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/chroma-js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",

View file

@ -17,6 +17,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chroma-js": "^3.2.0",
"framer-motion": "^12.26.1", "framer-motion": "^12.26.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
@ -34,6 +35,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.17",

15
public/favicon.svg Normal file
View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M12 13h4" />
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
<path d="M12 8h8" />
<path d="M16 8V5a2 2 0 0 1 2-2" />
<circle cx="16" cy="13" r=".5" />
<circle cx="18" cy="3" r=".5" />
<circle cx="20" cy="21" r=".5" />
<circle cx="20" cy="8" r=".5" />
</svg>

After

Width:  |  Height:  |  Size: 730 B

View file

@ -90,6 +90,24 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT"); db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT");
console.log("Migration: Added openrouter_model to users"); console.log("Migration: Added openrouter_model to users");
} }
const hasOpenAIKey = userTableInfo2.some(col => col.name === "openai_api_key");
if (!hasOpenAIKey) {
db.exec("ALTER TABLE users ADD COLUMN openai_api_key TEXT");
console.log("Migration: Added openai_api_key to users");
}
const hasOpenAIModel = userTableInfo2.some(col => col.name === "openai_model");
if (!hasOpenAIModel) {
db.exec("ALTER TABLE users ADD COLUMN openai_model TEXT");
console.log("Migration: Added openai_model to users");
}
const hasGeminiModel = userTableInfo2.some(col => col.name === "gemini_model");
if (!hasGeminiModel) {
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
console.log("Migration: Added gemini_model to users");
}
}; };
runMigrations(); runMigrations();

View file

@ -106,16 +106,20 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(` const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey, SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey,
ai_provider as aiProvider, openrouter_api_key as openRouterApiKey, gemini_model as geminiModel, ai_provider as aiProvider,
openrouter_model as openRouterModel openrouter_api_key as openRouterApiKey, openrouter_model as openRouterModel,
openai_api_key as openAIApiKey, openai_model as openAIModel
FROM users FROM users
WHERE id = ? WHERE id = ?
`).get(userSub) as { `).get(userSub) as {
colorScheme: string | null; colorScheme: string | null;
geminiApiKey: string | null; geminiApiKey: string | null;
geminiModel: string | null;
aiProvider: string | null; aiProvider: string | null;
openRouterApiKey: string | null; openRouterApiKey: string | null;
openRouterModel: string | null; openRouterModel: string | null;
openAIApiKey: string | null;
openAIModel: string | null;
} | undefined; } | undefined;
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
@ -125,30 +129,37 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
colorScheme: user?.colorScheme || 'blue', colorScheme: user?.colorScheme || 'blue',
aiProvider: user?.aiProvider || 'gemini', aiProvider: user?.aiProvider || 'gemini',
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub), geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
geminiModel: user?.geminiModel || null,
openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub), openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub),
openRouterModel: user?.openRouterModel || null, openRouterModel: user?.openRouterModel || null,
openAIApiKey: decryptForUser(user?.openAIApiKey || null, userSub),
openAIModel: user?.openAIModel || null,
hasAIAccess, hasAIAccess,
}); });
}); });
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub; const userSub = req.user!.sub;
const { colorScheme, geminiApiKey, aiProvider, openRouterApiKey, openRouterModel } = req.body; const { colorScheme, geminiApiKey, geminiModel, aiProvider, openRouterApiKey, openRouterModel, openAIApiKey, openAIModel } = req.body;
const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub); const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub);
const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub); const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub);
const encryptedOpenAIKey = encryptForUser(openAIApiKey || null, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub); const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub); const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(` const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, ai_provider, openrouter_api_key, openrouter_model, last_login) INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, gemini_model, ai_provider, openrouter_api_key, openrouter_model, openai_api_key, openai_model, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
color_scheme = ?, color_scheme = ?,
gemini_api_key = ?, gemini_api_key = ?,
gemini_model = ?,
ai_provider = ?, ai_provider = ?,
openrouter_api_key = ?, openrouter_api_key = ?,
openrouter_model = ?, openrouter_model = ?,
openai_api_key = ?,
openai_model = ?,
last_login = CURRENT_TIMESTAMP last_login = CURRENT_TIMESTAMP
`); `);
@ -159,14 +170,20 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
encryptedDisplayName, encryptedDisplayName,
colorScheme || 'blue', colorScheme || 'blue',
encryptedGeminiKey, encryptedGeminiKey,
geminiModel || null,
aiProvider || 'gemini', aiProvider || 'gemini',
encryptedOpenRouterKey, encryptedOpenRouterKey,
openRouterModel || null, openRouterModel || null,
encryptedOpenAIKey,
openAIModel || null,
colorScheme || 'blue', colorScheme || 'blue',
encryptedGeminiKey, encryptedGeminiKey,
geminiModel || null,
aiProvider || 'gemini', aiProvider || 'gemini',
encryptedOpenRouterKey, encryptedOpenRouterKey,
openRouterModel || null openRouterModel || null,
encryptedOpenAIKey,
openAIModel || null
); );
res.json({ success: true }); res.json({ success: true });

View file

@ -10,9 +10,14 @@ const getGeminiClient = (apiKey?: string) => {
return new GoogleGenAI({ apiKey: key }); return new GoogleGenAI({ apiKey: key });
}; };
const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview'; const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview';
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
const DEFAULT_OPENAI_MODEL = 'gpt-5-mini';
const QUIZ_SCHEMA = { const QUIZ_SCHEMA = {
type: Type.OBJECT, type: Type.OBJECT,
properties: { properties: {
@ -173,6 +178,39 @@ const JSON_SCHEMA_FOR_OPENROUTER = {
required: ["title", "questions"] required: ["title", "questions"]
}; };
const JSON_SCHEMA_FOR_OPENAI = {
type: "object",
properties: {
title: { type: "string", description: "A catchy title for the quiz" },
questions: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string", description: "The question text" },
options: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string" },
isCorrect: { type: "boolean" },
reason: { type: "string", description: "Brief explanation of why this answer is correct or incorrect" }
},
required: ["text", "isCorrect", "reason"],
additionalProperties: false
},
}
},
required: ["text", "options"],
additionalProperties: false
}
}
},
required: ["title", "questions"],
additionalProperties: false
};
async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Quiz> { async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Quiz> {
const ai = getGeminiClient(options.apiKey); const ai = getGeminiClient(options.apiKey);
@ -200,8 +238,10 @@ async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Qui
contents = prompt; contents = prompt;
} }
const model = options.geminiModel || DEFAULT_GEMINI_MODEL;
const response = await ai.models.generateContent({ const response = await ai.models.generateContent({
model: "gemini-3-flash-preview", model,
contents, contents,
config: { config: {
responseMimeType: "application/json", responseMimeType: "application/json",
@ -294,6 +334,78 @@ async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise
return transformToQuiz(data); return transformToQuiz(data);
} }
async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise<Quiz> {
const apiKey = options.apiKey;
if (!apiKey) {
throw new Error("OpenAI API key is missing");
}
const docs = options.documents || [];
const hasDocuments = docs.length > 0;
const prompt = buildPrompt(options, hasDocuments, true);
let fullPrompt = prompt;
if (hasDocuments) {
const textParts: string[] = [];
for (const doc of docs) {
if (doc.type === 'text') {
textParts.push(doc.content as string);
} else if (doc.type === 'native') {
console.warn('Native document type not supported with OpenAI - document will be skipped');
}
}
if (textParts.length > 0) {
fullPrompt = `Here is the content to create a quiz from:\n\n${textParts.join('\n\n---\n\n')}\n\n${prompt}`;
}
}
const model = options.openAIModel || DEFAULT_OPENAI_MODEL;
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content: fullPrompt
}
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'quiz',
strict: true,
schema: JSON_SCHEMA_FOR_OPENAI
}
}
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }));
throw new Error(error.error?.message || `OpenAI API error: ${response.status}`);
}
const result = await response.json();
const content = result.choices?.[0]?.message?.content;
// console.log('[OpenAI] Raw response:', content);
if (!content) {
throw new Error("Failed to generate quiz content from OpenAI");
}
const data = JSON.parse(content);
return transformToQuiz(data);
}
export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => { export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => {
const provider = options.aiProvider || 'gemini'; const provider = options.aiProvider || 'gemini';
@ -301,5 +413,9 @@ export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz>
return generateQuizWithOpenRouter(options); return generateQuizWithOpenRouter(options);
} }
if (provider === 'openai') {
return generateQuizWithOpenAI(options);
}
return generateQuizWithGemini(options); return generateQuizWithGemini(options);
}; };

View file

@ -36,14 +36,17 @@ export const COLOR_SCHEMES: ColorScheme[] = [
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' }, { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
]; ];
export type AIProvider = 'gemini' | 'openrouter'; export type AIProvider = 'gemini' | 'openrouter' | 'openai';
export interface UserPreferences { export interface UserPreferences {
colorScheme: string; colorScheme: string;
aiProvider?: AIProvider; aiProvider?: AIProvider;
geminiApiKey?: string; geminiApiKey?: string;
geminiModel?: string;
openRouterApiKey?: string; openRouterApiKey?: string;
openRouterModel?: string; openRouterModel?: string;
openAIApiKey?: string;
openAIModel?: string;
} }
export type GameRole = 'HOST' | 'CLIENT'; export type GameRole = 'HOST' | 'CLIENT';
@ -134,7 +137,9 @@ export interface GenerateQuizOptions {
documents?: ProcessedDocument[]; documents?: ProcessedDocument[];
aiProvider?: AIProvider; aiProvider?: AIProvider;
apiKey?: string; apiKey?: string;
geminiModel?: string;
openRouterModel?: string; openRouterModel?: string;
openAIModel?: string;
} }
export interface PointsBreakdown { export interface PointsBreakdown {