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
text/typescript
/**
* @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');
}
}