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.

678 lines (590 loc) โ€ข 24.4 kB
/** * @moduleName: QuizContent (Refactored Main Orchestrator) * @version: 3.0.0 * @since: 2025-07-26 * @lastUpdated: 2025-07-26 * @projectSummary: Enhanced MCP Quiz Server - Modular Quiz Content Orchestrator * @techStack: TypeScript, Component Architecture, Event-Driven Design * @dependency: Component, EventManagement, AnswerHandler, NavigationController * @interModuleDependency: Coordinates all quiz sub-components with proper lifecycle management * @requirementsTraceability: * {@link Requirements.REQ_UI_002} (Dual View Mode System - Quiz Orchestrator) * @briefDescription: Main quiz orchestrator managing view modes, rendering, and component coordination * @methods: render, renderQuiz, renderSingleQuestionMode, renderAllQuestionsMode, cleanup * @contributors: GitHub Copilot * @examples: * - const quizContent = new QuizContent(); quizContent.render(); * @vulnerabilitiesAssessment: Event-driven architecture with proper cleanup, no sensitive data exposure */ import { SettingsService } from '../../services/SettingsService'; import { TimerService } from '../../services/TimerService'; import { AppStore } from '../../store/AppStore'; import { AppState, Question, Quiz, ViewMode } from '../../types/index'; 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 { private store: AppStore; private settingsService: SettingsService; private timerService: TimerService; private answerHandler: AnswerHandler; private navigationController: NavigationController; private feedbackManager: FeedbackManager; private progressTracker: ProgressTracker; private welcomeScreen: HTMLElement; private quizContainer: HTMLElement; private submitButton: HTMLButtonElement; private unsubscribe: (() => void) | null = null; private timerUnsubscribe: (() => void) | null = null; private settingsUnsubscribe: (() => void) | null = null; private viewModeToggleSetup = false; private lastViewMode: ViewMode | null = null; // Track view mode changes constructor() { super('#quiz-content'); this.store = AppStore.getInstance(); this.settingsService = SettingsService.getInstance(); this.timerService = TimerService.getInstance(); // Initialize sub-components 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); // Get DOM elements this.welcomeScreen = document.querySelector('#welcome-screen') as HTMLElement; this.quizContainer = document.querySelector('#quiz-container') as HTMLElement; this.submitButton = document.querySelector('#submit-button') as HTMLButtonElement; // Set up event listeners for component communication this.setupComponentEvents(); } /** * Set up inter-component communication events */ private setupComponentEvents(): void { document.addEventListener('quiz:render-question', (e: Event) => { const customEvent = e as CustomEvent; const { questionIndex } = customEvent.detail; this.renderCurrentQuestion(questionIndex); }); document.addEventListener('quiz:show-results', () => { this.showResults(); }); } render(): void { if (!this.element) return; const state = this.store.getState(); if (state.currentQuiz) { this.renderQuiz(state.currentQuiz, state.userAnswers); } else { this.renderWelcome(); } // Set up view mode toggle (only once) if (!this.viewModeToggleSetup) { this.setupViewModeToggle(); this.viewModeToggleSetup = true; } // Subscribe to store changes if (!this.unsubscribe) { this.unsubscribe = this.store.subscribe((newState: AppState) => { this.handleStateChange(newState); }); } // Subscribe to settings changes for immediate UI updates if (!this.settingsUnsubscribe) { this.settingsUnsubscribe = this.settingsService.subscribe(() => { // Re-render the current quiz when settings change const state = this.store.getState(); if (state.currentQuiz) { console.log('๐Ÿ”„ Settings changed - re-rendering quiz for mode update'); this.renderQuiz(state.currentQuiz, state.userAnswers); } }); } // Subscribe to timer if available if (!this.timerUnsubscribe && this.timerService) { this.timerUnsubscribe = this.timerService.subscribe(() => { this.updateTimerDisplay(); }); } } /** * Handle store state changes */ private handleStateChange(state: AppState): void { if (state.currentQuiz) { // Only re-render if the quiz changed or view mode changed // For answer updates, just update progress const currentQuiz = state.currentQuiz; const userAnswers = state.userAnswers; // Check if this is just an answer update (not a full re-render scenario) if (this.isAnswerOnlyUpdate(state)) { this.updateProgressOnly(currentQuiz, userAnswers); } else { this.renderQuiz(currentQuiz, userAnswers); } } else { this.renderWelcome(); } } /** * Check if this state change is just an answer update */ private isAnswerOnlyUpdate(state: AppState): boolean { const container = this.quizContainer; const currentViewMode = state.ui.viewMode; // If view mode changed, force full re-render if (this.lastViewMode !== null && this.lastViewMode !== currentViewMode) { this.lastViewMode = currentViewMode; return false; // Force full re-render for view mode changes } // Update lastViewMode this.lastViewMode = currentViewMode; // Check if this is just an answer update (quiz already rendered) return !!( container && container.style.display !== 'none' && state.currentQuiz && container.children.length > 0 ); // Quiz is already rendered } /** * Update only progress tracking without full re-render */ private updateProgressOnly(quiz: Quiz, userAnswers: Record<string, string>): void { const answeredCount = Object.keys(userAnswers).length; const currentIndex = this.store.getState().quiz.currentQuestionIndex; // Update progress tracker this.progressTracker.updateProgress(currentIndex, quiz.questions.length, answeredCount); console.log(`๐Ÿ“Š Progress updated: ${answeredCount}/${quiz.questions.length} answered`); } /** * Render the welcome screen */ private renderWelcome(): void { if (this.welcomeScreen) { this.welcomeScreen.style.display = 'block'; } if (this.quizContainer) { this.quizContainer.style.display = 'none'; } console.log('๐Ÿ“‹ Welcome screen displayed'); } /** * Main quiz rendering method */ private renderQuiz(quiz: Quiz, userAnswers: Record<string, string>): void { if (!quiz) return; // Hide welcome, show quiz if (this.welcomeScreen) { this.welcomeScreen.style.display = 'none'; } if (this.quizContainer) { this.quizContainer.style.display = 'block'; } const state = this.store.getState(); // Render based on view mode if (state.ui.viewMode === 'single') { this.renderSingleQuestionMode(quiz, userAnswers); } else { this.renderAllQuestionsMode(quiz, userAnswers); } // Update view mode buttons to reflect current state this.updateViewModeButtons(state.ui.viewMode); // Update timer display if in timed mode this.updateTimerDisplay(); console.log(`๐ŸŽฏ Quiz rendered in ${state.ui.viewMode} mode`); } /** * Render single question mode (CRITICAL: Fixed infinite recursion) */ private renderSingleQuestionMode(quiz: Quiz, userAnswers: Record<string, string>): void { // Ensure questions array exists 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(); // Update progress tracking const answeredCount = Object.keys(userAnswers).length; // Generate question HTML const questionHtml = this.renderQuestion(question, currentIndex, userAnswers); // Generate action button HTML 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> `; } // Get progress HTML (keeping existing structure for compatibility) 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> `; // Update DOM this.quizContainer.innerHTML = ` <div class="single-question-view"> ${progressHtml} ${questionHtml} ${actionButtonHtml} </div> `; // CRITICAL FIX: Set up components with proper cleanup this.setupComponentsForCurrentView(); // Update progress tracking after DOM is rendered this.progressTracker.updateProgress(currentIndex, quiz.questions.length, answeredCount); } /** * Render all questions mode */ private renderAllQuestionsMode(quiz: Quiz, userAnswers: Record<string, string>): void { // Ensure questions array exists 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>`; // Set up components for all questions view this.setupComponentsForCurrentView(); } /** * Set up sub-components for current view (CRITICAL: Prevents infinite recursion) */ private setupComponentsForCurrentView(): void { // Set up answer handling this.answerHandler.setupAnswerHandling(this.quizContainer); // Set up navigation (this fixes the infinite recursion bug) this.navigationController.setupNavigation(); // Set up feedback integration by listening to store changes this.setupFeedbackIntegration(); console.log('โœ… All components set up for current view'); } /** * Set up feedback integration to work with answer selection */ private setupFeedbackIntegration(): void { // Listen for answer updates to trigger feedback const currentState = this.store.getState(); const currentQuiz = currentState.currentQuiz; if (!currentQuiz) { return; // No quiz loaded } // Set up event listeners for answer selections 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: Question) => q.id === questionId); if (question) { const isCorrect = selectedAnswer === question.correctAnswer; // Show immediate feedback this.feedbackManager.showImmediateFeedback(element, isCorrect, questionId); // Show explanation if enabled setTimeout(() => { this.feedbackManager.showExplanation(questionId, isCorrect); }, 500); } } }, 'feedback-integration' ); }); } /** * Render a single question */ private renderQuestion( question: Question, index: number, userAnswers: Record<string, string> ): string { const settings = this.settingsService.getSettings(); const state = this.store.getState(); const isSingleMode = state.ui.viewMode === 'single'; // Determine which UI to use based on mode and settings 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> `; } /** * Render instant submission card */ private renderInstantSubmissionCard( question: Question, option: string, isSelected: boolean ): string { 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> `; } /** * Render clickable card (non-instant) */ private renderClickableCard(question: Question, option: string, isSelected: boolean): string { 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> `; } /** * Render traditional radio button */ private renderTraditionalRadio(question: Question, option: string, isSelected: boolean): string { 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> `; } /** * Render current question in single mode */ private renderCurrentQuestion(questionIndex: number): void { this.navigationController.setCurrentQuestionIndex(questionIndex); const state = this.store.getState(); if (state.currentQuiz) { this.renderQuiz(state.currentQuiz, state.userAnswers); } } /** * Show quiz results */ private showResults(): void { // This will be handled by a ResultsModal component console.log('๐ŸŽ‰ Showing quiz results'); } /** * Set up view mode toggle (existing functionality) */ private setupViewModeToggle(): void { 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, }); } } /** * Set view mode */ private setViewMode(mode: ViewMode): void { this.store.setViewMode(mode); this.updateViewModeButtons(mode); console.log(`๐Ÿ‘๏ธ View mode changed to: ${mode}`); } /** * Update view mode button states */ private updateViewModeButtons(activeMode: ViewMode): void { const singleModeBtn = document.getElementById('view-mode-single'); const listModeBtn = document.getElementById('view-mode-list'); if (singleModeBtn && listModeBtn) { // Remove active classes singleModeBtn.classList.remove('bg-blue-500', 'text-white', 'shadow-sm'); listModeBtn.classList.remove('bg-blue-500', 'text-white', 'shadow-sm'); // Add inactive classes singleModeBtn.classList.add('bg-white', 'dark:bg-surface-600'); listModeBtn.classList.add('bg-white', 'dark:bg-surface-600'); // Set active button 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}`); } } /** * Update timer display */ private updateTimerDisplay(): void { // Timer display logic (simplified for now) const timerElement = document.getElementById('timer-display'); if (timerElement && this.timerService) { // Use a simple placeholder for now - will be implemented based on actual TimerService API timerElement.textContent = 'Timer Active'; } } /** * Format time for display */ private formatTime(seconds: number): string { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } /** * Get progress tracker instance for external access * Used by SettingsMenu to refresh document title when modal closes */ getProgressTracker(): ProgressTracker { return this.progressTracker; } /** * Clean up component and sub-components */ destroy(): void { // Clean up subscriptions if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } if (this.timerUnsubscribe) { this.timerUnsubscribe(); this.timerUnsubscribe = null; } if (this.settingsUnsubscribe) { this.settingsUnsubscribe(); this.settingsUnsubscribe = null; } // Clean up sub-components this.answerHandler.cleanup(); this.navigationController.cleanup(); this.feedbackManager.cleanup(); this.progressTracker.cleanup(); // Clean up all event listeners EventManagement.cleanup(); console.log('๐Ÿงน QuizContent cleanup completed'); } }