Add document AI gen support
This commit is contained in:
parent
16007cc3aa
commit
028bab23fd
11 changed files with 1270 additions and 170 deletions
|
|
@ -1,14 +1,16 @@
|
|||
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 { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } 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';
|
||||
|
||||
type GenerateMode = 'topic' | 'document';
|
||||
|
||||
interface LandingProps {
|
||||
onGenerate: (options: { topic?: string; questionCount?: number; file?: File }) => void;
|
||||
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
|
||||
onCreateManual: () => void;
|
||||
onLoadQuiz: (quiz: Quiz) => void;
|
||||
onJoin: (pin: string, name: string) => void;
|
||||
|
|
@ -19,15 +21,20 @@ 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 [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
||||
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 [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [questionCount, setQuestionCount] = useState(10);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [useOcr, setUseOcr] = 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 {
|
||||
quizzes,
|
||||
|
|
@ -48,21 +55,23 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
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[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);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -77,17 +86,28 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const removeFile = () => {
|
||||
setSelectedFile(null);
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
if (selectedFiles.length <= 1) {
|
||||
setUseOcr(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
setSelectedFiles([]);
|
||||
setUseOcr(false);
|
||||
};
|
||||
|
||||
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((topic.trim() || selectedFile) && !isLoading) {
|
||||
if (canGenerate && !isLoading) {
|
||||
onGenerate({
|
||||
topic: topic.trim(),
|
||||
topic: generateMode === 'topic' ? topic.trim() : undefined,
|
||||
questionCount,
|
||||
file: selectedFile || undefined
|
||||
files: generateMode === 'document' ? selectedFiles : undefined,
|
||||
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -145,120 +165,187 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
</div>
|
||||
|
||||
{mode === 'HOST' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Advanced Options
|
||||
{showAdvanced ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
<Sparkles size={16} /> Topic
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{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 || (!topic.trim() && !selectedFile)}
|
||||
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} /> {selectedFile && !topic.trim() ? 'Generate from Document' : 'Generate Quiz'}</>}
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<BrainCircuit size={24} />
|
||||
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue