UNPKG

mcp-quiz-server

Version:

๐Ÿง  AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

424 lines โ€ข 19.2 kB
import { SettingsService } from '../../services/SettingsService'; import { TimerService } from '../../services/TimerService'; import { AppStore } from '../../store/AppStore'; import { DOMUtils } from '../../utils/index'; import { Component } from '../Component'; import { AnswerHandler } from './AnswerHandler'; import { EventManagement } from './EventManagement'; import { FeedbackManager } from './FeedbackManager'; import { NavigationController } from './NavigationController'; import { ProgressTracker } from './ProgressTracker'; export class QuizContent extends Component { constructor() { super('#quiz-content'); this.unsubscribe = null; this.timerUnsubscribe = null; this.settingsUnsubscribe = null; this.viewModeToggleSetup = false; this.lastViewMode = null; this.store = AppStore.getInstance(); this.settingsService = SettingsService.getInstance(); this.timerService = TimerService.getInstance(); this.answerHandler = new AnswerHandler(this.store, this.settingsService); this.navigationController = new NavigationController(this.store, this.settingsService); this.feedbackManager = new FeedbackManager(this.settingsService); this.progressTracker = new ProgressTracker(this.store, this.settingsService); this.welcomeScreen = document.querySelector('#welcome-screen'); this.quizContainer = document.querySelector('#quiz-container'); this.submitButton = document.querySelector('#submit-button'); this.setupComponentEvents(); } setupComponentEvents() { document.addEventListener('quiz:render-question', (e) => { const customEvent = e; const { questionIndex } = customEvent.detail; this.renderCurrentQuestion(questionIndex); }); document.addEventListener('quiz:show-results', () => { this.showResults(); }); } render() { if (!this.element) return; const state = this.store.getState(); if (state.currentQuiz) { this.renderQuiz(state.currentQuiz, state.userAnswers); } else { this.renderWelcome(); } if (!this.viewModeToggleSetup) { this.setupViewModeToggle(); this.viewModeToggleSetup = true; } if (!this.unsubscribe) { this.unsubscribe = this.store.subscribe((newState) => { this.handleStateChange(newState); }); } if (!this.settingsUnsubscribe) { this.settingsUnsubscribe = this.settingsService.subscribe(() => { const state = this.store.getState(); if (state.currentQuiz) { console.log('๐Ÿ”„ Settings changed - re-rendering quiz for mode update'); this.renderQuiz(state.currentQuiz, state.userAnswers); } }); } if (!this.timerUnsubscribe && this.timerService) { this.timerUnsubscribe = this.timerService.subscribe(() => { this.updateTimerDisplay(); }); } } handleStateChange(state) { if (state.currentQuiz) { const currentQuiz = state.currentQuiz; const userAnswers = state.userAnswers; if (this.isAnswerOnlyUpdate(state)) { this.updateProgressOnly(currentQuiz, userAnswers); } else { this.renderQuiz(currentQuiz, userAnswers); } } else { this.renderWelcome(); } } isAnswerOnlyUpdate(state) { const container = this.quizContainer; const currentViewMode = state.ui.viewMode; if (this.lastViewMode !== null && this.lastViewMode !== currentViewMode) { this.lastViewMode = currentViewMode; return false; } this.lastViewMode = currentViewMode; return !!(container && container.style.display !== 'none' && state.currentQuiz && container.children.length > 0); } updateProgressOnly(quiz, userAnswers) { const answeredCount = Object.keys(userAnswers).length; const currentIndex = this.store.getState().quiz.currentQuestionIndex; this.progressTracker.updateProgress(currentIndex, quiz.questions.length, answeredCount); console.log(`๐Ÿ“Š Progress updated: ${answeredCount}/${quiz.questions.length} answered`); } renderWelcome() { if (this.welcomeScreen) { this.welcomeScreen.style.display = 'block'; } if (this.quizContainer) { this.quizContainer.style.display = 'none'; } console.log('๐Ÿ“‹ Welcome screen displayed'); } renderQuiz(quiz, userAnswers) { if (!quiz) return; if (this.welcomeScreen) { this.welcomeScreen.style.display = 'none'; } if (this.quizContainer) { this.quizContainer.style.display = 'block'; } const state = this.store.getState(); if (state.ui.viewMode === 'single') { this.renderSingleQuestionMode(quiz, userAnswers); } else { this.renderAllQuestionsMode(quiz, userAnswers); } this.updateViewModeButtons(state.ui.viewMode); this.updateTimerDisplay(); console.log(`๐ŸŽฏ Quiz rendered in ${state.ui.viewMode} mode`); } renderSingleQuestionMode(quiz, userAnswers) { if (!quiz.questions || !Array.isArray(quiz.questions)) { console.error('Quiz questions are missing or invalid:', quiz); this.quizContainer.innerHTML = ` <div class="error-message text-center py-8"> <p class="text-red-600 dark:text-red-400">Error: Quiz questions could not be loaded.</p> <button onclick="window.location.reload()" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> Reload Page </button> </div> `; return; } const currentIndex = this.navigationController.getCurrentQuestionIndex(); const question = quiz.questions[currentIndex]; if (!question) { console.error('๐Ÿšจ No question found at index:', currentIndex); return; } const isLastQuestion = this.navigationController.isLastQuestion(); const isAnswered = userAnswers[question.id] !== undefined; const settings = this.settingsService.getSettings(); const answeredCount = Object.keys(userAnswers).length; const questionHtml = this.renderQuestion(question, currentIndex, userAnswers); let actionButtonHtml = ''; if (isLastQuestion) { actionButtonHtml = ` <div class="complete-quiz-container mt-6 text-center" style="${isAnswered ? '' : 'display: none;'}"> <button id="complete-quiz-btn" class="bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-8 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 shadow-lg"> <span class="flex items-center justify-center"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> </svg> Complete Quiz </span> </button> </div> `; } else if (settings.ui.showContinueButton) { actionButtonHtml = ` <div class="continue-button-container mt-6 text-center" style="${isAnswered ? '' : 'display: none;'}"> <button id="continue-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> <span class="flex items-center justify-center"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> </svg> Continue </span> </button> </div> `; } const progressHtml = ` <div class="progress-indicator text-center mb-4 md:mb-6"> <span class="text-lg font-medium text-gray-700"> Question ${currentIndex + 1} of ${quiz.questions.length} </span> <div class="w-full bg-gray-200 rounded-full h-2 mt-2"> <div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${((currentIndex + 1) / quiz.questions.length) * 100}%"></div> </div> </div> `; this.quizContainer.innerHTML = ` <div class="single-question-view"> ${progressHtml} ${questionHtml} ${actionButtonHtml} </div> `; this.setupComponentsForCurrentView(); this.progressTracker.updateProgress(currentIndex, quiz.questions.length, answeredCount); } renderAllQuestionsMode(quiz, userAnswers) { if (!quiz.questions || !Array.isArray(quiz.questions)) { console.error('Quiz questions are missing or invalid:', quiz); this.quizContainer.innerHTML = ` <div class="error-message text-center py-8"> <p class="text-red-600 dark:text-red-400">Error: Quiz questions could not be loaded.</p> <button onclick="window.location.reload()" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> Reload Page </button> </div> `; return; } const questionsHtml = quiz.questions .map((question, index) => this.renderQuestion(question, index, userAnswers)) .join(''); this.quizContainer.innerHTML = `<div class="all-questions-view space-y-4 md:space-y-6">${questionsHtml}</div>`; this.setupComponentsForCurrentView(); } setupComponentsForCurrentView() { this.answerHandler.setupAnswerHandling(this.quizContainer); this.navigationController.setupNavigation(); this.setupFeedbackIntegration(); console.log('โœ… All components set up for current view'); } setupFeedbackIntegration() { const currentState = this.store.getState(); const currentQuiz = currentState.currentQuiz; if (!currentQuiz) { return; } const answerElements = this.quizContainer.querySelectorAll('[data-answer]'); answerElements.forEach(element => { EventManagement.addListener(element, 'click', () => { const questionId = element.getAttribute('data-question-id'); const selectedAnswer = element.getAttribute('data-answer'); if (questionId && selectedAnswer) { const question = currentQuiz.questions.find((q) => q.id === questionId); if (question) { const isCorrect = selectedAnswer === question.correctAnswer; this.feedbackManager.showImmediateFeedback(element, isCorrect, questionId); setTimeout(() => { this.feedbackManager.showExplanation(questionId, isCorrect); }, 500); } } }, 'feedback-integration'); }); } renderQuestion(question, index, userAnswers) { const settings = this.settingsService.getSettings(); const state = this.store.getState(); const isSingleMode = state.ui.viewMode === 'single'; const useClickableCards = isSingleMode && settings.ui.clickToSelectCards; const useInstantSubmission = settings.quiz.instantSubmission; const optionsHtml = question.options .map(option => { const isSelected = userAnswers[question.id] === option; if (useInstantSubmission) { return this.renderInstantSubmissionCard(question, option, isSelected); } else if (useClickableCards) { return this.renderClickableCard(question, option, isSelected); } else { return this.renderTraditionalRadio(question, option, isSelected); } }) .join(''); return ` <div class="question-container mb-6" data-question-id="${question.id}"> <h3 class="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200"> ${DOMUtils.escapeHtml(question.question)} </h3> <div class="options-container space-y-3"> ${optionsHtml} </div> </div> `; } renderInstantSubmissionCard(question, option, isSelected) { return ` <div class="quiz-option-card cursor-pointer p-4 rounded-xl border-2 transition-all duration-300 transform hover:scale-[1.02] hover:shadow-lg ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md' : 'border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500'}" data-question-id="${question.id}" data-answer="${DOMUtils.escapeHtml(option)}" role="button" tabindex="0" aria-pressed="${isSelected}"> <div class="flex items-center justify-between"> <span class="text-gray-900 dark:text-gray-100 font-medium">${DOMUtils.escapeHtml(option)}</span> <div class="answer-indicator w-6 h-6 rounded-full border-2 transition-all duration-200 ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-500'}"> ${isSelected ? '<div class="w-2 h-2 bg-white rounded-full mx-auto mt-1"></div>' : ''} </div> </div> </div> `; } renderClickableCard(question, option, isSelected) { return ` <div class="quiz-option-card cursor-pointer p-4 rounded-xl border-2 transition-all duration-300 ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md' : 'border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500'}" data-question-id="${question.id}" data-answer="${DOMUtils.escapeHtml(option)}" role="button" tabindex="0" aria-pressed="${isSelected}"> <div class="flex items-center justify-between"> <span class="text-gray-900 dark:text-gray-100 font-medium">${DOMUtils.escapeHtml(option)}</span> <div class="answer-indicator w-6 h-6 rounded-full border-2 transition-all duration-200 ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-500'}"> ${isSelected ? '<div class="w-2 h-2 bg-white rounded-full mx-auto mt-1"></div>' : ''} </div> </div> </div> `; } renderTraditionalRadio(question, option, isSelected) { return ` <label class="flex items-center p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"> <input type="radio" name="${question.id}" value="${DOMUtils.escapeHtml(option)}" ${isSelected ? 'checked' : ''} class="mr-3 text-blue-600 focus:ring-blue-500"> <span class="text-gray-900 dark:text-gray-100">${DOMUtils.escapeHtml(option)}</span> </label> `; } renderCurrentQuestion(questionIndex) { this.navigationController.setCurrentQuestionIndex(questionIndex); const state = this.store.getState(); if (state.currentQuiz) { this.renderQuiz(state.currentQuiz, state.userAnswers); } } showResults() { console.log('๐ŸŽ‰ Showing quiz results'); } setupViewModeToggle() { const singleModeBtn = document.getElementById('view-mode-single'); const listModeBtn = document.getElementById('view-mode-list'); if (singleModeBtn && listModeBtn) { EventManagement.addListener(singleModeBtn, 'click', () => this.setViewMode('single'), 'view-mode-single'); EventManagement.addListener(listModeBtn, 'click', () => this.setViewMode('list'), 'view-mode-list'); console.log('๐Ÿ”— View mode toggle set up successfully'); } else { console.warn('โŒ View mode buttons not found:', { singleModeBtn: !!singleModeBtn, listModeBtn: !!listModeBtn, }); } } setViewMode(mode) { this.store.setViewMode(mode); this.updateViewModeButtons(mode); console.log(`๐Ÿ‘๏ธ View mode changed to: ${mode}`); } updateViewModeButtons(activeMode) { const singleModeBtn = document.getElementById('view-mode-single'); const listModeBtn = document.getElementById('view-mode-list'); if (singleModeBtn && listModeBtn) { singleModeBtn.classList.remove('bg-blue-500', 'text-white', 'shadow-sm'); listModeBtn.classList.remove('bg-blue-500', 'text-white', 'shadow-sm'); singleModeBtn.classList.add('bg-white', 'dark:bg-surface-600'); listModeBtn.classList.add('bg-white', 'dark:bg-surface-600'); if (activeMode === 'single') { singleModeBtn.classList.remove('bg-white', 'dark:bg-surface-600'); singleModeBtn.classList.add('bg-blue-500', 'text-white', 'shadow-sm'); } else { listModeBtn.classList.remove('bg-white', 'dark:bg-surface-600'); listModeBtn.classList.add('bg-blue-500', 'text-white', 'shadow-sm'); } console.log(`๐ŸŽฏ View mode buttons updated for: ${activeMode}`); } } updateTimerDisplay() { const timerElement = document.getElementById('timer-display'); if (timerElement && this.timerService) { timerElement.textContent = 'Timer Active'; } } formatTime(seconds) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } getProgressTracker() { return this.progressTracker; } destroy() { if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } if (this.timerUnsubscribe) { this.timerUnsubscribe(); this.timerUnsubscribe = null; } if (this.settingsUnsubscribe) { this.settingsUnsubscribe(); this.settingsUnsubscribe = null; } this.answerHandler.cleanup(); this.navigationController.cleanup(); this.feedbackManager.cleanup(); this.progressTracker.cleanup(); EventManagement.cleanup(); console.log('๐Ÿงน QuizContent cleanup completed'); } } //# sourceMappingURL=QuizContent.js.map