658 lines
29 KiB
TypeScript
658 lines
29 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
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';
|
|
|
|
import type { AIProvider } from '../types';
|
|
|
|
interface LandingProps {
|
|
onGenerate: (options: {
|
|
topic?: string;
|
|
questionCount?: number;
|
|
files?: File[];
|
|
useOcr?: boolean;
|
|
aiProvider?: AIProvider;
|
|
apiKey?: string;
|
|
geminiModel?: string;
|
|
openRouterModel?: string;
|
|
openAIModel?: string;
|
|
}) => void;
|
|
onCreateManual: () => void;
|
|
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
|
onJoin: (pin: string, name: string) => void;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
initialPin?: string | null;
|
|
}
|
|
|
|
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error, initialPin }) => {
|
|
const auth = useAuth();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const getModeFromUrl = (): 'HOST' | 'JOIN' => {
|
|
if (initialPin) return 'JOIN';
|
|
const modeParam = searchParams.get('mode');
|
|
return modeParam === 'host' ? 'HOST' : 'JOIN';
|
|
};
|
|
|
|
const [mode, setModeState] = useState<'HOST' | 'JOIN'>(getModeFromUrl);
|
|
|
|
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
|
setModeState(newMode);
|
|
const newParams = new URLSearchParams();
|
|
const modalParam = searchParams.get('modal');
|
|
if (modalParam) {
|
|
newParams.set('modal', modalParam);
|
|
}
|
|
if (newMode === 'HOST') {
|
|
newParams.set('mode', 'host');
|
|
}
|
|
setSearchParams(newParams, { replace: true });
|
|
};
|
|
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
|
const [topic, setTopic] = useState('');
|
|
const [pin, setPin] = useState(initialPin || '');
|
|
const [name, setName] = useState('');
|
|
|
|
const modalParam = searchParams.get('modal');
|
|
const libraryOpen = modalParam === 'library';
|
|
const preferencesOpen = modalParam === 'preferences';
|
|
const defaultConfigOpen = modalParam === 'settings';
|
|
const accountSettingsOpen = modalParam === 'account';
|
|
|
|
const buildCleanParams = (updates: Record<string, string | null>) => {
|
|
const newParams = new URLSearchParams();
|
|
const modeParam = searchParams.get('mode');
|
|
if (modeParam) newParams.set('mode', modeParam);
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (value !== null) {
|
|
newParams.set(key, value);
|
|
}
|
|
}
|
|
return newParams;
|
|
};
|
|
|
|
const setLibraryOpen = (open: boolean) => {
|
|
setSearchParams(buildCleanParams({ modal: open ? 'library' : null }));
|
|
};
|
|
|
|
const setPreferencesOpen = (open: boolean) => {
|
|
setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null }));
|
|
};
|
|
|
|
const setDefaultConfigOpen = (open: boolean) => {
|
|
setSearchParams(buildCleanParams({ modal: open ? 'settings' : null }));
|
|
};
|
|
|
|
const setAccountSettingsOpen = (open: boolean) => {
|
|
setSearchParams(buildCleanParams({ modal: open ? 'account' : null }));
|
|
};
|
|
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
const [questionCount, setQuestionCount] = useState(10);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [useOcr, setUseOcr] = useState(false);
|
|
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
|
|
const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null);
|
|
const [checkingPin, setCheckingPin] = 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 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 {
|
|
quizzes,
|
|
loading: libraryLoading,
|
|
loadingQuizId,
|
|
deletingQuizId,
|
|
error: libraryError,
|
|
fetchQuizzes,
|
|
loadQuiz,
|
|
deleteQuiz,
|
|
retry: retryLibrary
|
|
} = useQuizLibrary();
|
|
|
|
useEffect(() => {
|
|
if (libraryOpen && auth.isAuthenticated) {
|
|
fetchQuizzes();
|
|
}
|
|
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
|
|
|
useEffect(() => {
|
|
if (defaultConfigOpen) {
|
|
setEditingDefaultConfig(defaultConfig);
|
|
} else {
|
|
setEditingDefaultConfig(null);
|
|
}
|
|
}, [defaultConfigOpen, defaultConfig]);
|
|
|
|
useEffect(() => {
|
|
const checkGamePin = async () => {
|
|
if (pin.trim().length === 6) {
|
|
setCheckingPin(true);
|
|
try {
|
|
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
|
const response = await fetch(`${backendUrl}/api/games/${pin.trim()}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setGameInfo({ randomNamesEnabled: data.randomNamesEnabled, quizTitle: data.quizTitle });
|
|
} else {
|
|
setGameInfo(null);
|
|
}
|
|
} catch {
|
|
setGameInfo(null);
|
|
}
|
|
setCheckingPin(false);
|
|
} else {
|
|
setGameInfo(null);
|
|
}
|
|
};
|
|
checkGamePin();
|
|
}, [pin]);
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const newFiles = Array.from(e.target.files);
|
|
setSelectedFiles(prev => [...prev, ...newFiles]);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const acceptedTypes = ['.pdf', '.txt', '.md', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods', '.rtf', '.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
const newFiles = Array.from(e.dataTransfer.files).filter((file: File) => {
|
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
return acceptedTypes.includes(fileExtension) || file.type.startsWith('image/');
|
|
});
|
|
if (newFiles.length > 0) {
|
|
setSelectedFiles(prev => [...prev, ...newFiles]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
|
if (selectedFiles.length <= 1) {
|
|
setUseOcr(false);
|
|
}
|
|
};
|
|
|
|
const clearAllFiles = () => {
|
|
setSelectedFiles([]);
|
|
setUseOcr(false);
|
|
};
|
|
|
|
const canGenerateInput = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
|
const canGenerate = canUseAI && canGenerateInput;
|
|
|
|
const handleHostSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (canGenerate && !isLoading) {
|
|
const aiProvider = preferences.aiProvider || 'gemini';
|
|
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,
|
|
questionCount,
|
|
files: generateMode === 'document' ? selectedFiles : undefined,
|
|
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,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleJoinSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (pin.trim() && (gameInfo?.randomNamesEnabled || name.trim())) {
|
|
onJoin(pin, name.trim() || 'Player');
|
|
}
|
|
};
|
|
|
|
const handleLoadQuiz = async (id: string) => {
|
|
try {
|
|
const quiz = await loadQuiz(id);
|
|
setLibraryOpen(false);
|
|
onLoadQuiz(quiz, id);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes('redirecting')) {
|
|
return;
|
|
}
|
|
console.error('Failed to load quiz:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-screen p-4 text-center relative overflow-hidden">
|
|
<div className="absolute top-4 right-4 flex items-center gap-2">
|
|
{auth.isAuthenticated && (
|
|
<>
|
|
<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 onAccountSettingsClick={() => setAccountSettingsOpen(true)} />
|
|
</div>
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
|
transition={{ type: "spring", bounce: 0.5 }}
|
|
className="bg-white text-gray-900 p-6 md:p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
|
>
|
|
<div className="flex justify-center mb-4 md:mb-6">
|
|
<div className="bg-theme-primary p-3 md:p-4 rounded-2xl md:rounded-3xl rotate-3 shadow-lg">
|
|
<BrainCircuit size={36} className="text-white md:w-12 md:h-12" />
|
|
</div>
|
|
</div>
|
|
<h1 className="text-4xl md:text-5xl font-black mb-1 md:mb-2 text-theme-primary tracking-tight">Kaboot</h1>
|
|
<p className="text-gray-500 font-bold mb-4 md:mb-6">The AI Quiz Party</p>
|
|
|
|
<div className="flex bg-gray-100 p-2 rounded-2xl mb-6 md:mb-8">
|
|
<button
|
|
onClick={() => setMode('HOST')}
|
|
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-theme-primary scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
Host
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('JOIN')}
|
|
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'JOIN' ? 'bg-white shadow-md text-theme-primary scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
Join
|
|
</button>
|
|
</div>
|
|
|
|
{mode === 'HOST' ? (
|
|
<div className="space-y-4">
|
|
{!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"
|
|
>
|
|
Sign In
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{canUseAI && (
|
|
<>
|
|
<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="50"
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
{!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"
|
|
>
|
|
<PenTool size={20} /> Create Manually
|
|
</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>
|
|
) : (
|
|
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Game PIN"
|
|
value={pin}
|
|
onChange={(e) => setPin(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 text-center"
|
|
/>
|
|
{gameInfo?.randomNamesEnabled ? (
|
|
<div className="p-4 bg-theme-primary/10 rounded-2xl border-2 border-theme-primary/20">
|
|
<p className="text-theme-primary font-bold text-center">
|
|
You'll get a random name!
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
placeholder="Nickname"
|
|
value={name}
|
|
onChange={(e) => setName(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 text-center"
|
|
/>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={!pin.trim() || (!gameInfo?.randomNamesEnabled && !name.trim()) || checkingPin}
|
|
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"
|
|
>
|
|
{checkingPin ? <Loader2 className="animate-spin" /> : <Play fill="currentColor" />} Join Game
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{error && (
|
|
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="overflow-hidden mt-4">
|
|
<p className="text-red-500 font-bold bg-red-50 p-3 rounded-xl border-2 border-red-100">{error}</p>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
|
|
<QuizLibrary
|
|
isOpen={libraryOpen}
|
|
onClose={() => setLibraryOpen(false)}
|
|
quizzes={quizzes}
|
|
loading={libraryLoading}
|
|
loadingQuizId={loadingQuizId}
|
|
deletingQuizId={deletingQuizId}
|
|
error={libraryError}
|
|
onLoadQuiz={handleLoadQuiz}
|
|
onDeleteQuiz={deleteQuiz}
|
|
onRetry={retryLibrary}
|
|
/>
|
|
|
|
<DefaultConfigModal
|
|
isOpen={defaultConfigOpen}
|
|
onClose={() => {
|
|
setDefaultConfigOpen(false);
|
|
setEditingDefaultConfig(null);
|
|
}}
|
|
config={editingDefaultConfig || defaultConfig}
|
|
onChange={setEditingDefaultConfig}
|
|
onSave={async () => {
|
|
if (editingDefaultConfig) {
|
|
await saveDefaultConfig(editingDefaultConfig);
|
|
setDefaultConfigOpen(false);
|
|
setEditingDefaultConfig(null);
|
|
}
|
|
}}
|
|
saving={savingConfig}
|
|
/>
|
|
|
|
<PreferencesModal
|
|
isOpen={preferencesOpen}
|
|
onClose={() => setPreferencesOpen(false)}
|
|
preferences={preferences}
|
|
onSave={savePreferences}
|
|
onPreview={applyColorScheme}
|
|
saving={savingPrefs}
|
|
/>
|
|
|
|
<ApiKeyModal
|
|
isOpen={accountSettingsOpen}
|
|
onClose={() => setAccountSettingsOpen(false)}
|
|
preferences={preferences}
|
|
onSave={async (prefs) => {
|
|
await savePreferences({ ...preferences, ...prefs });
|
|
}}
|
|
saving={savingPrefs}
|
|
hasAIAccess={hasAIAccess}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|