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.
342 lines (303 loc) • 14.3 kB
text/typescript
/**
* @moduleName: Results Modal Component - Quiz Completion Display
* @version: 2.0.0
* @since: 2025-07-21
* @lastUpdated: 2025-07-27
* @projectSummary: Enhanced MCP Quiz Server - Modal component displaying quiz results with score, grade, and question breakdown
* @techStack: TypeScript, Component Architecture, Modal UI, DOM Manipulation
* @dependency: Component base class, AppStore, QuizResult types, utility functions
* @interModuleDependency: AppStore for state management, DOMUtils, QuizUtils for formatting
* @requirementsTraceability:
* {@link Requirements.REQ_UI_005} (Progress Visualization)
* {@link Requirements.REQ_INTEGRATION_001} (Results Display)
* @briefDescription: Modal component managing quiz completion display with scores, grades, question feedback, and user actions
* @methods: render, show, hide, bindEvents, displayResults, formatScore, handleRetake
* @contributors: GitHub Copilot, Results Display Team
* @examples:
* - const resultsModal = new ResultsModal()
* - resultsModal.show() // Display quiz results
* - resultsModal.displayResults(quizResult) // Show specific results
* @vulnerabilitiesAssessment: Modal z-index management, XSS prevention in result display, accessibility compliance
*/
import { AppStore } from '../store/AppStore';
import { AppState } from '../types/index';
import { DOMUtils, QuizUtils } from '../utils/index';
import { Component } from './Component';
/**
* Represents a modal component that displays quiz results to the user.
*
* The `ResultsModal` class manages the display and interaction logic for showing
* quiz results, including the user's score, grade, and a breakdown of each question.
* It subscribes to application state changes, renders result details, and provides
* actions for retaking the quiz or closing the modal.
*
* @remarks
* - Extends the base `Component` class.
* - Listens to state changes from the `AppStore` singleton.
* - Handles modal animations and accessibility features.
* - Dynamically generates result summaries and binds UI events.
*
* @example
* ```typescript
* const resultsModal = new ResultsModal();
* resultsModal.showResult(resultData);
* ```
*/
export class ResultsModal extends Component {
private store: AppStore;
private unsubscribe: (() => void) | null = null;
private currentResult: any = null;
constructor() {
super('#result-modal');
this.store = AppStore.getInstance();
}
protected onMount(): void {
this.unsubscribe = this.store.subscribe(state => this.onStateChange(state));
}
protected onUnmount(): void {
this.unsubscribe?.();
}
protected bindEvents(): void {
// Close modal on backdrop click
this.element.addEventListener('click', e => {
if (e.target === this.element) {
this.store.hideResults();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
this.store.hideResults();
}
});
// Close button
document.querySelector('#close-results-btn')?.addEventListener('click', () => {
this.store.hideResults();
});
}
private onStateChange(state: AppState): void {
if (state.ui.showResults && state.currentQuiz) {
this.show();
this.render();
} else {
this.hide();
}
}
protected render(): void {
const state = this.store.getState();
if (!state.currentQuiz) return;
// Use actual result data if available, otherwise create from state
const result = state.lastResult || this.createResultFromState(state);
this.currentResult = result;
const resultContainer = this.element.querySelector('#result-container');
if (!resultContainer) return;
const percentage = Math.round(
(this.currentResult.score / this.currentResult.totalQuestions) * 100
);
const grade = QuizUtils.getGrade(percentage);
// Use feedback data if available (from enhanced server response), otherwise fallback to results
const feedbackData = this.currentResult.feedback || this.currentResult.results || [];
const resultsHtml =
feedbackData
?.map((questionResult: any, index: number) => {
const isCorrect = questionResult.isCorrect;
const bgColor = isCorrect
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-red-50 dark:bg-red-900/20';
const borderColor = isCorrect
? 'border-green-200 dark:border-green-800'
: 'border-red-200 dark:border-red-800';
const iconColor = isCorrect
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
return `
<div class="p-3 md:p-4 rounded-lg border ${borderColor} ${bgColor}">
<div class="flex items-start gap-3 mb-2">
<i data-lucide="${isCorrect ? 'check-circle' : 'x-circle'}"
class="w-4 h-4 md:w-5 md:h-5 ${iconColor} mt-1 flex-shrink-0"></i>
<div class="flex-1 min-w-0">
<p class="text-sm md:text-base font-medium text-surface-900 dark:text-surface-100 leading-snug">
${index + 1}. ${DOMUtils.escapeHtml(questionResult.question)}
</p>
</div>
</div>
<div class="ml-7 md:ml-8 text-xs md:text-sm space-y-1.5">
<p class="text-surface-700 dark:text-surface-300">
<span class="font-medium">Your answer:</span>
<span class="${isCorrect ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}">${DOMUtils.escapeHtml(questionResult.userAnswer)}</span>
</p>
${
!isCorrect
? `
<p class="text-surface-700 dark:text-surface-300">
<span class="font-medium">Correct answer:</span>
<span class="text-green-700 dark:text-green-300">${DOMUtils.escapeHtml(questionResult.correctAnswer)}</span>
</p>
`
: ''
}
${
questionResult.explanation
? `
<div class="mt-2 md:mt-3 p-2 md:p-3 bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-500 rounded-r">
<div class="flex items-start gap-2">
<i data-lucide="lightbulb" class="w-3 h-3 md:w-4 md:h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"></i>
<div class="flex-1">
<p class="text-xs md:text-sm font-medium text-blue-800 dark:text-blue-200 mb-1"> Explanation</p>
<p class="text-xs md:text-sm text-blue-700 dark:text-blue-300 leading-relaxed">${DOMUtils.escapeHtml(questionResult.explanation)}</p>
</div>
</div>
</div>
`
: ''
}
</div>
</div>
`;
})
.join('') || '';
resultContainer.innerHTML = `
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-16 h-16 bg-${grade.color}-100 dark:bg-${grade.color}-900/30 rounded-full mb-4">
<i data-lucide="${grade.icon}" class="w-8 h-8 text-${grade.color}-600 dark:text-${grade.color}-400"></i>
</div>
<h3 class="text-xl md:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-2">${grade.text}</h3>
<p class="text-surface-600 dark:text-surface-400 text-sm md:text-base">
You scored ${this.currentResult.score} out of ${this.currentResult.totalQuestions} questions
</p>
<div class="text-2xl md:text-3xl font-bold text-${grade.color}-600 dark:text-${grade.color}-400 mt-2">${percentage}%</div>
</div>
<div class="space-y-3 md:space-y-4">
<h4 class="font-medium text-surface-900 dark:text-surface-100 text-sm md:text-base">Question Breakdown:</h4>
${resultsHtml}
</div>
`;
// Update action buttons in footer
const actionsContainer = this.element.querySelector('#result-actions');
if (actionsContainer) {
actionsContainer.innerHTML = `
<div class="flex flex-col sm:flex-row gap-3">
<button id="retake-quiz-btn" class="flex-1 bg-primary-600 hover:bg-primary-700 text-white py-2.5 px-4 rounded-lg font-medium transition-colors text-sm md:text-base">
<i data-lucide="rotate-ccw" class="w-4 h-4 mr-2 inline"></i>
Retake Quiz
</button>
<button id="close-modal-btn" class="flex-1 bg-surface-200 hover:bg-surface-300 dark:bg-surface-600 dark:hover:bg-surface-500 text-surface-800 dark:text-surface-200 py-2.5 px-4 rounded-lg font-medium transition-colors text-sm md:text-base">
<i data-lucide="check" class="w-4 h-4 mr-2 inline"></i>
Done
</button>
</div>
`;
}
// Re-initialize icons and bind events
this.reinitializeIcons();
this.bindModalButtons();
}
/**
* Generates a summary of quiz results based on the current application state.
*
* Iterates through each question in the current quiz, compares the user's answer to the correct answer,
* and constructs a result object for each question including the question text, user's answer, correct answer,
* correctness, and an optional explanation. Calculates the total score as the number of correct answers.
*
* @param state - The current application state containing quiz and user answer data.
* @returns An object containing the user's score, total number of questions, and detailed results for each question,
* or `null` if there is no current quiz.
*/
private createResultFromState(state: AppState): any {
if (!state.currentQuiz) return null;
const results = state.currentQuiz.questions.map(question => {
const userAnswer = state.userAnswers[question.id] || '';
const isCorrect = userAnswer === question.correctAnswer;
return {
question: question.question,
userAnswer: userAnswer,
correctAnswer: question.correctAnswer,
isCorrect: isCorrect,
explanation: question.explanation, // Include explanation if available
};
});
const score = results.filter(r => r.isCorrect).length;
return {
score: score,
totalQuestions: results.length,
results: results,
};
}
private bindModalButtons(): void {
// Bind buttons in both containers (result-container and result-actions)
const retakeBtn = this.element.querySelector('#retake-quiz-btn');
const closeBtn = this.element.querySelector('#close-modal-btn');
retakeBtn?.addEventListener('click', () => {
this.store.hideResults();
// Reset answers to retake quiz
const state = this.store.getState();
if (state.currentQuiz) {
state.currentQuiz.questions.forEach(q => {
this.store.updateAnswer(q.id, '');
});
}
});
closeBtn?.addEventListener('click', () => {
this.store.hideResults();
});
}
show(): void {
this.element.classList.remove('hidden');
this.element.classList.add('flex');
// Trigger mobile slide-up animation
requestAnimationFrame(() => {
const modalContent = this.element.querySelector('.modal-content') as HTMLElement;
if (modalContent) {
modalContent.classList.remove('translate-y-full');
// Reset opacity and transform for proper display
modalContent.style.opacity = '1';
modalContent.style.transform = 'scale(1)';
modalContent.classList.add('scale-100');
}
});
}
/**
* Hides the modal with an animation based on the device type.
*
* - On mobile devices (window width < 768px), slides the modal content down.
* - On desktop devices, fades and scales down the modal content.
* - After the animation (300ms), hides the modal element and resets styles.
* - If modal content is not found, hides the modal immediately without animation.
*/
hide(): void {
const modalContent = this.element.querySelector('.modal-content') as HTMLElement;
if (modalContent) {
// Mobile: slide down, Desktop: fade out
if (window.innerWidth < 768) {
modalContent.classList.add('translate-y-full');
} else {
modalContent.style.transform = 'scale(0.95)';
modalContent.style.opacity = '0';
}
// Wait for animation to complete
setTimeout(() => {
this.element.classList.add('hidden');
this.element.classList.remove('flex');
// Reset transforms
modalContent.classList.remove('translate-y-full');
modalContent.style.transform = '';
modalContent.style.opacity = '';
}, 300);
} else {
// Fallback without animation
this.element.classList.add('hidden');
this.element.classList.remove('flex');
}
}
showResult(result: any): void {
this.currentResult = result;
this.show();
this.render();
}
private reinitializeIcons(): void {
if ((window as any).lucide?.createIcons) {
(window as any).lucide.createIcons();
}
}
}
export default ResultsModal;