Add gemini key ability

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 21:04:58 -07:00
commit 73c7d3efed
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
11 changed files with 793 additions and 228 deletions

View file

@ -36,6 +36,13 @@ entries:
attrs: attrs:
name: kaboot-users name: kaboot-users
- id: kaboot-ai-access-group
model: authentik_core.group
identifiers:
name: kaboot-ai-access
attrs:
name: kaboot-ai-access
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# OAUTH2/OIDC PROVIDER # OAUTH2/OIDC PROVIDER
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -234,8 +241,7 @@ entries:
is_active: true is_active: true
groups: groups:
- !KeyOf kaboot-users-group - !KeyOf kaboot-users-group
# Note: Password must be set manually via UI or API after blueprint import - !KeyOf kaboot-ai-access-group
# Run: docker compose exec authentik-server ak setpassword kaboottest
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# SERVICE ACCOUNT (for API/automated testing) # SERVICE ACCOUNT (for API/automated testing)
@ -253,5 +259,4 @@ entries:
is_active: true is_active: true
groups: groups:
- !KeyOf kaboot-users-group - !KeyOf kaboot-users-group
# Note: App password must be created via UI or API after blueprint import - !KeyOf kaboot-ai-access-group
# See docs/AUTHENTIK_SETUP.md for instructions

136
components/ApiKeyModal.tsx Normal file
View file

