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.

332 lines (287 loc) • 10.9 kB
/** * @moduleName: Tour Modal Component - Interactive User Onboarding * @version: 1.0.0 * @since: 2025-07-24 * @lastUpdated: 2025-07-24 * @projectSummary: MCP Quiz Server - Modal component for displaying interactive tour steps with navigation, videos, and progress tracking * @techStack: TypeScript, Component Architecture, DOM API, HTML5 Video * @dependency: Component base class, TourService, TourStep and TourState interfaces * @interModuleDependency: Component, TourService, tour state management * @requirementsTraceability: * {@link Requirements.REQ_UI_005} (Interactive Tutorial & Onboarding System) * @briefDescription: Modal component that displays tour steps with content, videos, navigation buttons, and progress indicators * @methods: showStep, hide, render, bindEvents, updateProgress, handleVideoPlay * @contributors: Full Stack Wizard Mega Architect, GitHub Copilot * @examples: const tourModal = new TourModal(); tourModal.showStep(step, state); * @vulnerabilitiesAssessment: Video URL validation, DOM element sanitization, keyboard navigation support, escape key handling */ import { TourService, TourState, TourStep } from '../services/TourService'; import { DOMUtils } from '../utils/index'; import { Component } from './Component'; export class TourModal extends Component { private tourService: TourService; private currentStep: TourStep | null = null; private currentState: TourState | null = null; constructor() { super('#tour-modal'); this.tourService = TourService.getInstance(); this.tourService.setTourModal(this); } protected render(): void { // Modal is created in HTML, we just manage its content } protected bindEvents(): void { // Close modal on backdrop click this.element.addEventListener('click', e => { if (e.target === this.element) { this.tourService.skipTour(); } }); // Navigation buttons const prevBtn = this.element.querySelector('#tour-prev-btn'); const nextBtn = this.element.querySelector('#tour-next-btn'); const skipBtn = this.element.querySelector('#tour-skip-btn'); const closeBtn = this.element.querySelector('#tour-close-btn'); prevBtn?.addEventListener('click', () => { this.tourService.previousStep(); }); nextBtn?.addEventListener('click', () => { this.tourService.nextStep(); }); skipBtn?.addEventListener('click', () => { this.tourService.skipTour(); }); closeBtn?.addEventListener('click', () => { this.tourService.skipTour(); }); // Keyboard navigation document.addEventListener('keydown', this.handleKeydown.bind(this)); // Video event handling const video = this.element.querySelector('#tour-video') as HTMLVideoElement; if (video) { video.addEventListener('ended', () => { // Auto-advance to next step when video ends (if enabled) const autoAdvance = this.element.querySelector('#auto-advance-toggle') as HTMLInputElement; if (autoAdvance?.checked && this.currentState?.canGoNext) { setTimeout(() => this.tourService.nextStep(), 1000); } }); } } /** * @description Handles keyboard navigation for tour * @param {KeyboardEvent} e Keyboard event */ private handleKeydown(e: KeyboardEvent): void { if (!this.currentState?.isActive) return; switch (e.key) { case 'Escape': this.tourService.skipTour(); break; case 'ArrowLeft': if (this.currentState.canGoBack) { e.preventDefault(); this.tourService.previousStep(); } break; case 'ArrowRight': case 'Enter': case ' ': if (this.currentState.canGoNext) { e.preventDefault(); this.tourService.nextStep(); } else if ( !this.currentState.canGoNext && this.currentState.currentStep === this.currentState.totalSteps ) { e.preventDefault(); this.tourService.completeTour(); } break; } } /** * @description Shows a tour step in the modal * @param {TourStep} step Tour step to display * @param {TourState} state Current tour state */ public showStep(step: TourStep, state: TourState): void { this.currentStep = step; this.currentState = state; // Update modal content this.updateContent(step); this.updateNavigation(state); this.updateProgress(state); // Show modal this.show(); // // Handle video if present // if (step.video) { // this.loadVideo(step.video); // } // Focus management for accessibility const nextBtn = this.element.querySelector('#tour-next-btn') as HTMLButtonElement; const skipBtn = this.element.querySelector('#tour-skip-btn') as HTMLButtonElement; (nextBtn || skipBtn)?.focus(); } /** * @description Updates the modal content with step information * @param {TourStep} step Tour step data */ private updateContent(step: TourStep): void { // Update title const title = this.element.querySelector('#tour-title'); if (title) { title.textContent = step.title; } // Update content const content = this.element.querySelector('#tour-content'); if (content) { content.innerHTML = DOMUtils.escapeHtml(step.content); } // Show/hide video section const videoSection = this.element.querySelector('#tour-video-section'); const videoElement = this.element.querySelector('#tour-video') as HTMLVideoElement; if (step.video && videoSection) { videoSection.classList.remove('hidden'); if (videoElement) { videoElement.src = step.video; videoElement.load(); } } else if (videoSection) { videoSection.classList.add('hidden'); } } /** * @description Updates navigation button states * @param {TourState} state Current tour state */ private updateNavigation(state: TourState): void { const prevBtn = this.element.querySelector('#tour-prev-btn') as HTMLButtonElement; const nextBtn = this.element.querySelector('#tour-next-btn') as HTMLButtonElement; const skipBtn = this.element.querySelector('#tour-skip-btn') as HTMLButtonElement; if (prevBtn) { prevBtn.disabled = !state.canGoBack; prevBtn.classList.toggle('opacity-50', !state.canGoBack); prevBtn.classList.toggle('cursor-not-allowed', !state.canGoBack); } if (nextBtn) { nextBtn.disabled = !state.canGoNext; nextBtn.classList.toggle('opacity-50', !state.canGoNext); nextBtn.classList.toggle('cursor-not-allowed', !state.canGoNext); // Update next button text for last step if (state.currentStep === state.totalSteps) { nextBtn.textContent = 'Finish'; nextBtn.classList.add('bg-green-600', 'hover:bg-green-700'); nextBtn.classList.remove('bg-primary-600', 'hover:bg-primary-700'); } else { nextBtn.textContent = 'Next'; nextBtn.classList.remove('bg-green-600', 'hover:bg-green-700'); nextBtn.classList.add('bg-primary-600', 'hover:bg-primary-700'); } } if (skipBtn) { skipBtn.classList.toggle('hidden', !state.canSkip); } } /** * @description Updates the progress indicator * @param {TourState} state Current tour state */ private updateProgress(state: TourState): void { // Update step counter const stepCounter = this.element.querySelector('#tour-step-counter'); if (stepCounter) { stepCounter.textContent = `${state.currentStep} of ${state.totalSteps}`; } // Update progress bar const progressBar = this.element.querySelector('#tour-progress-bar') as HTMLElement; if (progressBar) { const percentage = (state.currentStep / state.totalSteps) * 100; progressBar.style.width = `${percentage}%`; } // Update progress dots const dots = this.element.querySelectorAll('.tour-progress-dot'); dots.forEach((dot, index) => { if (index < state.currentStep) { dot.classList.add('bg-primary-600'); dot.classList.remove('bg-surface-300'); } else { dot.classList.remove('bg-primary-600'); dot.classList.add('bg-surface-300'); } }); } /** * @description Loads and prepares video for playback * @param {string} videoUrl URL of the video to load */ private loadVideo(videoUrl: string): void { const video = this.element.querySelector('#tour-video') as HTMLVideoElement; if (!video) return; video.src = videoUrl; video.load(); // Add play button overlay const playButton = this.element.querySelector('#video-play-btn'); if (playButton) { playButton.classList.remove('hidden'); playButton.addEventListener('click', () => { video.play(); playButton.classList.add('hidden'); }); } // Show video controls on first interaction video.addEventListener('play', () => { video.controls = true; }); } /** * @description Shows the tour modal */ public show(): void { this.element.classList.remove('hidden'); this.element.classList.add('flex'); // Add show animation setTimeout(() => { const content = this.element.querySelector('.modal-content'); content?.classList.remove('translate-y-full', 'md:translate-y-0'); content?.classList.add('translate-y-0'); }, 10); // Prevent body scroll document.body.style.overflow = 'hidden'; } /** * @description Hides the tour modal */ public hide(): void { const content = this.element.querySelector('.modal-content'); content?.classList.add('translate-y-full', 'md:translate-y-0'); content?.classList.remove('translate-y-0'); setTimeout(() => { this.element.classList.add('hidden'); this.element.classList.remove('flex'); // Restore body scroll document.body.style.overflow = ''; // Reset video const video = this.element.querySelector('#tour-video') as HTMLVideoElement; if (video) { video.pause(); video.currentTime = 0; video.removeAttribute('src'); } }, 300); } /** * @description Gets the current step being displayed * @returns {TourStep | null} Current step or null */ public getCurrentStep(): TourStep | null { return this.currentStep; } /** * @description Gets the current tour state * @returns {TourState | null} Current state or null */ public getCurrentState(): TourState | null { return this.currentState; } }