Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useCallback } from 'react'; | |
| import { Quiz, QuizConfig, UserAnswer, QuizQuestion as QuizQuestionType } from '../types.ts'; | |
| import Calculator from './Calculator.tsx'; | |
| interface QuestionPaletteProps { | |
| count: number; | |
| currentIndex: number; | |
| answers: UserAnswer[]; | |
| onQuestionSelect: (index: number) => void; | |
| } | |
| const QuestionPalette: React.FC<QuestionPaletteProps> = ({ count, currentIndex, answers, onQuestionSelect }) => { | |
| return ( | |
| <div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"> | |
| <h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3 text-center">Question Navigation</h4> | |
| <div className="grid grid-cols-5 gap-2"> | |
| {Array.from({ length: count }, (_, i) => { | |
| const isAnswered = answers[i]?.selectedOption !== null; | |
| const isCurrent = i === currentIndex; | |
| let buttonClass = 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200'; | |
| if (isAnswered) { | |
| buttonClass = 'bg-green-100 dark:bg-green-900/50 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-300'; | |
| } | |
| if (isCurrent) { | |
| buttonClass = 'bg-blue-500 hover:bg-blue-600 text-white ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-800 ring-blue-500'; | |
| } | |
| return ( | |
| <button | |
| key={i} | |
| onClick={() => onQuestionSelect(i)} | |
| className={`w-10 h-10 rounded-lg flex items-center justify-center font-bold text-sm transition-all duration-200 ${buttonClass}`} | |
| aria-label={`Go to question ${i + 1}`} | |
| > | |
| {i + 1} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const Question: React.FC<{ question: QuizQuestionType; questionNumber: number; totalQuestions: number; }> = ({ question, questionNumber, totalQuestions }) => { | |
| return ( | |
| <div> | |
| <div className="text-gray-800 dark:text-gray-100 text-xl whitespace-pre-wrap bg-gray-50 dark:bg-gray-700/50 p-6 rounded-lg border border-gray-200 dark:border-gray-700">{question.questionText}</div> | |
| {question.questionType === 'MSQ' && ( | |
| <p className="text-base text-blue-600 dark:text-blue-400 mt-2 font-semibold"> | |
| (Select all that apply) | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| interface QuizScreenProps { | |
| quiz: Quiz; | |
| config: QuizConfig; | |
| onSubmit: (answers: UserAnswer[]) => void; | |
| onQuit: () => void; | |
| } | |
| const QuizScreen: React.FC<QuizScreenProps> = ({ quiz, config, onSubmit, onQuit }) => { | |
| const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); | |
| const [answers, setAnswers] = useState<UserAnswer[]>(() => Array(quiz.questions.length).fill({ selectedOption: null })); | |
| const [timeLeft, setTimeLeft] = useState(config.timeLimit * 60); | |
| const handleSubmit = useCallback(() => { | |
| onSubmit(answers); | |
| }, [answers, onSubmit]); | |
| const handleQuit = () => { | |
| if (window.confirm("Are you sure you want to quit the quiz? All progress will be lost.")) { | |
| onQuit(); | |
| } | |
| }; | |
| useEffect(() => { | |
| const timer = setInterval(() => { | |
| setTimeLeft(prevTime => { | |
| if (prevTime <= 1) { | |
| clearInterval(timer); | |
| handleSubmit(); | |
| return 0; | |
| } | |
| return prevTime - 1; | |
| }); | |
| }, 1000); | |
| return () => clearInterval(timer); | |
| }, [handleSubmit]); | |
| const handleAnswerSelect = (optionIndex: number) => { | |
| const newAnswers = [...answers]; | |
| const currentQuestion = quiz.questions[currentQuestionIndex]; | |
| if (currentQuestion.questionType === 'MSQ') { | |
| const currentSelection = answers[currentQuestionIndex].selectedOption || []; | |
| const newSelection: number[] = [...currentSelection]; | |
| const optionIndexInSelection = newSelection.indexOf(optionIndex); | |
| if (optionIndexInSelection > -1) { | |
| newSelection.splice(optionIndexInSelection, 1); | |
| } else { | |
| newSelection.push(optionIndex); | |
| } | |
| newSelection.sort((a, b) => a - b); | |
| newAnswers[currentQuestionIndex] = { selectedOption: newSelection.length > 0 ? newSelection : null }; | |
| } else { // MCQ | |
| newAnswers[currentQuestionIndex] = { selectedOption: [optionIndex] }; | |
| } | |
| setAnswers(newAnswers); | |
| }; | |
| const handleClearSelection = () => { | |
| const newAnswers = [...answers]; | |
| newAnswers[currentQuestionIndex] = { selectedOption: null }; | |
| setAnswers(newAnswers); | |
| }; | |
| const goToNext = () => { | |
| if (currentQuestionIndex < quiz.questions.length - 1) { | |
| setCurrentQuestionIndex(prev => prev + 1); | |
| } | |
| }; | |
| const goToPrevious = () => { | |
| if (currentQuestionIndex > 0) { | |
| setCurrentQuestionIndex(prev => prev - 1); | |
| } | |
| }; | |
| const currentQuestion = quiz.questions[currentQuestionIndex]; | |
| const minutes = Math.floor(timeLeft / 60); | |
| const seconds = timeLeft % 60; | |
| const progress = ((currentQuestionIndex + 1) / quiz.questions.length) * 100; | |
| return ( | |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| {/* Main Content */} | |
| <div className="lg:col-span-8"> | |
| <div className="bg-white dark:bg-gray-800 p-6 md:p-8 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700"> | |
| {/* Header */} | |
| <div className="mb-6"> | |
| <div className="flex justify-between items-center mb-4"> | |
| <h3 className="text-xl font-semibold text-gray-700 dark:text-gray-300"> | |
| Question {currentQuestionIndex + 1} <span className="text-gray-400 dark:text-gray-500">/ {quiz.questions.length}</span> | |
| </h3> | |
| <div className="flex items-center gap-3"> | |
| <div className={`text-xl font-semibold px-4 py-2 rounded-lg ${timeLeft < 60 ? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300' : 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'}`}> | |
| Time: {minutes}:{seconds < 10 ? `0${seconds}` : seconds} | |
| </div> | |
| <button | |
| onClick={handleQuit} | |
| className="px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 rounded-lg transition-colors border border-red-200 dark:border-red-800" | |
| > | |
| Quit | |
| </button> | |
| </div> | |
| </div> | |
| <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5"> | |
| <div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${progress}%` }}></div> | |
| </div> | |
| </div> | |
| <Question | |
| question={currentQuestion} | |
| questionNumber={currentQuestionIndex + 1} | |
| totalQuestions={quiz.questions.length} | |
| /> | |
| <div className="mt-6 space-y-3"> | |
| {currentQuestion.options.map((option, index) => { | |
| const isSelected = Array.isArray(answers[currentQuestionIndex].selectedOption) && answers[currentQuestionIndex].selectedOption.includes(index); | |
| return ( | |
| <label key={index} className={`flex items-center p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${isSelected ? 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-500 shadow-md' : 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/60'}`}> | |
| <input | |
| type={currentQuestion.questionType === 'MSQ' ? 'checkbox' : 'radio'} | |
| name={`question-${currentQuestionIndex}`} | |
| checked={isSelected} | |
| onChange={() => handleAnswerSelect(index)} | |
| className={`h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-400 ${currentQuestion.questionType === 'MSQ' ? 'rounded' : ''}`} | |
| /> | |
| <span className="ml-4 text-gray-700 dark:text-gray-200 text-lg">{option}</span> | |
| </label> | |
| ) | |
| })} | |
| </div> | |
| <div className="mt-6 flex justify-between items-center"> | |
| <button | |
| onClick={handleClearSelection} | |
| disabled={answers[currentQuestionIndex].selectedOption === null} | |
| className="px-4 py-2 text-base font-semibold text-gray-600 dark:text-gray-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Clear Selection | |
| </button> | |
| <div className="flex space-x-4"> | |
| <button onClick={goToPrevious} disabled={currentQuestionIndex === 0} className="px-8 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 font-semibold rounded-lg shadow-md hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition text-lg"> | |
| Previous | |
| </button> | |
| {currentQuestionIndex === quiz.questions.length - 1 ? ( | |
| <button onClick={handleSubmit} className="px-10 py-3 bg-green-600 text-white font-bold rounded-lg shadow-lg hover:bg-green-700 transition-transform hover:scale-105 text-lg"> | |
| Submit Quiz | |
| </button> | |
| ) : ( | |
| <button onClick={goToNext} className="px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 disabled:opacity-50 transition text-lg"> | |
| Save & Next | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right Sidebar */} | |
| <div className="lg:col-span-4 space-y-6"> | |
| <QuestionPalette | |
| count={quiz.questions.length} | |
| currentIndex={currentQuestionIndex} | |
| answers={answers} | |
| onQuestionSelect={setCurrentQuestionIndex} | |
| /> | |
| <Calculator /> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default QuizScreen; | |