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
JavaScript
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