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.

342 lines (303 loc) • 14.3 kB
/** * @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;