Socratic-Lens / src /components /QuizModal.tsx
Jainish1808
Initial commit - Socratic Lens
4000a4c
'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>
);
}