@ -0,0 +1,136 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface ApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: string | undefined;
onSave: (key: string | undefined) => Promise<void>;
saving: boolean;
hasAIAccess: boolean;
}
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
isOpen,
onClose,
apiKey,
onSave,
saving,
hasAIAccess,
}) => {
useBodyScrollLock(isOpen);
const [localApiKey, setLocalApiKey] = useState(apiKey || '');
const [showApiKey, setShowApiKey] = useState(false);
useEffect(() => {
if (isOpen) {
setLocalApiKey(apiKey || '');
}
}, [isOpen, apiKey]);
if (!isOpen) return null;
const handleSave = async () => {
await onSave(localApiKey || undefined);
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<motion.div
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"
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="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Key size={24} />
</div>
<div>
<h2 className="text-xl font-black">Account Settings</h2>
<p className="text-sm opacity-80">Manage your API access</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>
</div>
<div className="p-6 bg-gray-50">
{hasAIAccess ? (
<div>
<h3 className="font-bold text-gray-800 mb-3 flex items-center gap-2">
<Key size={18} />
Custom Gemini API Key
</h3>
<p className="text-sm text-gray-500 mb-3">
Use your own API key for quiz generation. Leave empty to use the system key.
</p>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={localApiKey}
onChange={(e) => setLocalApiKey(e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</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 className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
>
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>
)}
</div>
</motion.div>
</motion.div>
);
};

View file

@ -1,9 +1,31 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useAuth } from 'react-oidc-context'; import { useAuth } from 'react-oidc-context';
import { LogIn, LogOut, User, Loader2 } from 'lucide-react'; import { LogIn, LogOut, User, Loader2, Key, ChevronDown } from 'lucide-react';
export const AuthButton: React.FC = () => { interface AuthButtonProps {
onAccountSettingsClick?: () => void;
}
export const AuthButton: React.FC<AuthButtonProps> = ({ onAccountSettingsClick }) => {
const auth = useAuth(); const auth = useAuth();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
if (auth.isLoading) { if (auth.isLoading) {
return ( return (
@ -24,20 +46,44 @@ export const AuthButton: React.FC = () => {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
return ( return (
<div className="flex items-center gap-3"> <div className="relative" ref={dropdownRef}>
<div className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl"> <button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl hover:bg-white/20 transition cursor-pointer"
>
<User size={18} /> <User size={18} />
<span className="font-bold text-sm"> <span className="font-bold text-sm">
{auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'} {auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'}
</span> </span>
</div> <ChevronDown size={16} className={`transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
<button
onClick={() => auth.signoutRedirect()}
className="p-2 bg-white/10 rounded-xl hover:bg-white/20 transition"
title="Sign out"
>
<LogOut size={20} />
</button> </button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg py-2 z-50 border border-gray-100">
{onAccountSettingsClick && (
<button
onClick={() => {
setDropdownOpen(false);
onAccountSettingsClick();
}}
className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition"
>
<Key size={18} />
Account Settings
</button>
)}
<button
onClick={() => {
setDropdownOpen(false);
auth.signoutRedirect();
}}
className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition"
>
<LogOut size={18} />
Sign Out
</button>
</div>
)}
</div> </div>
); );
} }

View file

@ -1,12 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react'; import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings, Palette, Lock } from 'lucide-react';
import { useAuth } from 'react-oidc-context'; import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton'; import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary'; import { QuizLibrary } from './QuizLibrary';
import { DefaultConfigModal } from './DefaultConfigModal'; import { DefaultConfigModal } from './DefaultConfigModal';
import { PreferencesModal } from './PreferencesModal';
import { ApiKeyModal } from './ApiKeyModal';
import { useQuizLibrary } from '../hooks/useQuizLibrary'; import { useQuizLibrary } from '../hooks/useQuizLibrary';
import { useUserConfig } from '../hooks/useUserConfig'; import { useUserConfig } from '../hooks/useUserConfig';
import { useUserPreferences } from '../hooks/useUserPreferences';
import type { Quiz, GameConfig } from '../types'; import type { Quiz, GameConfig } from '../types';
type GenerateMode = 'topic' | 'document'; type GenerateMode = 'topic' | 'document';
@ -22,7 +25,7 @@ interface LandingProps {
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
const auth = useAuth(); const auth = useAuth();
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN');
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic'); const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
const [topic, setTopic] = useState(''); const [topic, setTopic] = useState('');
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
@ -35,12 +38,16 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const [useOcr, setUseOcr] = useState(false); const [useOcr, setUseOcr] = useState(false);
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null); const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/')); const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/'));
const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type)); const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
const showOcrOption = hasImageFile || hasDocumentFile; const showOcrOption = hasImageFile || hasDocumentFile;
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey);
const { const {
quizzes, quizzes,
@ -104,7 +111,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
setUseOcr(false); setUseOcr(false);
}; };
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0; const canGenerateInput = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
const canGenerate = canUseAI && canGenerateInput;
const handleHostSubmit = (e: React.FormEvent) => { const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -140,18 +148,27 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative"> <div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4 flex items-center gap-2"> <div className="absolute top-4 right-4 flex items-center gap-2">
{auth.isAuthenticated && ( {auth.isAuthenticated && (
<button <>
onClick={() => { <button
setEditingDefaultConfig(defaultConfig); onClick={() => setPreferencesOpen(true)}
setDefaultConfigOpen(true); className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
}} title="Preferences"
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary" >
title="Default Game Settings" <Palette size={20} />
> </button>
<Settings size={20} /> <button
</button> onClick={() => {
setEditingDefaultConfig(defaultConfig);
setDefaultConfigOpen(true);
}}
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
title="Default Game Settings"
>
<Settings size={20} />
</button>
</>
)} )}
<AuthButton /> <AuthButton onAccountSettingsClick={() => setAccountSettingsOpen(true)} />
</div> </div>
<motion.div <motion.div
initial={{ scale: 0.8, opacity: 0, rotate: -2 }} initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
@ -184,209 +201,226 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
{mode === 'HOST' ? ( {mode === 'HOST' ? (
<div className="space-y-4"> <div className="space-y-4">
<form onSubmit={handleHostSubmit} className="space-y-4"> {!auth.isAuthenticated ? (
<div className="flex bg-gray-100 p-1.5 rounded-xl mb-2"> <div className="text-center py-6">
<button <Lock size={32} className="mx-auto mb-3 text-gray-400" />
type="button" <p className="text-gray-500 font-bold mb-4">Sign in to host quizzes</p>
onClick={() => setGenerateMode('topic')} <button
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${ onClick={() => auth.signinRedirect()}
generateMode === 'topic' className="bg-theme-primary text-white py-3 px-6 rounded-2xl font-black hover:opacity-90 transition-all"
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
> >
<Sparkles size={16} /> Topic Sign In
</button>
<button
type="button"
onClick={() => setGenerateMode('document')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
generateMode === 'document'
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<FileText size={16} /> Document
</button> </button>
</div> </div>
) : (
<AnimatePresence mode="wait"> <>
{generateMode === 'topic' ? ( {canUseAI && (
<motion.div
key="topic"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
<input
type="text"
placeholder="Enter a topic (e.g. 'Space')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
disabled={isLoading}
/>
</motion.div>
) : (
<motion.div
key="document"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="space-y-3"
>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById('file-upload')?.click()}
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
isDragging
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
: selectedFiles.length > 0
? 'border-theme-primary/50 bg-theme-primary/5'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
>
<input
id="file-upload"
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
/>
<div className="space-y-2">
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
<p className="text-sm font-bold text-gray-600">
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
</p>
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
</div>
</div>
{selectedFiles.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearAllFiles();
}}
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
>
Clear all
</button>
</div>
<div className="max-h-32 overflow-y-auto space-y-1.5">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
<div className="flex items-center gap-2 overflow-hidden text-left">
<div className="p-1.5 bg-theme-primary/10 rounded">
{file.type.startsWith('image/') ? (
<Image size={14} className="text-theme-primary" />
) : (
<FileText size={14} className="text-theme-primary" />
)}
</div>
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
</div>
)}
{showOcrOption && (
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
<div className="relative">
<input
type="checkbox"
checked={useOcr}
onChange={(e) => setUseOcr(e.target.checked)}
className="sr-only peer"
/>
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
</div>
<div className="flex items-center gap-2">
<ScanText size={18} className="text-gray-500" />
<span className="text-sm font-bold text-gray-600">
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
</span>
</div>
</label>
)}
</motion.div>
)}
</AnimatePresence>
<div className="space-y-2 pt-2">
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
<span>Questions</span>
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
</div>
<input
type="range"
min="5"
max="30"
value={questionCount}
onChange={(e) => setQuestionCount(Number(e.target.value))}
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
<button
type="submit"
disabled={isLoading || !canGenerate}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<> <>
<BrainCircuit size={24} /> <form onSubmit={handleHostSubmit} className="space-y-4">
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'} <div className="flex bg-gray-100 p-1.5 rounded-xl mb-2">
<button
type="button"
onClick={() => setGenerateMode('topic')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
generateMode === 'topic'
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<Sparkles size={16} /> Topic
</button>
<button
type="button"
onClick={() => setGenerateMode('document')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
generateMode === 'document'
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<FileText size={16} /> Document
</button>
</div>
<AnimatePresence mode="wait">
{generateMode === 'topic' ? (
<motion.div
key="topic"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
<input
type="text"
placeholder="Enter a topic (e.g. 'Space')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
disabled={isLoading}
/>
</motion.div>
) : (
<motion.div
key="document"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="space-y-3"
>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById('file-upload')?.click()}
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
isDragging
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
: selectedFiles.length > 0
? 'border-theme-primary/50 bg-theme-primary/5'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
>
<input
id="file-upload"
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
/>
<div className="space-y-2">
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
<p className="text-sm font-bold text-gray-600">
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
</p>
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
</div>
</div>
{selectedFiles.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearAllFiles();
}}
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
>
Clear all
</button>
</div>
<div className="max-h-32 overflow-y-auto space-y-1.5">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
<div className="flex items-center gap-2 overflow-hidden text-left">
<div className="p-1.5 bg-theme-primary/10 rounded">
{file.type.startsWith('image/') ? (
<Image size={14} className="text-theme-primary" />
) : (
<FileText size={14} className="text-theme-primary" />
)}
</div>
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
</div>
)}
{showOcrOption && (
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
<div className="relative">
<input
type="checkbox"
checked={useOcr}
onChange={(e) => setUseOcr(e.target.checked)}
className="sr-only peer"
/>
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
</div>
<div className="flex items-center gap-2">
<ScanText size={18} className="text-gray-500" />
<span className="text-sm font-bold text-gray-600">
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
</span>
</div>
</label>
)}
</motion.div>
)}
</AnimatePresence>
<div className="space-y-2 pt-2">
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
<span>Questions</span>
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
</div>
<input
type="range"
min="5"
max="30"
value={questionCount}
onChange={(e) => setQuestionCount(Number(e.target.value))}
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
<button
type="submit"
disabled={isLoading || !canGenerate}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<>
<BrainCircuit size={24} />
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
</>
)}
</button>
</form>
<div className="relative flex py-2 items-center opacity-50">
<div className="flex-grow border-t-2 border-gray-300"></div>
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
<div className="flex-grow border-t-2 border-gray-300"></div>
</div>
</> </>
)} )}
</button>
</form>
<div className="relative flex py-2 items-center opacity-50">
<div className="flex-grow border-t-2 border-gray-300"></div>
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
<div className="flex-grow border-t-2 border-gray-300"></div>
</div>
<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"
> >
<PenTool size={20} /> Create Manually <PenTool size={20} /> Create Manually
</button> </button>
{auth.isAuthenticated && ( <button
<button onClick={() => setLibraryOpen(true)}
onClick={() => setLibraryOpen(true)} className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2" >
> <BookOpen size={20} /> My Quizzes
<BookOpen size={20} /> My Quizzes </button>
</button> </>
)} )}
</div> </div>
) : ( ) : (
@ -452,6 +486,26 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}} }}
saving={savingConfig} saving={savingConfig}
/> />
<PreferencesModal
isOpen={preferencesOpen}
onClose={() => setPreferencesOpen(false)}
preferences={preferences}
onSave={savePreferences}
onPreview={applyColorScheme}
saving={savingPrefs}
/>
<ApiKeyModal
isOpen={accountSettingsOpen}
onClose={() => setAccountSettingsOpen(false)}
apiKey={preferences.geminiApiKey}
onSave={async (key) => {
await savePreferences({ ...preferences, geminiApiKey: key });
}}
saving={savingPrefs}
hasAIAccess={hasAIAccess}
/>
</div> </div>
); );
}; };

View file

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { X, Palette, Loader2, Check } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { UserPreferences } from '../types';
import { COLOR_SCHEMES } from '../types';
interface PreferencesModalProps {
isOpen: boolean;
onClose: () => void;
preferences: UserPreferences;
onSave: (prefs: UserPreferences) => Promise<void>;
onPreview: (schemeId: string) => void;
saving: boolean;
}
export const PreferencesModal: React.FC<PreferencesModalProps> = ({
isOpen,
onClose,
preferences,
onSave,
onPreview,
saving,
}) => {
useBodyScrollLock(isOpen);
const [localPrefs, setLocalPrefs] = useState<UserPreferences>(preferences);
if (!isOpen) return null;
const handleColorSelect = (schemeId: string) => {
setLocalPrefs(prev => ({ ...prev, colorScheme: schemeId }));
onPreview(schemeId);
};
const handleSave = async () => {
await onSave(localPrefs);
onClose();
};
const handleClose = () => {
onPreview(preferences.colorScheme);
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={handleClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Palette size={24} />
</div>
<div>
<h2 className="text-xl font-black">Color Scheme</h2>
<p className="text-sm opacity-80">Customize your theme</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1 bg-gray-50">
<div className="grid grid-cols-4 gap-3">
{COLOR_SCHEMES.map((scheme) => (
<button
key={scheme.id}
onClick={() => handleColorSelect(scheme.id)}
className={`relative aspect-square rounded-xl transition-all ${
localPrefs.colorScheme === scheme.id
? 'ring-2 ring-offset-2 ring-gray-800 scale-105'
: 'hover:scale-105'
}`}
style={{ background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.primaryDarker} 100%)` }}
title={scheme.name}
>
{localPrefs.colorScheme === scheme.id && (
<div className="absolute inset-0 flex items-center justify-center">
<Check size={24} className="text-white drop-shadow-lg" />
</div>
)}
</button>
))}
</div>
<p className="text-sm text-gray-500 mt-3 text-center">
{COLOR_SCHEMES.find(s => s.id === localPrefs.colorScheme)?.name || 'Select a color'}
</p>
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={handleClose}
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 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

@ -0,0 +1,94 @@
import { useState, useCallback, useEffect } from 'react';
import toast from 'react-hot-toast';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { UserPreferences } from '../types';
import { COLOR_SCHEMES } from '../types';
const DEFAULT_PREFERENCES: UserPreferences = {
colorScheme: 'blue',
};
export const applyColorScheme = (schemeId: string) => {
const scheme = COLOR_SCHEMES.find(s => s.id === schemeId) || COLOR_SCHEMES[0];
document.documentElement.style.setProperty('--theme-primary', scheme.primary);
document.documentElement.style.setProperty('--theme-primary-dark', scheme.primaryDark);
document.documentElement.style.setProperty('--theme-primary-darker', scheme.primaryDarker);
};
interface UseUserPreferencesReturn {
preferences: UserPreferences;
hasAIAccess: boolean;
loading: boolean;
saving: boolean;
fetchPreferences: () => Promise<void>;
savePreferences: (prefs: UserPreferences) => Promise<void>;
applyColorScheme: (schemeId: string) => void;
}
export const useUserPreferences = (): UseUserPreferencesReturn => {
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [hasAIAccess, setHasAIAccess] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const fetchPreferences = useCallback(async () => {
if (!isAuthenticated) return;
setLoading(true);
try {
const response = await authFetch('/api/users/me/preferences');
if (response.ok) {
const data = await response.json();
const prefs: UserPreferences = {
colorScheme: data.colorScheme || 'blue',
geminiApiKey: data.geminiApiKey || undefined,
};
setPreferences(prefs);
setHasAIAccess(data.hasAIAccess || false);
applyColorScheme(prefs.colorScheme);
}
} catch {
} finally {
setLoading(false);
}
}, [authFetch, isAuthenticated]);
const savePreferences = useCallback(async (prefs: UserPreferences) => {
setSaving(true);
try {
const response = await authFetch('/api/users/me/preferences', {
method: 'PUT',
body: JSON.stringify(prefs),
});
if (!response.ok) {
throw new Error('Failed to save preferences');
}
setPreferences(prefs);
applyColorScheme(prefs.colorScheme);
toast.success('Preferences saved!');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
toast.error(message);
throw err;
} finally {
setSaving(false);
}
}, [authFetch]);
useEffect(() => {
fetchPreferences();
}, [fetchPreferences]);
return {
preferences,
hasAIAccess,
loading,
saving,
fetchPreferences,
savePreferences,
applyColorScheme,
};
};

View file

@ -60,6 +60,18 @@ const runMigrations = () => {
db.exec("ALTER TABLE game_sessions ADD COLUMN first_correct_player_id TEXT"); db.exec("ALTER TABLE game_sessions ADD COLUMN first_correct_player_id TEXT");
console.log("Migration: Added first_correct_player_id to game_sessions"); console.log("Migration: Added first_correct_player_id to game_sessions");
} }
const userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
if (!hasColorScheme) {
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
console.log("Migration: Added color_scheme to users");
}
const hasGeminiKey = userTableInfo2.some(col => col.name === "gemini_api_key");
if (!hasGeminiKey) {
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
console.log("Migration: Added gemini_api_key to users");
}
}; };
runMigrations(); runMigrations();

View file

@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME, last_login DATETIME,
default_game_config TEXT default_game_config TEXT,
color_scheme TEXT DEFAULT 'blue',
gemini_api_key TEXT
); );
CREATE TABLE IF NOT EXISTS quizzes ( CREATE TABLE IF NOT EXISTS quizzes (

View file

@ -34,6 +34,7 @@ export interface AuthenticatedUser {
preferred_username: string; preferred_username: string;
email?: string; email?: string;
name?: string; name?: string;
groups?: string[];
} }
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
@ -74,6 +75,7 @@ export function requireAuth(
preferred_username: payload.preferred_username || payload.sub!, preferred_username: payload.preferred_username || payload.sub!,
email: payload.email, email: payload.email,
name: payload.name, name: payload.name,
groups: payload.groups || [],
}; };
next(); next();

View file

@ -8,11 +8,16 @@ router.use(requireAuth);
router.get('/me', (req: AuthenticatedRequest, res: Response) => { router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(` const user = db.prepare(`
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
created_at as createdAt, last_login as lastLogin
FROM users FROM users
WHERE id = ? WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined; `).get(req.user!.sub) as Record<string, unknown> | undefined;
const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access');
if (!user) { if (!user) {
res.json({ res.json({
id: req.user!.sub, id: req.user!.sub,
@ -20,6 +25,9 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
email: req.user!.email, email: req.user!.email,
displayName: req.user!.name, displayName: req.user!.name,
defaultGameConfig: null, defaultGameConfig: null,
colorScheme: 'blue',
geminiApiKey: null,
hasAIAccess,
createdAt: null, createdAt: null,
lastLogin: null, lastLogin: null,
isNew: true, isNew: true,
@ -36,7 +44,12 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
} }
} }
res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false }); res.json({
...user,
defaultGameConfig: parsedConfig,
hasAIAccess,
isNew: false
});
}); });
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => { router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
@ -64,4 +77,47 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
res.json({ success: true }); res.json({ success: true });
}); });
router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
FROM users
WHERE id = ?
`).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access');
res.json({
colorScheme: user?.colorScheme || 'blue',
geminiApiKey: user?.geminiApiKey || null,
hasAIAccess,
});
});
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const { colorScheme, geminiApiKey } = req.body;
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
color_scheme = ?,
gemini_api_key = ?,
last_login = CURRENT_TIMESTAMP
`);
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null,
colorScheme || 'blue',
geminiApiKey || null,
colorScheme || 'blue',
geminiApiKey || null
);
res.json({ success: true });
});
export default router; export default router;

View file

@ -13,6 +13,34 @@ export type GameState =
| 'WAITING_TO_REJOIN' | 'WAITING_TO_REJOIN'
| 'HOST_RECONNECTED'; | 'HOST_RECONNECTED';
export interface ColorScheme {
id: string;
name: string;
primary: string;
primaryDark: string;
primaryDarker: string;
}
export const COLOR_SCHEMES: ColorScheme[] = [
{ id: 'blue', name: 'Ocean Blue', primary: '#2563eb', primaryDark: '#1e40af', primaryDarker: '#1e3a5f' },
{ id: 'purple', name: 'Royal Purple', primary: '#7c3aed', primaryDark: '#5b21b6', primaryDarker: '#3b1a5f' },
{ id: 'pink', name: 'Hot Pink', primary: '#db2777', primaryDark: '#9d174d', primaryDarker: '#5f1a3b' },
{ id: 'red', name: 'Ruby Red', primary: '#dc2626', primaryDark: '#991b1b', primaryDarker: '#5f1a1a' },
{ id: 'orange', name: 'Sunset Orange', primary: '#ea580c', primaryDark: '#c2410c', primaryDarker: '#5f2a1a' },
{ id: 'yellow', name: 'Golden Yellow', primary: '#ca8a04', primaryDark: '#a16207', primaryDarker: '#5f4a1a' },
{ id: 'green', name: 'Emerald Green', primary: '#16a34a', primaryDark: '#15803d', primaryDarker: '#1a5f3b' },
{ id: 'teal', name: 'Teal', primary: '#0d9488', primaryDark: '#0f766e', primaryDarker: '#1a5f5a' },
{ id: 'cyan', name: 'Cyan', primary: '#0891b2', primaryDark: '#0e7490', primaryDarker: '#1a4a5f' },
{ id: 'indigo', name: 'Indigo', primary: '#4f46e5', primaryDark: '#4338ca', primaryDarker: '#2a2a5f' },
{ id: 'slate', name: 'Slate', primary: '#475569', primaryDark: '#334155', primaryDarker: '#1e293b' },
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
];
export interface UserPreferences {
colorScheme: string;
geminiApiKey?: string;
}
export type GameRole = 'HOST' | 'CLIENT'; export type GameRole = 'HOST' | 'CLIENT';
export interface AnswerOption { export interface AnswerOption {