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.

310 lines (269 loc) • 11.9 kB
/** * @moduleName: Quiz List Component - Sidebar Quiz Navigation * @version: 2.0.0 * @since: 2025-07-23 * @lastUpdated: 2025-07-24 * @projectSummary: MCP Quiz Server - Quiz list sidebar component with search, filtering, and quiz selection functionality * @techStack: TypeScript, Component Architecture, DOM API, State Management * @dependency: Component base class, AppStore, DOM utilities * @interModuleDependency: Component, AppStore, QuizUtils, DOMUtils, Quiz types * @requirementsTraceability: * {@link Requirements.REQ_UI_001} (Sophisticated Sidebar Navigation System) * @briefDescription: Interactive sidebar component displaying available quizzes with search functionality, category filtering, difficulty sorting, and quiz selection handling * @methods: render, bindEvents, onStateChange, handleSearch, handleFilter, selectQuiz * @contributors: Architecture Team, GitHub Copilot * @examples: const quizList = new QuizList(); quizList.mount(); * @vulnerabilitiesAssessment: Input sanitization for search queries, XSS prevention in quiz list rendering, proper event cleanup prevents memory leaks */ import { QuizService } from '../services/QuizService'; import { AppStore } from '../store/AppStore'; import { AppState, FilterType, Quiz } from '../types/index'; import { DOMUtils, QuizUtils } from '../utils/index'; import { Component } from './Component'; import { QuizStartModal } from './QuizStartModal'; export class QuizList extends Component { private store: AppStore; private quizService: QuizService; private searchInput: HTMLInputElement; private filterTabs: NodeListOf<HTMLElement>; private unsubscribe: (() => void) | null = null; constructor() { super('#quiz-list'); this.store = AppStore.getInstance(); this.quizService = QuizService.getInstance(); this.searchInput = document.querySelector('#search-input') as HTMLInputElement; this.filterTabs = document.querySelectorAll('.filter-tab'); } protected onMount(): void { this.unsubscribe = this.store.subscribe(state => this.onStateChange(state)); } protected onUnmount(): void { this.unsubscribe?.(); } protected bindEvents(): void { this.searchInput?.addEventListener('input', this.handleSearch.bind(this)); this.filterTabs.forEach(tab => { tab.addEventListener('click', () => { const filter = (tab.dataset.filter as FilterType) || 'all'; this.store.setFilter(filter); }); }); // Bind refresh button const refreshBtn = document.getElementById('refresh-quizzes-btn'); refreshBtn?.addEventListener('click', this.handleRefresh.bind(this)); } private handleSearch(event: Event): void { const target = event.target as HTMLInputElement; this.store.setSearchTerm(target.value); } private async handleRefresh(): Promise<void> { try { const refreshBtn = document.getElementById('refresh-quizzes-btn'); const refreshIcon = refreshBtn?.querySelector('[data-lucide="refresh-cw"]'); // Add spinning animation refreshIcon?.classList.add('animate-spin'); refreshBtn?.setAttribute('disabled', 'true'); // Reload quizzes await this.store.loadQuizzes(); // Show success feedback DOMUtils.showToast('Quiz list refreshed', 'success'); } catch (error) { console.error('Error refreshing quizzes:', error); DOMUtils.showToast('Failed to refresh quiz list', 'error'); } finally { // Remove spinning animation const refreshBtn = document.getElementById('refresh-quizzes-btn'); const refreshIcon = refreshBtn?.querySelector('[data-lucide="refresh-cw"]'); refreshIcon?.classList.remove('animate-spin'); refreshBtn?.removeAttribute('disabled'); } } private onStateChange(state: AppState): void { this.render(); this.updateFilterTabs(state.currentFilter); } protected render(): void { const state = this.store.getState(); if (state.ui.loading) { this.renderLoading(); return; } if (state.filteredQuizzes.length === 0) { this.renderEmpty(); return; } this.renderQuizzes(state.filteredQuizzes, state.currentQuiz); } private renderLoading(): void { this.updateElement({ innerHTML: ` <div class="text-center text-surface-500 dark:text-surface-400 py-8"> <div class="animate-spin w-8 h-8 mx-auto mb-2 border-2 border-blue-500 border-t-transparent rounded-full"></div> <p>Loading quizzes...</p> </div> `, }); } private renderEmpty(): void { this.updateElement({ innerHTML: ` <div class="text-center text-surface-500 dark:text-surface-400 py-8"> <i data-lucide="search-x" class="w-8 h-8 mx-auto mb-2"></i> <p>No quizzes found</p> </div> `, }); this.reinitializeIcons(); } private renderQuizzes(quizzes: Quiz[], currentQuiz: Quiz | null): void { const quizCards = quizzes.map(quiz => this.createQuizCard(quiz, currentQuiz?.id === quiz.id)); this.element.innerHTML = ''; quizCards.forEach(card => this.element.appendChild(card)); this.reinitializeIcons(); } private createQuizCard(quiz: Quiz, isActive: boolean): HTMLElement { const questionCount = quiz.questions?.length || 0; const estimatedTime = QuizUtils.calculateEstimatedTime(questionCount); const isFavorite = this.store.isFavorite(quiz.id); const isCompleted = this.store.isQuizCompleted(quiz.id); const progress = this.store.getQuizProgress(quiz.id); // Determine card styling based on state let cardClassName = 'quiz-card p-4 rounded-lg cursor-pointer transition-all duration-200 border '; if (isActive) { cardClassName += 'bg-blue-100 border-blue-400 dark:bg-blue-900/20 dark:border-blue-500'; } else if (isCompleted) { cardClassName += 'bg-green-50 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:border-green-700 dark:hover:bg-green-900/20'; } else if (progress?.status === 'in-progress') { cardClassName += 'bg-yellow-50 border-yellow-200 hover:bg-yellow-100 dark:bg-yellow-900/10 dark:border-yellow-700 dark:hover:bg-yellow-900/20'; } else { cardClassName += 'bg-gray-50 border-gray-200 hover:bg-gray-100 dark:bg-surface-800 dark:border-surface-600 dark:hover:bg-surface-700'; } const card = DOMUtils.createElement('div', { className: cardClassName, dataset: { quizId: quiz.id }, }); card.innerHTML = ` <div class="flex items-start justify-between mb-2"> <h3 class="font-medium text-surface-900 dark:text-surface-100 text-sm leading-tight flex-1 mr-2"> ${DOMUtils.escapeHtml(quiz.title)} </h3> <div class="flex items-center space-x-2"> ${this.getStatusIndicator(isCompleted, progress)} <button class="favorite-btn p-1 rounded-full hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors" data-quiz-id="${quiz.id}"> <i data-lucide="${isFavorite ? 'star' : 'star'}" class="w-3 h-3 ${isFavorite ? 'text-yellow-500 fill-current' : 'text-surface-400'}"></i> </button> <span class="text-xs text-surface-500 dark:text-surface-400 whitespace-nowrap"> ${questionCount}Q </span> </div> </div> ${ quiz.description ? ` <p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2"> ${DOMUtils.escapeHtml(quiz.description)} </p> ` : '' } <div class="flex items-center justify-between text-xs text-surface-500 dark:text-surface-400"> <span class="flex items-center"> <i data-lucide="clock" class="w-3 h-3 mr-1"></i> ${estimatedTime}m </span> ${ quiz.category ? ` <span class="bg-surface-200 dark:bg-surface-600 px-2 py-1 rounded-full text-xs text-surface-700 dark:text-surface-300"> ${DOMUtils.escapeHtml(quiz.category)} </span> ` : '' } </div> `; // Add click event for quiz selection card.addEventListener('click', e => { // Don't trigger quiz selection if clicking the favorite button if ((e.target as HTMLElement).closest('.favorite-btn')) { return; } // Show quiz start modal instead of directly selecting this.showQuizStartModal(quiz); }); // Add click event for favorite button const favoriteBtn = card.querySelector('.favorite-btn'); favoriteBtn?.addEventListener('click', e => { e.stopPropagation(); this.store.toggleFavorite(quiz.id); // Add favorite animation favoriteBtn.classList.add('favorited'); setTimeout(() => { favoriteBtn.classList.remove('favorited'); }, 400); this.render(); // Re-render to update star state }); return card; } private updateFilterTabs(currentFilter: string): void { this.filterTabs.forEach(tab => { const isActive = tab.dataset.filter === currentFilter; tab.classList.toggle('active', isActive); tab.classList.toggle('bg-blue-100', isActive); tab.classList.toggle('text-blue-700', isActive); tab.classList.toggle('text-gray-600', !isActive); }); } /** * Show quiz start modal for immersive quiz introduction */ private async showQuizStartModal(quiz: Quiz): Promise<void> { try { // Fetch the full quiz data including questions before showing modal const fullQuiz = await this.quizService.getQuizById(quiz.id); QuizStartModal.show({ quiz: fullQuiz, onStart: (selectedQuiz: Quiz) => { this.store.startQuiz(selectedQuiz); }, onCancel: () => { // Modal closed without starting quiz }, }); } catch (error) { console.error('Failed to load quiz details:', error); DOMUtils.showToast('Failed to load quiz details', 'error'); } } /** * Get status indicator HTML for a quiz */ private getStatusIndicator(isCompleted: boolean, progress: any): string { if (isCompleted) { const score = progress?.score || 0; return `<div class="flex items-center text-green-600 dark:text-green-400" title="Completed with ${score}% score"> <i data-lucide="check-circle" class="w-3 h-3"></i> </div>`; } if (progress?.status === 'in-progress') { const currentQ = progress.currentQuestion || 0; const total = progress.answers ? Object.keys(progress.answers).length : 0; return `<div class="flex items-center text-yellow-600 dark:text-yellow-400" title="In progress: ${total} questions answered"> <i data-lucide="play-circle" class="w-3 h-3"></i> </div>`; } return `<div class="flex items-center text-surface-400 dark:text-surface-500" title="Available to start"> <i data-lucide="circle" class="w-3 h-3"></i> </div>`; } private reinitializeIcons(): void { // Re-initialize Lucide icons if available if ((window as any).lucide?.createIcons) { (window as any).lucide.createIcons(); } } }