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.

404 lines โ€ข 18.1 kB
import { QuizProgressManager } from '../services/QuizProgressManager'; 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 './quiz/AnswerHandler'; import { EventManagement } from './quiz/EventManagement'; import { NavigationController } from './quiz/NavigationController'; export class QuizContent extends Component { constructor() { super('#quiz-content'); this.progressManager = null; this.previousAnswers = {}; this.answerDebounceTimer = null; this.unsubscribe = null; this.timerUnsubscribe = null; this.viewModeToggleSetup = false; 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.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(); }); document.addEventListener('quiz:completed', (e) => { const customEvent = e; console.log('๐ŸŽ‰ Quiz completed via auto-advance:', customEvent.detail); 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.timerUnsubscribe && this.timerService) { this.timerUnsubscribe = this.timerService.subscribe(() => { this.updateTimerDisplay(); }); } window.addEventListener('timerSettingsChanged', (event) => { const customEvent = event; console.log('๐Ÿ”„ Timer settings changed, updating display'); this.updateTimerDisplay(); if (customEvent.detail?.useTimer) { this.autoStartTimerIfEnabled(); } }); } handleStateChange(state) { if (state.currentQuiz) { this.renderQuiz(state.currentQuiz, state.userAnswers); if (this.progressManager) { const newAnswers = []; for (const [questionId, answer] of Object.entries(state.userAnswers)) { if (this.previousAnswers[questionId] !== answer) { newAnswers.push({ questionId, answer }); } } if (newAnswers.length > 0) { if (this.answerDebounceTimer) { window.clearTimeout(this.answerDebounceTimer); } this.answerDebounceTimer = window.setTimeout(() => { console.log('๐Ÿ“ Processing debounced answers:', newAnswers); newAnswers.forEach(({ questionId, answer }) => { this.progressManager?.handleAnswerSelection(questionId, answer); }); this.answerDebounceTimer = null; }, 150); } } this.previousAnswers = { ...state.userAnswers }; } else { this.renderWelcome(); } } autoStartTimerIfEnabled() { const settings = this.settingsService.getSettings(); if (settings.quiz.useTimer && this.timerService) { const timerState = this.timerService.getState(); if (!timerState.isRunning) { console.log('๐Ÿš€ Auto-starting timer (modern single-setting approach)'); this.timerService.autoStartIfEnabled(this.settingsService); } } } initializeProgressManager(quiz) { if (this.progressManager) { this.progressManager.destroy(); } this.progressManager = new QuizProgressManager(quiz, this.settingsService.getSettings(), this.navigationController, null); console.log('๐Ÿ“Š Progress manager initialized for quiz:', quiz.id); } 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'; } this.initializeProgressManager(quiz); this.autoStartTimerIfEnabled(); const state = this.store.getState(); if (state.ui.viewMode === 'single') { this.renderSingleQuestionMode(quiz, userAnswers); } else { this.renderAllQuestionsMode(quiz, userAnswers); } this.updateTimerDisplay(); console.log(`๐ŸŽฏ Quiz rendered in ${state.ui.viewMode} mode`); } renderSingleQuestionMode(quiz, userAnswers) { 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 questionHtml = this.renderQuestion(question, currentIndex, userAnswers); let actionButtonHtml = ''; const shouldShowButton = this.shouldShowSubmitButton(isAnswered, isLastQuestion, settings); if (isLastQuestion) { actionButtonHtml = ` <div class="complete-quiz-container mt-6 text-center" style="${shouldShowButton ? '' : '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="${shouldShowButton ? '' : '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> `; } this.quizContainer.innerHTML = ` <div class="single-question-view"> <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> ${questionHtml} ${actionButtonHtml} </div> `; this.setupComponentsForCurrentView(); } renderAllQuestionsMode(quiz, userAnswers) { 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(); console.log('โœ… All components set up for current view'); } 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-block 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); console.log(`๐Ÿ‘๏ธ View mode changed to: ${mode}`); } updateTimerDisplay() { const timerStatusElement = document.getElementById('timer-status'); const timerCountdownElement = document.getElementById('timer-countdown'); if (!timerStatusElement || !timerCountdownElement) return; const settings = this.settingsService.getSettings(); const useTimer = settings.quiz?.useTimer || false; if (!useTimer) { timerStatusElement.textContent = 'Not configured'; timerCountdownElement.classList.add('hidden'); return; } if (this.timerService) { const timerState = this.timerService.getState(); if (timerState.isRunning) { timerStatusElement.textContent = 'Running'; timerCountdownElement.textContent = this.formatTime(timerState.timeRemaining); timerCountdownElement.classList.remove('hidden'); } else { timerStatusElement.textContent = 'Ready to start'; timerCountdownElement.textContent = this.formatTime(timerState.totalTime); timerCountdownElement.classList.remove('hidden'); } } else { timerStatusElement.textContent = 'Enabled'; timerCountdownElement.classList.add('hidden'); } } shouldShowSubmitButton(isAnswered, isLastQuestion, settings) { const state = this.store.getState(); if (state.ui.viewMode === 'single') { return isAnswered; } const quiz = state.currentQuiz; if (!quiz) return false; const answeredCount = Object.keys(state.userAnswers).length; const totalQuestions = quiz.questions.length; const completionRate = answeredCount / totalQuestions; return (completionRate >= 0.7 || answeredCount === totalQuestions || settings.quiz.smartSubmitLogic === false); } formatTime(seconds) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } destroy() { if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } if (this.timerUnsubscribe) { this.timerUnsubscribe(); this.timerUnsubscribe = null; } if (this.answerDebounceTimer) { window.clearTimeout(this.answerDebounceTimer); this.answerDebounceTimer = null; } if (this.progressManager) { this.progressManager.destroy(); this.progressManager = null; } this.answerHandler.cleanup(); this.navigationController.cleanup(); EventManagement.cleanup(); console.log('๐Ÿงน QuizContent cleanup completed'); } } //# sourceMappingURL=QuizContent.js.map