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