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.

571 lines (524 loc) • 19 kB
/** * @fileoverview Dashboard Component - Modern MVVM Architecture * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module Dashboard * @description Main dashboard component with progressive enhancement and unified auth * @contributors Claude Code Agent * @dependencies Component base class, AuthManager, DashboardService * @requirements REQ-UI-001, REQ-AUTH-002 (Unified Dashboard) * @testCoverage Dashboard rendering, authentication integration, data loading */ import { DashboardService } from '../services/DashboardService'; import { AppStore } from '../store/AppStore'; import { AuthManager } from './AuthManager'; import { Component } from './Component'; /** * Dashboard Component * * @description Main dashboard component with progressive enhancement. * Replaces server-generated HTML with modern TypeScript architecture. * * @example * ```typescript * const dashboard = new Dashboard(); * dashboard.mount(); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-UI-001 (Modern dashboard), REQ-AUTH-002 (Integrated auth) */ export class Dashboard extends Component { private authManager: AuthManager; private dashboardService: DashboardService; private appStore: AppStore; private unsubscribeStore: (() => void) | null = null; // Component state private isLoading = true; private dashboardData: any = null; private error: string | null = null; constructor() { // Mount to existing dashboard container or create new one if (!document.getElementById('dashboard-app')) { const container = document.createElement('div'); container.id = 'dashboard-app'; container.className = 'min-h-screen bg-gray-50'; document.body.appendChild(container); } super('#dashboard-app'); this.authManager = AuthManager.getInstance(); this.dashboardService = new DashboardService(); this.appStore = AppStore.getInstance(); } /** * Initialize dashboard component * * @description Sets up authentication and loads dashboard data * * @since 2025-08-04 * @author Claude Code Agent */ protected async onMount(): Promise<void> { this.setupStoreSubscription(); this.render(); // Initial render with loading state try { // Ensure authentication if (!this.authManager.isAuthenticated()) { const authenticated = await this.authManager.requireAuthentication(); if (!authenticated) { this.redirectToLogin(); return; } } // Load dashboard data await this.loadDashboardData(); } catch (error) { console.error('Dashboard initialization failed:', error); this.error = 'Failed to load dashboard. Please try again.'; this.isLoading = false; this.render(); } } /** * Render dashboard UI * * @description Renders dashboard based on current state * * @since 2025-08-04 * @author Claude Code Agent */ protected render(): void { if (this.isLoading) { this.renderLoading(); } else if (this.error) { this.renderError(); } else { this.renderDashboard(); } } /** * Bind dashboard event listeners * * @description Sets up event listeners for dashboard interactions * * @since 2025-08-04 * @author Claude Code Agent */ protected bindEvents(): void { // Create Quiz button const createQuizBtn = this.element.querySelector('.create-quiz-btn'); createQuizBtn?.addEventListener('click', this.handleCreateQuiz.bind(this)); // Refresh button const refreshBtn = this.element.querySelector('.refresh-btn'); refreshBtn?.addEventListener('click', this.handleRefresh.bind(this)); // Quiz list interactions this.bindQuizListEvents(); } /** * Render loading state * * @description Shows loading spinner and message * * @since 2025-08-04 * @author Claude Code Agent */ private renderLoading(): void { this.element.innerHTML = ` <div class="dashboard-loading"> <div class="min-h-screen flex items-center justify-center"> <div class="text-center"> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> <h2 class="text-lg font-medium text-gray-900 mb-2">Loading Dashboard</h2> <p class="text-gray-600">Please wait while we load your information...</p> </div> </div> </div> `; } /** * Render error state * * @description Shows error message with retry option * * @since 2025-08-04 * @author Claude Code Agent */ private renderError(): void { this.element.innerHTML = ` <div class="dashboard-error"> <div class="min-h-screen flex items-center justify-center"> <div class="text-center max-w-md mx-auto"> <div class="text-red-500 mb-4"> <svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> </svg> </div> <h2 class="text-xl font-semibold text-gray-900 mb-2">Dashboard Error</h2> <p class="text-gray-600 mb-6">${this.error}</p> <button class="retry-btn bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> Try Again </button> </div> </div> </div> `; // Bind retry button const retryBtn = this.element.querySelector('.retry-btn'); retryBtn?.addEventListener('click', this.handleRetry.bind(this)); } /** * Render main dashboard * * @description Renders the complete dashboard interface * * @since 2025-08-04 * @author Claude Code Agent */ private renderDashboard(): void { const user = this.authManager.getCurrentUser(); const stats = this.dashboardData?.stats || {}; const recentQuizzes = this.dashboardData?.recentQuizzes || []; this.element.innerHTML = ` <div class="dashboard"> <!-- Navigation --> <nav class="bg-white shadow-sm border-b"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-16"> <div class="flex items-center"> <h1 class="text-xl font-semibold text-gray-900">Quiz Dashboard</h1> </div> <div class="auth-container"></div> </div> </div> </nav> <!-- Main Content --> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <!-- Welcome Section --> <div class="mb-8"> <h2 class="text-2xl font-bold text-gray-900 mb-2"> Welcome back, ${user?.username || 'User'}! </h2> <p class="text-gray-600">Manage your quizzes and track your progress</p> </div> <!-- Quick Actions --> <div class="mb-8"> <div class="flex flex-wrap gap-4"> <button class="create-quiz-btn bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center"> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> </svg> Create New Quiz </button> <button class="refresh-btn bg-gray-100 hover:bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 flex items-center"> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> </svg> Refresh </button> </div> </div> <!-- Dashboard Cards --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <!-- My Quizzes Card --> <div class="card bg-white rounded-lg shadow p-6"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-medium text-gray-900">My Quizzes</h3> <div class="text-3xl font-bold text-blue-600">${stats.totalQuizzes || 0}</div> </div> <p class="text-gray-600 text-sm mb-4">Total quizzes created</p> <a href="/dashboard/quizzes" class="text-blue-600 hover:text-blue-700 text-sm font-medium"> View all → </a> </div> <!-- Recent Activity Card --> <div class="card bg-white rounded-lg shadow p-6"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-medium text-gray-900">Recent Activity</h3> <div class="text-3xl font-bold text-green-600">${stats.recentActivity || 0}</div> </div> <p class="text-gray-600 text-sm mb-4">Actions this week</p> <a href="/dashboard/analytics" class="text-green-600 hover:text-green-700 text-sm font-medium"> View analytics → </a> </div> <!-- Profile Card --> <div class="card bg-white rounded-lg shadow p-6"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-medium text-gray-900">Profile</h3> <div class="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center"> <span class="text-white font-medium">${this.getUserInitials(user?.username)}</span> </div> </div> <p class="text-gray-600 text-sm mb-4">${user?.email || 'No email'}</p> <a href="/dashboard/profile" class="text-blue-600 hover:text-blue-700 text-sm font-medium"> Manage profile → </a> </div> </div> <!-- Recent Quizzes --> <div class="bg-white rounded-lg shadow"> <div class="px-6 py-4 border-b border-gray-200"> <h3 class="text-lg font-medium text-gray-900">Recent Quizzes</h3> </div> <div class="quiz-list"> ${this.renderQuizList(recentQuizzes)} </div> </div> </main> </div> `; // Mount auth manager in navigation const authContainer = this.element.querySelector('.auth-container'); if (authContainer) { this.authManager.mount(); } this.bindEvents(); } /** * Render quiz list * * @description Renders list of recent quizzes * * @param {Array} quizzes - Array of quiz objects * @returns {string} HTML string for quiz list * * @since 2025-08-04 * @author Claude Code Agent */ private renderQuizList(quizzes: any[]): string { if (!quizzes || quizzes.length === 0) { return ` <div class="px-6 py-8 text-center"> <div class="text-gray-400 mb-4"> <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> </svg> </div> <h4 class="text-lg font-medium text-gray-900 mb-2">No quizzes yet</h4> <p class="text-gray-600 mb-4">Get started by creating your first quiz</p> <button class="create-quiz-btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"> Create Quiz </button> </div> `; } return quizzes .map( quiz => ` <div class="quiz-item px-6 py-4 border-b border-gray-200 hover:bg-gray-50 transition-colors"> <div class="flex items-center justify-between"> <div class="flex-1"> <h4 class="text-md font-medium text-gray-900 mb-1">${quiz.title}</h4> <p class="text-sm text-gray-600 mb-2">${quiz.description || 'No description'}</p> <div class="flex items-center text-xs text-gray-500 space-x-4"> <span>${quiz.questions?.length || 0} questions</span> <span>Created ${this.formatDate(quiz.createdAt)}</span> <span class="quiz-status ${quiz.status}">${quiz.status || 'draft'}</span> </div> </div> <div class="flex items-center space-x-2"> <button class="edit-quiz-btn text-blue-600 hover:text-blue-700 text-sm font-medium" data-quiz-id="${quiz.id}"> Edit </button> <button class="view-quiz-btn text-green-600 hover:text-green-700 text-sm font-medium" data-quiz-id="${quiz.id}"> View </button> </div> </div> </div> ` ) .join(''); } /** * Setup app store subscription * * @description Subscribes to app store state changes * * @since 2025-08-04 * @author Claude Code Agent */ private setupStoreSubscription(): void { this.unsubscribeStore = this.appStore.subscribe(state => { // Re-render if auth state changes if (!state.auth.isAuthenticated) { this.redirectToLogin(); } }); } /** * Load dashboard data * * @description Fetches dashboard data from API * * @since 2025-08-04 * @author Claude Code Agent */ private async loadDashboardData(): Promise<void> { try { this.isLoading = true; this.render(); const data = await this.dashboardService.getDashboardData(); this.dashboardData = data; this.error = null; } catch (error) { console.error('Failed to load dashboard data:', error); this.error = 'Failed to load dashboard data. Please try again.'; } finally { this.isLoading = false; this.render(); } } /** * Handle create quiz action * * @description Navigates to quiz creation page * * @since 2025-08-04 * @author Claude Code Agent */ private handleCreateQuiz(): void { // Navigate to quiz creation window.location.href = '/quiz/create'; } /** * Handle refresh action * * @description Reloads dashboard data * * @since 2025-08-04 * @author Claude Code Agent */ private async handleRefresh(): Promise<void> { await this.loadDashboardData(); } /** * Handle retry action * * @description Retries dashboard initialization * * @since 2025-08-04 * @author Claude Code Agent */ private async handleRetry(): Promise<void> { this.error = null; await this.onMount(); } /** * Bind quiz list events * * @description Sets up event listeners for quiz list items * * @since 2025-08-04 * @author Claude Code Agent */ private bindQuizListEvents(): void { // Edit quiz buttons const editButtons = this.element.querySelectorAll('.edit-quiz-btn'); editButtons.forEach(btn => { btn.addEventListener('click', e => { const quizId = (e.target as HTMLElement).dataset.quizId; if (quizId) this.handleEditQuiz(quizId); }); }); // View quiz buttons const viewButtons = this.element.querySelectorAll('.view-quiz-btn'); viewButtons.forEach(btn => { btn.addEventListener('click', e => { const quizId = (e.target as HTMLElement).dataset.quizId; if (quizId) this.handleViewQuiz(quizId); }); }); } /** * Handle edit quiz action * * @description Navigates to quiz editor * * @param {string} quizId - Quiz ID to edit * * @since 2025-08-04 * @author Claude Code Agent */ private handleEditQuiz(quizId: string): void { window.location.href = `/quiz/edit/${quizId}`; } /** * Handle view quiz action * * @description Navigates to quiz viewer * * @param {string} quizId - Quiz ID to view * * @since 2025-08-04 * @author Claude Code Agent */ private handleViewQuiz(quizId: string): void { window.location.href = `/quiz/view/${quizId}`; } /** * Get user initials * * @description Generates user initials for avatar * * @param {string} username - Username * @returns {string} User initials * * @since 2025-08-04 * @author Claude Code Agent */ private getUserInitials(username?: string): string { if (!username) return 'U'; const parts = username.split(/[\s@_.-]+/); if (parts.length >= 2) { return (parts[0][0] + parts[1][0]).toUpperCase(); } return username.substring(0, 2).toUpperCase(); } /** * Format date for display * * @description Formats date in user-friendly format * * @param {string | Date} date - Date to format * @returns {string} Formatted date * * @since 2025-08-04 * @author Claude Code Agent */ private formatDate(date: string | Date): string { if (!date) return 'Unknown'; const d = new Date(date); const now = new Date(); const diffTime = Math.abs(now.getTime() - d.getTime()); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 1) return 'Today'; if (diffDays === 2) return 'Yesterday'; if (diffDays <= 7) return `${diffDays} days ago`; return d.toLocaleDateString(); } /** * Redirect to login * * @description Redirects user to login page * * @since 2025-08-04 * @author Claude Code Agent */ private redirectToLogin(): void { window.location.href = '/auth/login.html'; } /** * Clean up resources * * @description Removes subscriptions and event listeners * * @since 2025-08-04 * @author Claude Code Agent */ protected onUnmount(): void { this.unsubscribeStore?.(); } }