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