kaboot/components/Landing.tsx
Joey Yakimowich-Payne 16007cc3aa
feat(landing): add document upload and quiz customization options
- Added collapsible advanced options section
- Implemented file upload with drag-and-drop support (PDF, TXT, MD, DOCX, Images)
- Added question count slider (5-30)
- Updated Generate button logic to support document-based generation
2026-01-13 20:44:11 -07:00

334 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, ChevronDown, ChevronUp, X, FileText, Settings, Image } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import type { Quiz } from '../types';
interface LandingProps {
onGenerate: (options: { topic?: string; questionCount?: number; file?: File }) => void;
onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz) => void;
onJoin: (pin: string, name: string) => void;
isLoading: boolean;
error: string | null;
}
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
const auth = useAuth();
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
const [topic, setTopic] = useState('');
const [pin, setPin] = useState('');
const [name, setName] = useState('');
const [libraryOpen, setLibraryOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [questionCount, setQuestionCount] = useState(10);
const [showAdvanced, setShowAdvanced] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const {
quizzes,
loading: libraryLoading,
loadingQuizId,
deletingQuizId,
error: libraryError,
fetchQuizzes,
loadQuiz,
deleteQuiz,
retry: retryLibrary
} = useQuizLibrary();
useEffect(() => {
if (libraryOpen && auth.isAuthenticated) {
fetchQuizzes();
}
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
const acceptedTypes = ['.pdf', '.txt', '.md', '.docx', '.jpg', '.jpeg', '.png', '.gif', '.webp'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (acceptedTypes.includes(fileExtension) || file.type.startsWith('image/')) {
setSelectedFile(file);
}
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const removeFile = () => {
setSelectedFile(null);
};
const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault();
if ((topic.trim() || selectedFile) && !isLoading) {
onGenerate({
topic: topic.trim(),
questionCount,
file: selectedFile || undefined
});
}
};
const handleJoinSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pin.trim() && name.trim()) onJoin(pin, name);
};
const handleLoadQuiz = async (id: string) => {
try {
const quiz = await loadQuiz(id);
setLibraryOpen(false);
onLoadQuiz(quiz);
} 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 min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4">
<AuthButton />
</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-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
>
<div className="flex justify-center mb-6">
<div className="bg-theme-primary p-4 rounded-3xl rotate-3 shadow-lg">
<BrainCircuit size={48} className="text-white" />
</div>
</div>
<h1 className="text-5xl font-black mb-2 text-theme-primary tracking-tight">Kaboot</h1>
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
<div className="flex bg-gray-100 p-2 rounded-2xl 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-6">
<form onSubmit={handleHostSubmit} className="space-y-4">
<input
type="text"
placeholder="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}
/>
<div className="border-2 border-gray-100 rounded-2xl overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full p-3 bg-gray-50 flex items-center justify-center gap-2 text-gray-500 font-bold hover:bg-gray-100 transition-colors"
>
<Settings size={18} />
Advanced Options
{showAdvanced ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
<AnimatePresence>
{showAdvanced && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="p-4 space-y-4 bg-white border-t-2 border-gray-100">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm font-bold text-gray-600">
<span>Question Count</span>
<span className="bg-theme-primary text-white 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 className="flex justify-between text-xs text-gray-400 font-medium">
<span>5</span>
<span>30</span>
</div>
</div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById('file-upload')?.click()}
className={`border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-all ${
isDragging
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.md,.docx,.jpg,.jpeg,.png,.gif,.webp"
/>
{selectedFile ? (
<div className="flex items-center justify-between bg-gray-100 p-2 rounded-lg">
<div className="flex items-center gap-2 overflow-hidden text-left">
{selectedFile.type.startsWith('image/') ? (
<Image size={20} className="text-theme-primary flex-shrink-0" />
) : (
<FileText size={20} className="text-theme-primary flex-shrink-0" />
)}
<div className="flex flex-col items-start min-w-0">
<span className="text-sm font-bold text-gray-700 truncate max-w-[150px]">{selectedFile.name}</span>
<span className="text-xs text-gray-500">{(selectedFile.size / 1024).toFixed(1)} KB</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFile();
}}
className="p-1 hover:bg-gray-200 rounded-full text-gray-500 hover:text-red-500 transition-colors"
>
<X size={16} />
</button>
</div>
) : (
<div className="space-y-1">
<Upload className={`mx-auto mb-2 ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={24} />
<p className="text-sm font-bold text-gray-600">
{isDragging ? 'Drop file here' : 'Upload Document'}
</p>
<p className="text-xs text-gray-400">PDF, TXT, DOCX, Images</p>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
type="submit"
disabled={isLoading || (!topic.trim() && !selectedFile)}
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} /> {selectedFile && !topic.trim() ? '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
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>
)}
</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"
/>
<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() || !name.trim()}
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"
>
<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}
/>
</div>
);
};