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 { 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<ApiKeyModalProps> = ({
useBodyScrollLock(isOpen);
const [localProvider, setLocalProvider] = useState<AIProvider>(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<OpenAIModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(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<ApiKeyModalProps> = ({
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()}
>
<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="p-2 bg-white/20 rounded-xl">
<Key size={24} />
@ -84,15 +157,13 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
<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>
<div className="flex bg-gray-200 p-1 rounded-xl">
<button
type="button"
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'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
@ -100,10 +171,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
>
Gemini
</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
type="button"
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'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
@ -114,32 +196,164 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
</div>
{localProvider === 'gemini' ? (
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Leave empty to use the system key.
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
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>
{localProvider === 'gemini' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
aistudio.google.com/apikey
</a>
{hasAIAccess ? ' or leave empty to use the system key.' : '.'}
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
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>
<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>
<label className="block font-bold text-gray-800 mb-2">
@ -190,14 +404,6 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</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 className="p-6 border-t border-gray-100 flex gap-3">
@ -207,22 +413,20 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
>
Cancel
</button>
{hasAIAccess && (
<button
onClick={handleSave}
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"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save'
)}
</button>
)}
<button
onClick={handleSave}
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"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save'
)}
</button>
</div>
</motion.div>
</motion.div>

View file

@ -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<LandingProps> = ({ 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<LandingProps> = ({ 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<LandingProps> = ({ 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<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
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"

View file

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

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar';
import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react';
import { PlayerAvatar, getAvatarColors } from './PlayerAvatar';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
const spring = useSpring(0, { duration });
@ -53,126 +53,153 @@ const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, del
interface PlayerRowProps {
player: Player & { displayName: string };
index: number;
maxScore: number;
rank: number;
currentScore: number;
phase: number;
baseDelay: number;
}
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
const [phase, setPhase] = useState(0);
const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
const breakdown = player.pointsBreakdown;
const baseDelay = index * 0.3;
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000));
if (breakdown) {
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000));
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000));
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
}
return () => timers.forEach(clearTimeout);
}, [baseDelay, breakdown]);
const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
const getDisplayScore = () => {
if (!breakdown) return player.previousScore;
let score = player.previousScore;
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty;
if (phase >= 2) score += breakdown.streakBonus;
if (phase >= 3) score += breakdown.comebackBonus;
if (phase >= 4) score += breakdown.firstCorrectBonus;
return Math.max(0, score);
};
const isFirst = rank === 1;
const isSecond = rank === 2;
const isThird = rank === 3;
const isTop3 = rank <= 3;
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
let rankStyles = "bg-white border-gray-100";
let rankBadgeStyles = "bg-gray-100 text-gray-500";
if (isFirst) {
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";
} else if (isSecond) {
rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0";
rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm";
} else if (isThird) {
rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0";
rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm";
}
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: baseDelay, duration: 0.4 }}
className="flex flex-col gap-2 py-3"
layout
layoutId={player.id}
initial={{ opacity: 0, y: 20 }}
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 gap-3 min-w-0">
<PlayerAvatar seed={player.avatarSeed} size={32} />
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
<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}`}>
{rank}
</div>
<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>
<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">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: player.color }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
/>
</div>
<div className="w-full h-8 md:h-10 bg-black/5 rounded-full overflow-hidden relative">
<motion.div
className="h-full rounded-full"
style={{ background: `linear-gradient(90deg, ${getAvatarColors(player.avatarSeed)[0]}, ${getAvatarColors(player.avatarSeed)[1]})` }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
/>
</div>
<div className="flex flex-wrap items-center gap-2 w-full justify-start">
<AnimatePresence>
{breakdown === null && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
<div className="flex flex-wrap items-center gap-2 w-full justify-start h-6">
<AnimatePresence mode='popLayout'>
{breakdown === null && (
<motion.span
key="no-answer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm"
>
<span>+{breakdown.basePoints}</span>
</motion.div>
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
key="base-points"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={14} />}
color="bg-amber-500"
delay={0}
/>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge
key="streak"
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={12} />}
color="bg-amber-500"
delay={0}
/>
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={14} />}
color="bg-blue-500"
delay={0}
/>
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge
key="comeback"
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={12} />}
color="bg-blue-500"
delay={0}
/>
)}
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={14} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge
key="first-correct"
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={12} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
);
@ -185,38 +212,144 @@ interface ScoreboardProps {
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 }) => {
const playersWithDisplayName = players.map(p => ({
...p,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
}));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1);
// Initialize players sorted by previousScore to start
const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
const playersWithMeta = players.map(p => ({
...p,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name,
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 (
<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">
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</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="space-y-2">
{sortedPlayers.map((player, index) => (
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
))}
</div>
<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">
<LayoutGroup>
<div className="space-y-3">
{animatedPlayers.map((player, index) => (
<PlayerRow
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 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 ? (
<button
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>
) : (
<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" />
<span className="text-base md:text-xl font-bold">Waiting for host...</span>
</div>

View file

@ -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);

View file

@ -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);

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<script src="https://cdn.tailwindcss.com"></script>
<script>

15
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^1.35.0",
"canvas-confetti": "^1.9.4",
"chroma-js": "^3.2.0",
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0",
@ -30,6 +31,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.17",
@ -1727,6 +1729,13 @@
"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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -2202,6 +2211,12 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",

View file

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

View file

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

View file

@ -10,9 +10,14 @@ const getGeminiClient = (apiKey?: string) => {
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 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 = {
type: Type.OBJECT,
properties: {
@ -173,6 +178,39 @@ const JSON_SCHEMA_FOR_OPENROUTER = {
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> {
const ai = getGeminiClient(options.apiKey);
@ -200,8 +238,10 @@ async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Qui
contents = prompt;
}
const model = options.geminiModel || DEFAULT_GEMINI_MODEL;
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
model,
contents,
config: {
responseMimeType: "application/json",
@ -294,6 +334,78 @@ async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise
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> => {
const provider = options.aiProvider || 'gemini';
@ -301,5 +413,9 @@ export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz>
return generateQuizWithOpenRouter(options);
}
if (provider === 'openai') {
return generateQuizWithOpenAI(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' },
];
export type AIProvider = 'gemini' | 'openrouter';
export type AIProvider = 'gemini' | 'openrouter' | 'openai';
export interface UserPreferences {
colorScheme: string;
aiProvider?: AIProvider;
geminiApiKey?: string;
geminiModel?: string;
openRouterApiKey?: string;
openRouterModel?: string;
openAIApiKey?: string;
openAIModel?: string;
}
export type GameRole = 'HOST' | 'CLIENT';
@ -134,7 +137,9 @@ export interface GenerateQuizOptions {
documents?: ProcessedDocument[];
aiProvider?: AIProvider;
apiKey?: string;
geminiModel?: string;
openRouterModel?: string;
openAIModel?: string;
}
export interface PointsBreakdown {