Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState } from 'react'; | |
| import { X, Play, Check, XCircle, Trophy, Loader2, BookOpen, RotateCcw } from 'lucide-react'; | |
| import clsx from 'clsx'; | |
| interface QuizModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| context: string; | |
| } | |
| interface Question { | |
| question: string; | |
| options?: string[]; | |
| correctAnswer: string; | |
| type: 'mcq' | 'written'; | |
| } | |
| type QuizState = 'config' | 'loading' | 'quiz' | 'results'; | |
| export default function QuizModal({ isOpen, onClose, context }: QuizModalProps) { | |
| const [state, setState] = useState<QuizState>('config'); | |
| const [numQuestions, setNumQuestions] = useState(5); | |
| const [questionType, setQuestionType] = useState<'mcq' | 'written' | 'mixed'>('mcq'); | |
| const [numOptions, setNumOptions] = useState(4); | |
| const [questions, setQuestions] = useState<Question[]>([]); | |
| const [currentIndex, setCurrentIndex] = useState(0); | |
| const [answers, setAnswers] = useState<Record<number, string>>({}); | |
| const [showAnswer, setShowAnswer] = useState(false); | |
| const [score, setScore] = useState(0); | |
| const generateQuiz = async () => { | |
| setState('loading'); | |
| try { | |
| const prompt = ` | |
| Based on this educational content: | |
| --- | |
| ${context.substring(0, 3000)} | |
| --- | |
| Generate a quiz with exactly ${numQuestions} questions. | |
| Question type: ${questionType === 'mcq' ? `Multiple Choice with ${numOptions} options` : questionType === 'written' ? 'Written/Short Answer (no options)' : 'Mix of MCQ and Written'} | |
| IMPORTANT: Return ONLY valid JSON in this exact format, no extra text: | |
| { | |
| "questions": [ | |
| { | |
| "question": "Question text here?", | |
| "options": ["Option A", "Option B", "Option C", "Option D"], | |
| "correctAnswer": "Option A", | |
| "type": "mcq" | |
| }, | |
| { | |
| "question": "Written question here?", | |
| "correctAnswer": "The expected answer", | |
| "type": "written" | |
| } | |
| ] | |
| } | |
| For MCQ: include "options" array with exactly ${numOptions} choices. | |
| For written: omit "options" field. | |
| Make questions educational and relevant to the content. | |
| `; | |
| const formData = new FormData(); | |
| formData.append('text', prompt); | |
| const res = await fetch('/api/analyze', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!res.ok) throw new Error('Failed to generate quiz'); | |
| const data = await res.json(); | |
| let responseText = data.result || ''; | |
| responseText = responseText.replace(/\[\[TOPICS?:.*?\]\]/gi, '').trim(); | |
| const jsonMatch = responseText.match(/\{[\s\S]*"questions"[\s\S]*\}/); | |
| if (!jsonMatch) throw new Error('Invalid response format'); | |
| const parsed = JSON.parse(jsonMatch[0]); | |
| if (!parsed.questions || !Array.isArray(parsed.questions)) { | |
| throw new Error('Invalid quiz format'); | |
| } | |
| setQuestions(parsed.questions); | |
| setCurrentIndex(0); | |
| setAnswers({}); | |
| setScore(0); | |
| setState('quiz'); | |
| } catch (error) { | |
| console.error('Quiz generation error:', error); | |
| const fallbackQuestions: Question[] = [ | |
| { | |
| question: "What was the main topic covered in this content?", | |
| type: 'written', | |
| correctAnswer: "Review the content for the answer" | |
| } | |
| ]; | |
| setQuestions(fallbackQuestions); | |
| setState('quiz'); | |
| } | |
| }; | |
| const selectAnswer = (answer: string) => { | |
| setAnswers(prev => ({ ...prev, [currentIndex]: answer })); | |
| setShowAnswer(true); | |
| const currentQ = questions[currentIndex]; | |
| if (currentQ.type === 'mcq' && answer === currentQ.correctAnswer) { | |
| setScore(prev => prev + 1); | |
| } | |
| }; | |
| const nextQuestion = () => { | |
| setShowAnswer(false); | |
| if (currentIndex < questions.length - 1) { | |
| setCurrentIndex(prev => prev + 1); | |
| } else { | |
| setState('results'); | |
| } | |
| }; | |
| const resetQuiz = () => { | |
| setState('config'); | |
| setQuestions([]); | |
| setCurrentIndex(0); | |
| setAnswers({}); | |
| setScore(0); | |
| setShowAnswer(false); | |
| }; | |
| if (!isOpen) return null; | |
| const currentQuestion = questions[currentIndex]; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in-up"> | |
| <div className="w-full max-w-xl bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-neutral-800 overflow-hidden"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-neutral-800"> | |
| <div className="flex items-center gap-2"> | |
| <BookOpen size={20} className="text-sky-500" /> | |
| <h2 className="text-lg font-semibold text-gray-900 dark:text-white">Quiz Me</h2> | |
| </div> | |
| <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-full"> | |
| <X size={20} className="text-gray-500" /> | |
| </button> | |
| </div> | |
| <div className="p-6"> | |
| {/* Configuration State */} | |
| {state === 'config' && ( | |
| <div className="space-y-5"> | |
| {/* Number of Questions - Input Field */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| Number of Questions | |
| </label> | |
| <input | |
| type="number" | |
| min={1} | |
| max={50} | |
| value={numQuestions} | |
| onChange={(e) => setNumQuestions(Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))} | |
| className="w-full px-4 py-3 bg-gray-50 dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent text-gray-900 dark:text-white text-center text-lg font-medium" | |
| placeholder="Enter number (1-50)" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1 text-center">Enter any number between 1-50</p> | |
| </div> | |
| {/* Question Type */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| Question Type | |
| </label> | |
| <div className="flex gap-2"> | |
| {[ | |
| { value: 'mcq', label: 'Multiple Choice' }, | |
| { value: 'written', label: 'Written' }, | |
| { value: 'mixed', label: 'Mixed' } | |
| ].map(opt => ( | |
| <button | |
| key={opt.value} | |
| onClick={() => setQuestionType(opt.value as any)} | |
| className={clsx( | |
| "flex-1 py-3 rounded-xl font-medium transition-all text-sm", | |
| questionType === opt.value | |
| ? "bg-sky-500 text-white" | |
| : "bg-gray-100 dark:bg-neutral-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-neutral-700" | |
| )} | |
| > | |
| {opt.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Number of Options - Input Field */} | |
| {(questionType === 'mcq' || questionType === 'mixed') && ( | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| Number of Options (MCQ) | |
| </label> | |
| <input | |
| type="number" | |
| min={2} | |
| max={6} | |
| value={numOptions} | |
| onChange={(e) => setNumOptions(Math.max(2, Math.min(6, parseInt(e.target.value) || 2)))} | |
| className="w-full px-4 py-3 bg-gray-50 dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent text-gray-900 dark:text-white text-center text-lg font-medium" | |
| placeholder="Enter number (2-6)" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1 text-center">Enter any number between 2-6</p> | |
| </div> | |
| )} | |
| <button | |
| onClick={generateQuiz} | |
| disabled={numQuestions < 1} | |
| className="w-full py-4 bg-sky-500 text-white rounded-xl font-semibold hover:bg-sky-600 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <Play size={18} /> | |
| Start Quiz | |
| </button> | |
| </div> | |
| )} | |
| {/* Loading State */} | |
| {state === 'loading' && ( | |
| <div className="text-center py-12"> | |
| <Loader2 size={48} className="mx-auto mb-4 text-sky-500 animate-spin" /> | |
| <p className="text-gray-500">Generating {numQuestions} questions...</p> | |
| </div> | |
| )} | |
| {/* Quiz State */} | |
| {state === 'quiz' && currentQuestion && ( | |
| <div className="space-y-6"> | |
| <div className="flex justify-between text-sm text-gray-400"> | |
| <span>Question {currentIndex + 1} of {questions.length}</span> | |
| <span>{currentQuestion.type === 'mcq' ? 'MCQ' : 'Written'}</span> | |
| </div> | |
| <div className="w-full bg-gray-200 dark:bg-neutral-700 rounded-full h-2"> | |
| <div | |
| className="bg-sky-500 h-2 rounded-full transition-all" | |
| style={{ width: `${((currentIndex + 1) / questions.length) * 100}%` }} | |
| /> | |
| </div> | |
| <p className="text-lg font-medium text-gray-900 dark:text-white"> | |
| {currentQuestion.question} | |
| </p> | |
| {currentQuestion.type === 'mcq' && currentQuestion.options && ( | |
| <div className="space-y-3"> | |
| {currentQuestion.options.map((option, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => !showAnswer && selectAnswer(option)} | |
| disabled={showAnswer} | |
| className={clsx( | |
| "w-full p-4 rounded-xl text-left transition-all border-2", | |
| showAnswer | |
| ? option === currentQuestion.correctAnswer | |
| ? "border-green-500 bg-green-50 dark:bg-green-900/20" | |
| : answers[currentIndex] === option | |
| ? "border-red-500 bg-red-50 dark:bg-red-900/20" | |
| : "border-gray-200 dark:border-neutral-700" | |
| : "border-gray-200 dark:border-neutral-700 hover:border-sky-500 hover:bg-gray-50 dark:hover:bg-neutral-800" | |
| )} | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-800 dark:text-gray-200">{option}</span> | |
| {showAnswer && option === currentQuestion.correctAnswer && ( | |
| <Check size={20} className="text-green-500" /> | |
| )} | |
| {showAnswer && answers[currentIndex] === option && option !== currentQuestion.correctAnswer && ( | |
| <XCircle size={20} className="text-red-500" /> | |
| )} | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {currentQuestion.type === 'written' && ( | |
| <div className="space-y-4"> | |
| <textarea | |
| placeholder="Type your answer..." | |
| value={answers[currentIndex] || ''} | |
| onChange={(e) => setAnswers(prev => ({ ...prev, [currentIndex]: e.target.value }))} | |
| disabled={showAnswer} | |
| className="w-full p-4 bg-gray-100 dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 text-gray-900 dark:text-white min-h-[100px] resize-none" | |
| /> | |
| {!showAnswer && ( | |
| <button | |
| onClick={() => setShowAnswer(true)} | |
| disabled={!answers[currentIndex]?.trim()} | |
| className="w-full py-3 bg-sky-500 text-white rounded-xl font-medium hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Check Answer | |
| </button> | |
| )} | |
| {showAnswer && ( | |
| <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-200 dark:border-green-800"> | |
| <p className="text-sm text-gray-500 mb-1">Expected Answer:</p> | |
| <p className="text-green-700 dark:text-green-300">{currentQuestion.correctAnswer}</p> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {showAnswer && ( | |
| <button | |
| onClick={nextQuestion} | |
| className="w-full py-3 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-xl font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors" | |
| > | |
| {currentIndex < questions.length - 1 ? 'Next Question' : 'See Results'} | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Results State */} | |
| {state === 'results' && ( | |
| <div className="text-center space-y-6"> | |
| <Trophy size={64} className="mx-auto text-yellow-500" /> | |
| <div> | |
| <h3 className="text-2xl font-bold text-gray-900 dark:text-white">Quiz Complete!</h3> | |
| <p className="text-gray-500 mt-2"> | |
| You scored <span className="text-sky-500 font-bold">{score}</span> out of {questions.filter(q => q.type === 'mcq').length} MCQ questions | |
| </p> | |
| </div> | |
| <div className="flex gap-3"> | |
| <button | |
| onClick={resetQuiz} | |
| className="flex-1 py-3 bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-neutral-700 flex items-center justify-center gap-2" | |
| > | |
| <RotateCcw size={18} /> | |
| Try Again | |
| </button> | |
| <button | |
| onClick={onClose} | |
| className="flex-1 py-3 bg-sky-500 text-white rounded-xl font-medium hover:bg-sky-600" | |
| > | |
| Done | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |