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