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.

494 lines (432 loc) 19.5 kB
/** * @moduleName: Quiz Start Modal Component - Immersive Quiz Introduction * @version: 2.0.0 * @since: 2025-07-25 * @lastUpdated: 2025-07-28 * @projectSummary: Enhanced MCP Quiz Server - Modal component providing immersive quiz introduction experience with context setting * @techStack: TypeScript, Modal UI, DOM Manipulation, Event Handling * @dependency: Quiz interface, DOMUtils for DOM manipulation * @interModuleDependency: Quiz type definitions, utility functions, modal management * @requirementsTraceability: * {@link Requirements.REQ_UI_001} (Quiz Navigation System) * {@link Requirements.REQ_UI_012} (Auto-Start Timer System) * {@link Requirements.REQ_TIMER_001} (Timer Logic) * {@link Requirements.REQ_TIMER_002} (UI Integration) * {@link Requirements.REQ_UX_002} (Timer Experience) * {@link Requirements.REQ_INTEGRATION_001} (Quiz Flow Management) * @briefDescription: Modal component creating anticipation and context before starting quizzes with immersive introduction experience * @methods: show, hide, bindEvents, createModalContent, handleStart, handleCancel * @contributors: GitHub Copilot, UX Enhancement Team * @examples: * - const startModal = new QuizStartModal({ quiz, onStart, onCancel }) * - startModal.show() // Display quiz introduction modal * @vulnerabilitiesAssessment: Modal overlay security, event cleanup prevention, XSS prevention in quiz content display */ import { SettingsService } from '../services/SettingsService'; import { Quiz } from '../types/index'; import { DOMUtils } from '../utils/index'; export interface QuizStartModalOptions { quiz: Quiz; onStart: (quiz: Quiz) => void; onCancel: () => void; } /** * Immersive modal for quiz introduction and mood setting * Creates anticipation and context before starting a quiz */ export class QuizStartModal { private quiz: Quiz; private onStart: (quiz: Quiz) => void; private onCancel: () => void; private startButton: HTMLButtonElement | null = null; private cancelButton: HTMLButtonElement | null = null; private element: HTMLElement | null = null; private settingsService: SettingsService; constructor(options: QuizStartModalOptions) { this.quiz = options.quiz; this.onStart = options.onStart; this.onCancel = options.onCancel; this.settingsService = SettingsService.getInstance(); } /** * Create and show the modal */ static show(options: QuizStartModalOptions): QuizStartModal { const modal = new QuizStartModal(options); modal.mount(); modal.appear(); return modal; } /** * Mount the modal to the DOM */ mount(): void { if (this.element) return; // Already mounted // Create modal element this.element = document.createElement('div'); this.element.id = 'quiz-start-overlay'; // 🔧 FIX: Unique ID for start modal this.element.style.display = 'none'; // Start hidden this.render(); this.bindEvents(); // Append to body document.body.appendChild(this.element); } /** * Remove modal from DOM */ unmount(): void { if (this.element && this.element.parentNode) { document.removeEventListener('keydown', this.handleEscapeKey); this.element.parentNode.removeChild(this.element); this.element = null; } } private render(): void { const estimatedTime = this.calculateEstimatedTime(); const categoryColor = this.getCategoryColor(this.quiz.category); const difficultyInfo = this.getDifficultyInfo(this.quiz.difficulty); if (!this.element) return; this.element.innerHTML = ` <div class="quiz-start-overlay fixed inset-0 z-[10000] flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm opacity-0 transition-all duration-300" id="quiz-start-inner-overlay"> <div class="quiz-start-modal bg-white dark:bg-gray-800 rounded-3xl shadow-2xl max-w-lg w-11/12 mx-4 overflow-hidden transform scale-95 transition-all duration-300" id="quiz-start-modal"> <!-- Category Header with Gradient --> <div class="quiz-category-header h-2 ${categoryColor}"></div> <!-- Main Content --> <div class="p-8"> <!-- Quiz Badge --> <div class="flex items-center justify-center mb-6"> <div class="quiz-category-badge inline-flex items-center px-4 py-2 rounded-full text-sm font-medium ${this.getCategoryBadgeStyle(this.quiz.category)}"> <span class="mr-2">${this.getCategoryIcon(this.quiz.category)}</span> ${this.quiz.category || 'General'} </div> </div> <!-- Quiz Title --> <div class="text-center mb-6"> <h2 class="quiz-title text-3xl font-bold text-gray-900 dark:text-white mb-3 leading-tight"> ${DOMUtils.escapeHtml(this.quiz.title)} </h2> ${ this.quiz.description ? ` <p class="quiz-description text-gray-600 dark:text-gray-300 text-lg leading-relaxed"> ${DOMUtils.escapeHtml(this.quiz.description)} </p> ` : '' } </div> <!-- Quiz Metadata --> <div class="quiz-meta-grid grid grid-cols-2 gap-4 mb-8"> <div class="meta-item text-center p-4 bg-gray-50 dark:bg-gray-700 rounded-xl"> <div class="meta-icon text-2xl mb-2">📝</div> <div class="meta-value text-xl font-semibold text-gray-900 dark:text-white"> ${this.quiz.questions?.length || 0} </div> <div class="meta-label text-sm text-gray-600 dark:text-gray-400">Questions</div> </div> <div class="meta-item text-center p-4 bg-gray-50 dark:bg-gray-700 rounded-xl"> <div class="meta-icon text-2xl mb-2">⏱️</div> <div class="meta-value text-xl font-semibold text-gray-900 dark:text-white"> ~${estimatedTime} </div> <div class="meta-label text-sm text-gray-600 dark:text-gray-400">Minutes</div> </div> </div> <!-- Difficulty Indicator --> <div class="difficulty-section mb-8"> <div class="flex items-center justify-center mb-3"> <span class="text-sm font-medium text-gray-600 dark:text-gray-400 mr-3">Difficulty:</span> <div class="difficulty-badge inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${difficultyInfo.style}"> <span class="mr-1">${difficultyInfo.icon}</span> ${this.quiz.difficulty || 'Medium'} </div> </div> <div class="difficulty-meter flex justify-center"> ${this.renderDifficultyMeter()} </div> </div> <!-- Timer Configuration Section --> <div class="timer-config-section p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-t border-b border-blue-200 dark:border-blue-700/50"> <div class="flex items-center justify-between mb-4"> <div class="flex items-center"> <span class="text-lg mr-2">⏱️</span> <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Timer Settings</h3> </div> <div class="text-sm text-gray-500 dark:text-gray-400">Optional</div> </div> <div class="space-y-4"> <!-- Use Timer (Modern Simplified Interface) --> <label class="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> <div class="flex items-center"> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Use Timer</span> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400">Auto-starts with quiz</span> </div> <input type="checkbox" id="quiz-use-timer" class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"> </label> <!-- Timer Duration --> <div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timer Duration</label> <select id="quiz-timer-duration" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-blue-500 focus:border-blue-500"> <option value="300">5 minutes</option> <option value="600">10 minutes</option> <option value="900">15 minutes</option> <option value="1200">20 minutes</option> <option value="1800">30 minutes</option> <option value="3600">1 hour</option> <option value="0">No limit</option> </select> </div> </div> </div> <!-- Call to Action --> <div class="cta-section text-center"> <button id="start-quiz-btn" class="start-quiz-btn w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold py-4 px-8 rounded-xl transition-all duration-300 transform hover:scale-105 hover:shadow-lg mb-4 group"> <span class="flex items-center justify-center"> <span class="btn-icon mr-3 text-xl transition-transform group-hover:scale-110">🚀</span> <span class="btn-text text-lg">Begin Quiz</span> <span class="ml-3 opacity-0 group-hover:opacity-100 transition-opacity">→</span> </span> </button> <button id="cancel-quiz-btn" class="cancel-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium py-2 px-4 rounded-lg transition-colors duration-200"> Maybe Later </button> </div> </div> </div> </div> `; } protected bindEvents(): void { if (!this.element) return; this.startButton = this.element.querySelector('#start-quiz-btn') as HTMLButtonElement; this.cancelButton = this.element.querySelector('#cancel-quiz-btn') as HTMLButtonElement; // Timer configuration elements const useTimerCheckbox = this.element.querySelector('#quiz-use-timer') as HTMLInputElement; const timerDurationSelect = this.element.querySelector( '#quiz-timer-duration' ) as HTMLSelectElement; // Load current timer settings this.loadTimerSettings(useTimerCheckbox, timerDurationSelect); // Use timer checkbox change handler useTimerCheckbox?.addEventListener('change', () => { this.saveTimerSettings(useTimerCheckbox, timerDurationSelect); }); // Timer duration change handler timerDurationSelect?.addEventListener('change', () => { this.saveTimerSettings(useTimerCheckbox, timerDurationSelect); }); // Start quiz button this.startButton?.addEventListener('click', () => { this.triggerStartAnimation(); setTimeout(() => { this.onStart(this.quiz); this.hide(); }, 600); }); // Cancel button this.cancelButton?.addEventListener('click', () => { this.hide(); this.onCancel(); }); // Escape key to close document.addEventListener('keydown', this.handleEscapeKey.bind(this)); // Click outside to close const overlay = this.element?.querySelector('#quiz-start-inner-overlay'); overlay?.addEventListener('click', e => { if (e.target === overlay) { this.hide(); this.onCancel(); } }); } private handleEscapeKey(event: KeyboardEvent): void { if (event.key === 'Escape') { this.hide(); this.onCancel(); } } private calculateEstimatedTime(): number { const questionCount = this.quiz.questions?.length || 0; return Math.max(1, Math.ceil(questionCount * 1.5)); // ~1.5 minutes per question } private getCategoryColor(category?: string): string { const colors: Record<string, string> = { Technology: 'bg-gradient-to-r from-blue-500 to-cyan-500', Science: 'bg-gradient-to-r from-green-500 to-emerald-500', History: 'bg-gradient-to-r from-amber-500 to-orange-500', Arts: 'bg-gradient-to-r from-purple-500 to-pink-500', Sports: 'bg-gradient-to-r from-red-500 to-rose-500', General: 'bg-gradient-to-r from-gray-500 to-slate-500', }; return colors[category || 'General'] || colors['General']; } private getCategoryBadgeStyle(category?: string): string { const styles: Record<string, string> = { Technology: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', Science: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', History: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', Arts: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', Sports: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', General: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200', }; return styles[category || 'General'] || styles['General']; } private getCategoryIcon(category?: string): string { const icons: Record<string, string> = { Technology: '💻', Science: '🔬', History: '📚', Arts: '🎨', Sports: '⚽', General: '🧠', }; return icons[category || 'General'] || icons['General']; } private getDifficultyInfo(difficulty?: string): { icon: string; style: string } { const info: Record<string, { icon: string; style: string }> = { Beginner: { icon: '🌟', style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', }, Easy: { icon: '✨', style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', }, Medium: { icon: '🔥', style: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', }, Hard: { icon: '⚡', style: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', }, Expert: { icon: '💎', style: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', }, }; return info[difficulty || 'Medium'] || info['Medium']; } private renderDifficultyMeter(): string { const difficulty = this.quiz.difficulty || 'Medium'; const levels = ['Beginner', 'Easy', 'Medium', 'Hard', 'Expert']; const currentLevel = levels.indexOf(difficulty); return levels .map((level, index) => { const isActive = index <= currentLevel; const isCurrentLevel = index === currentLevel; return ` <div class="meter-dot w-3 h-3 rounded-full mx-1 transition-all duration-300 ${ isActive ? isCurrentLevel ? 'bg-blue-500 scale-125 shadow-lg' : 'bg-blue-400' : 'bg-gray-300 dark:bg-gray-600' }"></div> `; }) .join(''); } private triggerStartAnimation(): void { if (this.startButton) { this.startButton.classList.add('animate-pulse', 'scale-95'); this.startButton.innerHTML = ` <span class="flex items-center justify-center"> <div class="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent mr-3"></div> <span>Starting Quiz...</span> </span> `; } } /** * Show modal with entrance animation */ private appear(): void { if (!this.element) return; // Make the main container visible and apply proper styles this.element.style.display = 'block'; this.element.style.position = 'fixed'; this.element.style.inset = '0'; this.element.style.zIndex = '10000'; this.element.style.visibility = 'visible'; this.element.style.opacity = '1'; requestAnimationFrame(() => { const overlay = this.element?.querySelector('#quiz-start-inner-overlay') as HTMLElement; const modal = this.element?.querySelector('#quiz-start-modal') as HTMLElement; if (overlay && modal) { overlay.classList.remove('opacity-0'); modal.classList.remove('scale-95'); modal.classList.add('scale-100'); } }); } /** * Hide modal with exit animation */ hide(): void { if (!this.element) return; const overlay = this.element.querySelector('#quiz-start-inner-overlay') as HTMLElement; const modal = this.element.querySelector('#quiz-start-modal') as HTMLElement; if (overlay && modal) { overlay.classList.add('opacity-0'); modal.classList.add('scale-95'); modal.classList.remove('scale-100'); setTimeout(() => { this.unmount(); }, 300); } } protected onUnmount(): void { document.removeEventListener('keydown', this.handleEscapeKey); } /** * Load current timer settings from SettingsService (MODERNIZED) */ private loadTimerSettings( useTimerCheckbox: HTMLInputElement, timerDurationSelect: HTMLSelectElement ): void { try { const settings = this.settingsService.getSettings(); // Set timer enabled state if (useTimerCheckbox) { useTimerCheckbox.checked = settings.quiz?.useTimer || false; } // Set timer duration from settings if (timerDurationSelect) { timerDurationSelect.value = settings.quiz?.timerDuration?.toString() || '900'; } } catch (error) { console.warn('Failed to load timer settings:', error); } } /** * Save timer settings to SettingsService (MODERNIZED) */ private saveTimerSettings( useTimerCheckbox: HTMLInputElement, timerDurationSelect: HTMLSelectElement ): void { try { const useTimer = useTimerCheckbox?.checked || false; const timerDuration = parseInt(timerDurationSelect?.value || '900'); // Update quiz settings with modern single-timer approach this.settingsService.updateQuizSettings({ useTimer, timerDuration, }); console.log('💾 Timer settings saved:', { useTimer, timerDuration, }); // Dispatch event for other components to react window.dispatchEvent( new CustomEvent('timerSettingsChanged', { detail: { useTimer, timerDuration }, }) ); } catch (error) { console.error('Failed to save timer settings:', error); } } }