Add gemini key ability
This commit is contained in:
parent
9363f643f0
commit
73c7d3efed
11 changed files with 793 additions and 228 deletions
136
components/ApiKeyModal.tsx
Normal file
136
components/ApiKeyModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,31 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { LogIn, LogOut, User, Loader2 } from 'lucide-react';
|
||||
import { LogIn, LogOut, User, Loader2, Key, ChevronDown } from 'lucide-react';
|
||||
|
||||
export const AuthButton: React.FC = () => {
|
||||
interface AuthButtonProps {
|
||||
onAccountSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
export const AuthButton: React.FC<AuthButtonProps> = ({ onAccountSettingsClick }) => {
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -24,20 +46,44 @@ export const AuthButton: React.FC = () => {
|
|||
|
||||
if (auth.isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<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} />
|
||||
<span className="font-bold text-sm">
|
||||
{auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => auth.signoutRedirect()}
|
||||
className="p-2 bg-white/10 rounded-xl hover:bg-white/20 transition"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<ChevronDown size={16} className={`transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
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 { AuthButton } from './AuthButton';
|
||||
import { QuizLibrary } from './QuizLibrary';
|
||||
import { DefaultConfigModal } from './DefaultConfigModal';
|
||||
import { PreferencesModal } from './PreferencesModal';
|
||||
import { ApiKeyModal } from './ApiKeyModal';
|
||||
import { useQuizLibrary } from '../hooks/useQuizLibrary';
|
||||
import { useUserConfig } from '../hooks/useUserConfig';
|
||||
import { useUserPreferences } from '../hooks/useUserPreferences';
|
||||
import type { Quiz, GameConfig } from '../types';
|
||||
|
||||
type GenerateMode = 'topic' | 'document';
|
||||
|
|
@ -22,7 +25,7 @@ interface LandingProps {
|
|||
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
|
||||
const auth = useAuth();
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN');
|
||||
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
|
@ -35,12 +38,16 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
const [useOcr, setUseOcr] = useState(false);
|
||||
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
|
||||
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 hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
|
||||
const showOcrOption = hasImageFile || hasDocumentFile;
|
||||
|
||||
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
||||
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
|
||||
const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey);
|
||||
|
||||
const {
|
||||
quizzes,
|
||||
|
|
@ -104,7 +111,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
setUseOcr(false);
|
||||
};
|
||||
|
||||
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
||||
const canGenerateInput = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
||||
const canGenerate = canUseAI && canGenerateInput;
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -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="absolute top-4 right-4 flex items-center gap-2">
|
||||
{auth.isAuthenticated && (
|
||||
<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>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPreferencesOpen(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"
|
||||
>
|
||||
<Palette 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>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
||||
|
|
@ -184,209 +201,226 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
{mode === 'HOST' ? (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<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'
|
||||
}`}
|
||||
{!auth.isAuthenticated ? (
|
||||
<div className="text-center py-6">
|
||||
<Lock size={32} className="mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 font-bold mb-4">Sign in to host quizzes</p>
|
||||
<button
|
||||
onClick={() => auth.signinRedirect()}
|
||||
className="bg-theme-primary text-white py-3 px-6 rounded-2xl font-black hover:opacity-90 transition-all"
|
||||
>
|
||||
<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
|
||||
Sign In
|
||||
</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" />
|
||||
) : (
|
||||
) : (
|
||||
<>
|
||||
{canUseAI && (
|
||||
<>
|
||||
<BrainCircuit size={24} />
|
||||
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</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"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<BookOpen size={20} /> My Quizzes
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<BookOpen size={20} /> My Quizzes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -452,6 +486,26 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
130
components/PreferencesModal.tsx
Normal file
130
components/PreferencesModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue