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