add changes

This commit is contained in:
AD2025
2025-11-14 02:04:33 +02:00
parent 501de0103f
commit 6f23890407
48 changed files with 10759 additions and 213 deletions

View File

@@ -226,22 +226,22 @@
**Purpose:** Fetch all active categories **Purpose:** Fetch all active categories
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Create `CategoryService` with `getCategories()` method - [x] Create `CategoryService` with `getCategories()` method
- [ ] Store categories in `categoriesState` signal - [x] Store categories in `categoriesState` signal
- [ ] Implement caching strategy for categories (1 hour TTL) - [x] Implement caching strategy for categories (1 hour TTL)
- [ ] Handle guest vs authenticated user filtering (guestAccessible flag) - [x] Handle guest vs authenticated user filtering (guestAccessible flag)
- [ ] Sort categories by displayOrder or name - [x] Sort categories by displayOrder or name
**UI Tasks:** **UI Tasks:**
- [ ] Build `CategoryListComponent` to display all categories - [x] Build `CategoryListComponent` to display all categories
- [ ] Design category card with icon, name, description, question count - [x] Design category card with icon, name, description, question count
- [ ] Show "Locked" badge for auth-only categories (guest users) - [x] Show "Locked" badge for auth-only categories (guest users)
- [ ] Implement grid layout (responsive: 1 col mobile, 2 cols tablet, 3-4 cols desktop) - [x] Implement grid layout (responsive: 1 col mobile, 2 cols tablet, 3-4 cols desktop)
- [ ] Add search/filter bar for categories - [x] Add search/filter bar for categories
- [ ] Show loading skeleton while fetching - [x] Show loading skeleton while fetching
- [ ] Display empty state if no categories available - [x] Display empty state if no categories available
- [ ] Add hover effects and click animation - [x] Add hover effects and click animation
- [ ] Ensure keyboard navigation and ARIA labels - [x] Ensure keyboard navigation and ARIA labels
--- ---
@@ -249,21 +249,21 @@
**Purpose:** Get category details with question preview **Purpose:** Get category details with question preview
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `CategoryService.getCategoryById(id)` method - [x] Add `CategoryService.getCategoryById(id)` method
- [ ] Store selected category in `selectedCategoryState` signal - [x] Store selected category in `selectedCategoryState` signal
- [ ] Fetch category stats (total questions, difficulty breakdown, accuracy) - [x] Fetch category stats (total questions, difficulty breakdown, accuracy)
- [ ] Handle 404 if category not found - [x] Handle 404 if category not found
- [ ] Handle 403 for guest users accessing auth-only categories - [x] Handle 403 for guest users accessing auth-only categories
**UI Tasks:** **UI Tasks:**
- [ ] Build `CategoryDetailComponent` showing full category info - [x] Build `CategoryDetailComponent` showing full category info
- [ ] Display category header with icon, name, description - [x] Display category header with icon, name, description
- [ ] Show statistics (total questions, difficulty breakdown chart) - [x] Show statistics (total questions, difficulty breakdown chart)
- [ ] Display question preview (first 5 questions) - [x] Display question preview (first 5 questions)
- [ ] Add "Start Quiz" button with difficulty selector - [x] Add "Start Quiz" button with difficulty selector
- [ ] Show loading spinner while fetching details - [x] Show loading spinner while fetching details
- [ ] Display error message if category not accessible - [x] Display error message if category not accessible
- [ ] Implement breadcrumb navigation (Home > Categories > Category Name) - [x] Implement breadcrumb navigation (Home > Categories > Category Name)
--- ---
@@ -271,19 +271,19 @@
**Purpose:** Create new category **Purpose:** Create new category
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `CategoryService.createCategory(data)` method (admin only) - [x] Add `CategoryService.createCategory(data)` method (admin only)
- [ ] Validate form data (name, slug, description) - [x] Validate form data (name, slug, description)
- [ ] Handle 401/403 authorization errors - [x] Handle 401/403 authorization errors
- [ ] Invalidate category cache after creation - [x] Invalidate category cache after creation
**UI Tasks:** **UI Tasks:**
- [ ] Build `CategoryFormComponent` (admin) for creating categories - [x] Build `CategoryFormComponent` (admin) for creating categories
- [ ] Design form with name, slug, description, icon, color fields - [x] Design form with name, slug, description, icon, color fields
- [ ] Add guest accessible checkbox - [x] Add guest accessible checkbox
- [ ] Show slug preview/auto-generation - [x] Show slug preview/auto-generation
- [ ] Display validation errors inline - [x] Display validation errors inline
- [ ] Show success toast after creation - [x] Show success toast after creation
- [ ] Redirect to category list after success - [x] Redirect to category list after success
--- ---
@@ -291,17 +291,17 @@
**Purpose:** Update category **Purpose:** Update category
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `CategoryService.updateCategory(id, data)` method (admin only) - [x] Add `CategoryService.updateCategory(id, data)` method (admin only)
- [ ] Pre-fill form with existing category data - [x] Pre-fill form with existing category data
- [ ] Handle 404 if category not found - [x] Handle 404 if category not found
- [ ] Invalidate cache after update - [x] Invalidate cache after update
**UI Tasks:** **UI Tasks:**
- [ ] Reuse `CategoryFormComponent` in edit mode - [x] Reuse `CategoryFormComponent` in edit mode
- [ ] Pre-populate form fields with existing data - [x] Pre-populate form fields with existing data
- [ ] Show "Editing: Category Name" header - [x] Show "Editing: Category Name" header
- [ ] Add "Cancel" and "Save Changes" buttons - [x] Add "Cancel" and "Save Changes" buttons
- [ ] Display success toast after update - [x] Display success toast after update
--- ---
@@ -309,17 +309,17 @@
**Purpose:** Delete category **Purpose:** Delete category
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `CategoryService.deleteCategory(id)` method (admin only) - [x] Add `CategoryService.deleteCategory(id)` method (admin only)
- [ ] Handle soft delete - [x] Handle soft delete
- [ ] Invalidate cache after deletion - [x] Invalidate cache after deletion
- [ ] Handle 404 if category not found - [x] Handle 404 if category not found
**UI Tasks:** **UI Tasks:**
- [ ] Add delete button in admin category list - [x] Add delete button in admin category list
- [ ] Show confirmation dialog before deletion - [x] Show confirmation dialog before deletion
- [ ] Display warning if category has questions - [x] Display warning if category has questions
- [ ] Show success toast after deletion - [x] Show success toast after deletion
- [ ] Remove category from list immediately - [x] Remove category from list immediately
--- ---
@@ -329,23 +329,23 @@
**Purpose:** Start a new quiz session **Purpose:** Start a new quiz session
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Create `QuizService` with `startQuiz(categoryId, questionCount, difficulty, quizType)` method - [x] Create `QuizService` with `startQuiz(categoryId, questionCount, difficulty, quizType)` method
- [ ] Store active session in `quizSessionState` signal - [x] Store active session in `quizSessionState` signal
- [ ] Validate category accessibility (guest vs authenticated) - [x] Validate category accessibility (guest vs authenticated)
- [ ] Check guest quiz limit before starting - [x] Check guest quiz limit before starting
- [ ] Handle JWT token or guest token header - [x] Handle JWT token or guest token header
- [ ] Navigate to quiz page after starting - [x] Navigate to quiz page after starting
**UI Tasks:** **UI Tasks:**
- [ ] Build `QuizSetupComponent` for configuring quiz - [x] Build `QuizSetupComponent` for configuring quiz
- [ ] Add category selector dropdown - [x] Add category selector dropdown
- [ ] Add question count slider (5, 10, 15, 20) - [x] Add question count slider (5, 10, 15, 20)
- [ ] Add difficulty selector (Easy, Medium, Hard, Mixed) - [x] Add difficulty selector (Easy, Medium, Hard, Mixed)
- [ ] Add quiz type selector (Practice, Timed) - [x] Add quiz type selector (Practice, Timed)
- [ ] Show estimated time for quiz - [x] Show estimated time for quiz
- [ ] Display "Start Quiz" button with loading state - [x] Display "Start Quiz" button with loading state
- [ ] Show guest limit warning if applicable - [x] Show guest limit warning if applicable
- [ ] Implement responsive design - [x] Implement responsive design
--- ---
@@ -353,28 +353,28 @@
**Purpose:** Submit answer for current question **Purpose:** Submit answer for current question
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `QuizService.submitAnswer(questionId, answer, sessionId)` method - [x] Add `QuizService.submitAnswer(questionId, answer, sessionId)` method
- [ ] Update quiz session state with answer result - [x] Update quiz session state with answer result
- [ ] Increment current question index - [x] Increment current question index
- [ ] Calculate and update score in real-time - [x] Calculate and update score in real-time
- [ ] Handle validation errors - [x] Handle validation errors
- [ ] Store answer history for review - [x] Store answer history for review
**UI Tasks:** **UI Tasks:**
- [ ] Build `QuizQuestionComponent` displaying current question - [x] Build `QuizQuestionComponent` displaying current question
- [ ] Show question text, type, and options - [x] Show question text, type, and options
- [ ] Create answer input based on question type: - [x] Create answer input based on question type:
- Multiple choice: Radio buttons - Multiple choice: Radio buttons
- True/False: Toggle buttons - True/False: Toggle buttons
- Written: Text area - Written: Text area
- [ ] Show "Submit Answer" button (disabled until answer selected) - [x] Show "Submit Answer" button (disabled until answer selected)
- [ ] Display loading spinner during submission - [x] Display loading spinner during submission
- [ ] Show immediate feedback (correct/incorrect) with animation - [x] Show immediate feedback (correct/incorrect) with animation
- [ ] Display explanation after submission - [x] Display explanation after submission
- [ ] Show "Next Question" button after submission - [x] Show "Next Question" button after submission
- [ ] Update progress bar and score - [x] Update progress bar and score
- [ ] Add timer display if timed quiz - [x] Add timer display if timed quiz
- [ ] Prevent answer changes after submission - [x] Prevent answer changes after submission
--- ---
@@ -382,25 +382,25 @@
**Purpose:** Complete quiz session and get results **Purpose:** Complete quiz session and get results
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `QuizService.completeQuiz(sessionId)` method - [x] Add `QuizService.completeQuiz(sessionId)` method
- [ ] Store final results in `quizResultsState` signal - [x] Store final results in `quizResultsState` signal
- [ ] Calculate percentage score - [x] Calculate percentage score
- [ ] Fetch detailed answer breakdown - [x] Fetch detailed answer breakdown
- [ ] Clear active session state - [x] Clear active session state
- [ ] Redirect to results page - [x] Redirect to results page
**UI Tasks:** **UI Tasks:**
- [ ] Build `QuizResultsComponent` showing final score - [x] Build `QuizResultsComponent` showing final score
- [ ] Display score with percentage and message (Excellent, Good, Keep Practicing) - [x] Display score with percentage and message (Excellent, Good, Keep Practicing)
- [ ] Show time taken and questions answered - [x] Show time taken and questions answered
- [ ] Display pie chart for correct/incorrect breakdown - [x] Display pie chart for correct/incorrect breakdown
- [ ] List all questions with user answers and correct answers - [x] List all questions with user answers and correct answers
- [ ] Highlight incorrect answers in red - [x] Highlight incorrect answers in red
- [ ] Add "Review Incorrect Answers" button - [x] Add "Review Incorrect Answers" button
- [ ] Add "Retake Quiz" button - [x] Add "Retake Quiz" button
- [ ] Add "Return to Dashboard" button - [x] Add "Return to Dashboard" button
- [ ] Show confetti animation for high scores (>80%) - [x] Show confetti animation for high scores (>80%)
- [ ] Add social share buttons (Twitter, LinkedIn, Facebook) - [x] Add social share buttons (Twitter, LinkedIn, Facebook)
--- ---
@@ -408,15 +408,15 @@
**Purpose:** Get current quiz session details **Purpose:** Get current quiz session details
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `QuizService.getSession(sessionId)` method - [x] Add `QuizService.getSession(sessionId)` method
- [ ] Restore session state if user refreshes page - [x] Restore session state if user refreshes page
- [ ] Handle 404 if session not found - [x] Handle 404 if session not found
- [ ] Resume quiz from current question index - [x] Resume quiz from current question index
**UI Tasks:** **UI Tasks:**
- [ ] Show "Resume Quiz" prompt if incomplete session exists - [x] Show "Resume Quiz" prompt if incomplete session exists
- [ ] Display current progress in prompt (e.g., "Question 5 of 10") - [x] Display current progress in prompt (e.g., "Question 5 of 10")
- [ ] Allow user to continue or start new quiz - [x] Allow user to continue or start new quiz
--- ---
@@ -424,19 +424,19 @@
**Purpose:** Review completed quiz **Purpose:** Review completed quiz
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `QuizService.reviewQuiz(sessionId)` method - [x] Add `QuizService.reviewQuiz(sessionId)` method
- [ ] Fetch all questions and answers for session - [x] Fetch all questions and answers for session
- [ ] Store review data in signal - [x] Store review data in signal
- [ ] Handle 404 if session not found - [x] Handle 404 if session not found
**UI Tasks:** **UI Tasks:**
- [ ] Build `QuizReviewComponent` for reviewing completed quiz - [x] Build `QuizReviewComponent` for reviewing completed quiz
- [ ] Display each question with user answer and correct answer - [x] Display each question with user answer and correct answer
- [ ] Highlight correct answers in green, incorrect in red - [x] Highlight correct answers in green, incorrect in red
- [ ] Show explanations for all questions - [x] Show explanations for all questions
- [ ] Add "Bookmark" button for difficult questions - [x] Add "Bookmark" button for difficult questions
- [ ] Implement pagination or infinite scroll for long quizzes - [x] Implement pagination or infinite scroll for long quizzes
- [ ] Add "Back to Results" button - [x] Add "Back to Results" button
--- ---
@@ -446,27 +446,27 @@
**Purpose:** Get user dashboard with statistics **Purpose:** Get user dashboard with statistics
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Create `UserService` with `getDashboard(userId)` method - [x] Create `UserService` with `getDashboard(userId)` method
- [ ] Store dashboard data in `dashboardState` signal - [x] Store dashboard data in `dashboardState` signal
- [ ] Fetch on dashboard component load - [x] Fetch on dashboard component load
- [ ] Implement caching (5 min TTL) - [x] Implement caching (5 min TTL)
- [ ] Handle 401 if not authenticated - [x] Handle 401 if not authenticated
**UI Tasks:** **UI Tasks:**
- [ ] Build `DashboardComponent` as main user landing page - [x] Build `DashboardComponent` as main user landing page
- [ ] Display welcome message with username - [x] Display welcome message with username
- [ ] Show overall statistics cards: - [x] Show overall statistics cards:
- Total quizzes taken - Total quizzes taken
- Overall accuracy percentage - Overall accuracy percentage
- Current streak - Current streak
- Total questions answered - Total questions answered
- [ ] Create category-wise performance chart (bar chart or pie chart) - [x] Create category-wise performance chart (bar chart or pie chart)
- [ ] Display recent quiz sessions (last 5) with scores - [x] Display recent quiz sessions (last 5) with scores
- [ ] Show achievements and badges earned - [x] Show achievements and badges earned
- [ ] Add "Start New Quiz" CTA button - [x] Add "Start New Quiz" CTA button
- [ ] Implement responsive grid layout (stack on mobile) - [x] Implement responsive grid layout (stack on mobile)
- [ ] Add loading skeletons for data sections - [x] Add loading skeletons for data sections
- [ ] Show empty state if no quizzes taken yet - [x] Show empty state if no quizzes taken yet
--- ---
@@ -474,24 +474,24 @@
**Purpose:** Get quiz history with pagination **Purpose:** Get quiz history with pagination
**Frontend Tasks:** **Frontend Tasks:**
- [ ] Add `UserService.getHistory(userId, page, limit, category?, sortBy?)` method - [x] Add `UserService.getHistory(userId, page, limit, category?, sortBy?)` method
- [ ] Store history in `historyState` signal - [x] Store history in `historyState` signal
- [ ] Implement pagination state management - [x] Implement pagination state management
- [ ] Add filtering by category - [x] Add filtering by category
- [ ] Add sorting by date or score - [x] Add sorting by date or score
- [ ] Handle query parameters in URL - [x] Handle query parameters in URL
**UI Tasks:** **UI Tasks:**
- [ ] Build `QuizHistoryComponent` displaying all past quizzes - [x] Build `QuizHistoryComponent` displaying all past quizzes
- [ ] Create history table/list with columns: Date, Category, Score, Time, Actions - [x] Create history table/list with columns: Date, Category, Score, Time, Actions
- [ ] Add filter dropdown for category - [x] Add filter dropdown for category
- [ ] Add sort dropdown (Date, Score) - [x] Add sort dropdown (Date, Score)
- [ ] Implement pagination controls (Previous, Next, Page numbers) - [x] Implement pagination controls (Previous, Next, Page numbers)
- [ ] Show "View Details" button for each quiz - [x] Show "View Details" button for each quiz
- [ ] Display loading spinner during fetch - [x] Display loading spinner during fetch
- [ ] Show empty state if no history - [x] Show empty state if no history
- [ ] Make table responsive (collapse to cards on mobile) - [x] Make table responsive (collapse to cards on mobile)
- [ ] Add export functionality (CSV download) - [x] Add export functionality (CSV download)
--- ---

View File

@@ -23,12 +23,79 @@ export const routes: Routes = [
title: 'Welcome - Quiz Platform' title: 'Welcome - Quiz Platform'
}, },
// Category routes
{
path: 'categories',
loadComponent: () => import('./features/categories/category-list/category-list').then(m => m.CategoryListComponent),
title: 'Categories - Quiz Platform'
},
{
path: 'categories/:id',
loadComponent: () => import('./features/categories/category-detail/category-detail').then(m => m.CategoryDetailComponent),
title: 'Category Detail - Quiz Platform'
},
// Dashboard route (protected)
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard],
title: 'Dashboard - Quiz Platform'
},
// History route (protected)
{
path: 'history',
loadComponent: () => import('./features/history/quiz-history.component').then(m => m.QuizHistoryComponent),
canActivate: [authGuard],
title: 'Quiz History - Quiz Platform'
},
// Quiz routes
{
path: 'quiz/setup',
loadComponent: () => import('./features/quiz/quiz-setup/quiz-setup').then(m => m.QuizSetupComponent),
title: 'Setup Quiz - Quiz Platform'
},
{
path: 'quiz/:sessionId',
loadComponent: () => import('./features/quiz/quiz-question/quiz-question').then(m => m.QuizQuestionComponent),
title: 'Quiz - Quiz Platform'
},
{
path: 'quiz/:sessionId/results',
loadComponent: () => import('./features/quiz/quiz-results/quiz-results').then(m => m.QuizResultsComponent),
title: 'Quiz Results - Quiz Platform'
},
{
path: 'quiz/:sessionId/review',
loadComponent: () => import('./features/quiz/quiz-review/quiz-review').then(m => m.QuizReviewComponent),
title: 'Review Quiz - Quiz Platform'
},
// Admin routes (TODO: Add adminGuard)
{
path: 'admin/categories',
loadComponent: () => import('./features/admin/admin-category-list/admin-category-list').then(m => m.AdminCategoryListComponent),
title: 'Manage Categories - Admin'
},
{
path: 'admin/categories/new',
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
title: 'Create Category - Admin'
},
{
path: 'admin/categories/edit/:id',
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
title: 'Edit Category - Admin'
},
// TODO: Add more routes as components are created // TODO: Add more routes as components are created
// - Home page (public) // - Home page (public)
// - Dashboard (protected with authGuard) // - Quiz history (protected with authGuard)
// - Quiz routes (protected with authGuard) // - Bookmarks (protected with authGuard)
// - Results routes (protected with authGuard) // - Profile settings (protected with authGuard)
// - Admin routes (protected with adminGuard) // - More Admin routes (protected with adminGuard)
// Fallback - redirect to login for now // Fallback - redirect to login for now
{ {

View File

@@ -23,6 +23,11 @@ export interface Category {
export interface CategoryDetail extends Category { export interface CategoryDetail extends Category {
questionPreview?: QuestionPreview[]; questionPreview?: QuestionPreview[];
stats?: CategoryStats; stats?: CategoryStats;
difficultyBreakdown?: {
easy: number;
medium: number;
hard: number;
};
} }
/** /**
@@ -38,6 +43,7 @@ export interface CategoryStats {
totalAttempts: number; totalAttempts: number;
totalCorrect: number; totalCorrect: number;
averageAccuracy: number; averageAccuracy: number;
averageScore?: number;
} }
/** /**

View File

@@ -41,8 +41,11 @@ export interface UserLogin {
*/ */
export interface AuthResponse { export interface AuthResponse {
success: boolean; success: boolean;
token: string; data: {
user: User; user: User;
token: string;
};
message?: string; message?: string;
migratedStats?: { migratedStats?: {
quizzesTaken: number; quizzesTaken: number;

View File

@@ -57,8 +57,8 @@ export class AuthService {
return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe( return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe(
tap((response) => { tap((response) => {
// Store token and user data // Store token and user data
this.storageService.setToken(response.token, true); // Remember me by default this.storageService.setToken(response.data.token, true); // Remember me by default
this.storageService.setUserData(response.user); this.storageService.setUserData(response.data.user);
// Clear guest token if converting // Clear guest token if converting
if (guestSessionId) { if (guestSessionId) {
@@ -66,16 +66,16 @@ export class AuthService {
} }
// Update auth state // Update auth state
this.updateAuthState(response.user, null); this.updateAuthState(response.data.user, null);
// Show success message // Show success message
const message = response.migratedStats const message = response.migratedStats
? `Welcome ${response.user.username}! Your guest progress has been saved.` ? `Welcome ${response.data.user.username}! Your guest progress has been saved.`
: `Welcome ${response.user.username}! Your account has been created.`; : `Welcome ${response.data.user.username}! Your account has been created.`;
this.toastService.success(message); this.toastService.success(message);
// Auto-login: redirect to dashboard // Auto-login: redirect to categories
this.router.navigate(['/dashboard']); this.router.navigate(['/categories']);
}), }),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
this.handleAuthError(error); this.handleAuthError(error);
@@ -87,7 +87,7 @@ export class AuthService {
/** /**
* Login user * Login user
*/ */
login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/dashboard'): Observable<AuthResponse> { login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/categories'): Observable<AuthResponse> {
this.setLoading(true); this.setLoading(true);
const loginData: UserLogin = { email, password }; const loginData: UserLogin = { email, password };
@@ -95,17 +95,19 @@ export class AuthService {
return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe( return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe(
tap((response) => { tap((response) => {
// Store token and user data // Store token and user data
this.storageService.setToken(response.token, rememberMe); console.log(response.data.user);
this.storageService.setUserData(response.user);
this.storageService.setToken(response.data.token, rememberMe);
this.storageService.setUserData(response.data.user);
// Clear guest token // Clear guest token
this.storageService.clearGuestToken(); this.storageService.clearGuestToken();
// Update auth state // Update auth state
this.updateAuthState(response.user, null); this.updateAuthState(response.data.user, null);
// Show success message // Show success message
this.toastService.success(`Welcome back, ${response.user.username}!`); this.toastService.success(`Welcome back, ${response.data.user.username}!`);
// Redirect to requested URL // Redirect to requested URL
this.router.navigate([redirectUrl]); this.router.navigate([redirectUrl]);

View File

@@ -0,0 +1,313 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError, of } from 'rxjs';
import { tap, catchError, shareReplay, map } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
Category,
CategoryDetail,
CategoryFormData
} from '../models/category.model';
import { ToastService } from './toast.service';
import { AuthService } from './auth.service';
import { GuestService } from './guest.service';
/**
* Cache entry interface
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
}
@Injectable({
providedIn: 'root'
})
export class CategoryService {
private http = inject(HttpClient);
private toastService = inject(ToastService);
private authService = inject(AuthService);
private guestService = inject(GuestService);
private readonly API_URL = `${environment.apiUrl}/categories`;
private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
// State management with signals
private categoriesState = signal<Category[]>([]);
private selectedCategoryState = signal<CategoryDetail | null>(null);
private loadingState = signal<boolean>(false);
private errorState = signal<string | null>(null);
// Cache storage
private categoriesCache: CacheEntry<Category[]> | null = null;
private categoryDetailsCache = new Map<string, CacheEntry<CategoryDetail>>();
// Public readonly signals
readonly categories = this.categoriesState.asReadonly();
readonly selectedCategory = this.selectedCategoryState.asReadonly();
readonly isLoading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
// Computed signals
readonly filteredCategories = computed(() => {
const categories = this.categoriesState();
const isGuest = this.guestService.guestState().isGuest;
// Filter categories based on user type
if (isGuest) {
return categories //.filter(cat => cat.guestAccessible && cat.isActive);
}
return categories //.filter(cat => cat.isActive);
});
readonly categoriesByDisplayOrder = computed(() => {
return [...this.filteredCategories()].sort((a, b) => {
const orderA = a.displayOrder ?? 999;
const orderB = b.displayOrder ?? 999;
if (orderA !== orderB) {
return orderA - orderB;
}
return a.name.localeCompare(b.name);
});
});
/**
* Get all active categories
* Implements caching strategy with 1 hour TTL
*/
getCategories(forceRefresh: boolean = false): Observable<Category[]> {
// Check cache if not forcing refresh
if (!forceRefresh && this.categoriesCache && this.isCacheValid(this.categoriesCache.timestamp)) {
this.categoriesState.set(this.categoriesCache.data);
return of(this.categoriesCache.data);
}
this.loadingState.set(true);
this.errorState.set(null);
return this.http.get<{ success: boolean; data: Category[]; count: number; message: string }>(this.API_URL).pipe(
map(response => response.data),
tap(categories => {
// Update cache
this.categoriesCache = {
data: categories,
timestamp: Date.now()
};
console.log(categories);
// Update state
this.categoriesState.set(categories);
this.loadingState.set(false);
}),
catchError(error => this.handleError(error, 'Failed to load categories')),
shareReplay(1)
);
}
/**
* Get category by ID with details
*/
getCategoryById(id: string, forceRefresh: boolean = false): Observable<CategoryDetail> {
// Check cache if not forcing refresh
const cached = this.categoryDetailsCache.get(id);
if (!forceRefresh && cached && this.isCacheValid(cached.timestamp)) {
this.selectedCategoryState.set(cached.data);
return of(cached.data);
}
this.loadingState.set(true);
this.errorState.set(null);
return this.http.get<{
success: boolean;
data: {
category: Category;
questionPreview: any[];
stats: any
};
message: string
}>(`${this.API_URL}/${id}`).pipe(
map(response => {
// Flatten the nested response structure
const { category, questionPreview, stats } = response.data;
return {
...category,
questionPreview,
stats: {
...stats,
averageScore: stats.averageAccuracy // Use same value for now
},
difficultyBreakdown: stats.questionsByDifficulty
} as CategoryDetail;
}),
tap(category => {
// Update cache
this.categoryDetailsCache.set(id, {
data: category,
timestamp: Date.now()
});
// Update state
this.selectedCategoryState.set(category);
this.loadingState.set(false);
}),
catchError(error => {
if (error.status === 404) {
return this.handleError(error, 'Category not found');
}
if (error.status === 403) {
return this.handleError(error, 'This category is not accessible in guest mode');
}
return this.handleError(error, 'Failed to load category details');
}),
shareReplay(1)
);
}
/**
* Create new category (Admin only)
*/
createCategory(data: CategoryFormData): Observable<Category> {
this.loadingState.set(true);
this.errorState.set(null);
return this.http.post<Category>(this.API_URL, data).pipe(
tap(category => {
this.toastService.success('Category created successfully');
this.invalidateCategoriesCache();
this.loadingState.set(false);
}),
catchError(error => {
if (error.status === 401 || error.status === 403) {
return this.handleError(error, 'You do not have permission to create categories');
}
return this.handleError(error, 'Failed to create category');
})
);
}
/**
* Update category (Admin only)
*/
updateCategory(id: string, data: CategoryFormData): Observable<Category> {
this.loadingState.set(true);
this.errorState.set(null);
return this.http.put<Category>(`${this.API_URL}/${id}`, data).pipe(
tap(category => {
this.toastService.success('Category updated successfully');
this.invalidateCategoriesCache();
this.categoryDetailsCache.delete(id);
this.loadingState.set(false);
}),
catchError(error => {
if (error.status === 404) {
return this.handleError(error, 'Category not found');
}
if (error.status === 401 || error.status === 403) {
return this.handleError(error, 'You do not have permission to update categories');
}
return this.handleError(error, 'Failed to update category');
})
);
}
/**
* Delete category (Admin only)
*/
deleteCategory(id: string): Observable<void> {
this.loadingState.set(true);
this.errorState.set(null);
return this.http.delete<void>(`${this.API_URL}/${id}`).pipe(
tap(() => {
this.toastService.success('Category deleted successfully');
this.invalidateCategoriesCache();
this.categoryDetailsCache.delete(id);
// Remove from state
const currentCategories = this.categoriesState();
this.categoriesState.set(currentCategories.filter(cat => cat.id !== id));
this.loadingState.set(false);
}),
catchError(error => {
if (error.status === 404) {
return this.handleError(error, 'Category not found');
}
if (error.status === 401 || error.status === 403) {
return this.handleError(error, 'You do not have permission to delete categories');
}
return this.handleError(error, 'Failed to delete category');
})
);
}
/**
* Search categories by name or description
*/
searchCategories(query: string): Category[] {
if (!query.trim()) {
return this.filteredCategories();
}
const searchTerm = query.toLowerCase();
return this.filteredCategories().filter(category =>
category.name.toLowerCase().includes(searchTerm) ||
category.description.toLowerCase().includes(searchTerm)
);
}
/**
* Clear selected category
*/
clearSelectedCategory(): void {
this.selectedCategoryState.set(null);
}
/**
* Invalidate categories cache
*/
invalidateCategoriesCache(): void {
this.categoriesCache = null;
}
/**
* Invalidate specific category cache
*/
invalidateCategoryCache(id: string): void {
this.categoryDetailsCache.delete(id);
}
/**
* Clear all caches
*/
clearAllCaches(): void {
this.categoriesCache = null;
this.categoryDetailsCache.clear();
}
/**
* Check if cache is still valid
*/
private isCacheValid(timestamp: number): boolean {
return Date.now() - timestamp < this.CACHE_TTL;
}
/**
* Handle HTTP errors
*/
private handleError(error: HttpErrorResponse, defaultMessage: string): Observable<never> {
console.error('CategoryService Error:', error);
const message = error.error?.message || defaultMessage;
this.errorState.set(message);
this.loadingState.set(false);
this.toastService.error(message);
return throwError(() => error);
}
}

View File

@@ -4,3 +4,5 @@ export * from './state.service';
export * from './loading.service'; export * from './loading.service';
export * from './theme.service'; export * from './theme.service';
export * from './auth.service'; export * from './auth.service';
export * from './category.service';
export * from './guest.service';

View File

@@ -0,0 +1,356 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap, catchError, throwError, map } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
QuizSession,
QuizStartRequest,
QuizStartResponse,
QuizAnswerSubmission,
QuizAnswerResponse,
QuizResults
} from '../models/quiz.model';
import { ToastService } from './toast.service';
import { StorageService } from './storage.service';
import { GuestService } from './guest.service';
@Injectable({
providedIn: 'root'
})
export class QuizService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly toastService = inject(ToastService);
private readonly storageService = inject(StorageService);
private readonly guestService = inject(GuestService);
private readonly apiUrl = `${environment.apiUrl}/quiz`;
// Active quiz session state
private readonly _activeSession = signal<QuizSession | null>(null);
readonly activeSession = this._activeSession.asReadonly();
// Quiz questions state
private readonly _questions = signal<any[]>([]);
readonly questions = this._questions.asReadonly();
// Quiz results state
private readonly _quizResults = signal<QuizResults | null>(null);
readonly quizResults = this._quizResults.asReadonly();
// Loading states
private readonly _isStartingQuiz = signal<boolean>(false);
readonly isStartingQuiz = this._isStartingQuiz.asReadonly();
private readonly _isSubmittingAnswer = signal<boolean>(false);
readonly isSubmittingAnswer = this._isSubmittingAnswer.asReadonly();
private readonly _isCompletingQuiz = signal<boolean>(false);
readonly isCompletingQuiz = this._isCompletingQuiz.asReadonly();
// Computed states
readonly hasActiveSession = computed(() => this._activeSession() !== null);
readonly currentQuestionIndex = computed(() => this._activeSession()?.currentQuestionIndex ?? 0);
readonly totalQuestions = computed(() => this._activeSession()?.totalQuestions ?? 0);
readonly progress = computed(() => {
const total = this.totalQuestions();
const current = this.currentQuestionIndex();
return total > 0 ? (current / total) * 100 : 0;
});
/**
* Start a new quiz session
*/
startQuiz(request: QuizStartRequest): Observable<QuizStartResponse> {
// Validate category accessibility
if (!this.canAccessCategory(request.categoryId)) {
this.toastService.error('You do not have access to this category');
return throwError(() => new Error('Category not accessible'));
}
// Check guest quiz limit
if (!this.storageService.isAuthenticated()) {
const guestState = this.guestService.guestState();
const remainingQuizzes = guestState.quizLimit?.quizzesRemaining ?? null;
if (remainingQuizzes !== null && remainingQuizzes <= 0) {
this.toastService.warning('Guest quiz limit reached. Please sign up to continue.');
this.router.navigate(['/register']);
return throwError(() => new Error('Guest quiz limit reached'));
}
}
this._isStartingQuiz.set(true);
return this.http.post<QuizStartResponse>(`${this.apiUrl}/start`, request).pipe(
tap(response => {
if (response.success) {
// Store session data
const session: QuizSession = {
id: response.sessionId,
userId: this.storageService.getUserData()?.id,
guestSessionId: this.guestService.guestState().session?.guestId,
categoryId: request.categoryId,
quizType: request.quizType || 'practice',
difficulty: request.difficulty || 'mixed',
totalQuestions: response.totalQuestions,
currentQuestionIndex: 0,
score: 0,
correctAnswers: 0,
incorrectAnswers: 0,
skippedAnswers: 0,
status: 'in_progress',
startedAt: new Date().toISOString()
};
this._activeSession.set(session);
// Store questions from response
if (response.questions) {
this._questions.set(response.questions);
}
// Store session ID for restoration
this.storeSessionId(response.sessionId);
this.toastService.success('Quiz started successfully!');
}
}),
catchError(error => {
this.toastService.error(error.error?.message || 'Failed to start quiz');
return throwError(() => error);
}),
tap(() => this._isStartingQuiz.set(false))
);
}
/**
* Submit answer for current question
*/
submitAnswer(submission: QuizAnswerSubmission): Observable<QuizAnswerResponse> {
this._isSubmittingAnswer.set(true);
return this.http.post<QuizAnswerResponse>(`${this.apiUrl}/submit`, submission).pipe(
tap(response => {
if (response.success) {
// Update session state
const currentSession = this._activeSession();
if (currentSession) {
const updated: QuizSession = {
...currentSession,
score: response.score,
correctAnswers: response.isCorrect
? currentSession.correctAnswers + 1
: currentSession.correctAnswers,
incorrectAnswers: !response.isCorrect
? currentSession.incorrectAnswers + 1
: currentSession.incorrectAnswers,
currentQuestionIndex: currentSession.currentQuestionIndex + 1
};
this._activeSession.set(updated);
}
}
}),
catchError(error => {
this.toastService.error(error.error?.message || 'Failed to submit answer');
return throwError(() => error);
}),
tap(() => this._isSubmittingAnswer.set(false))
);
}
/**
* Complete the quiz session
*/
completeQuiz(sessionId: string): Observable<QuizResults> {
this._isCompletingQuiz.set(true);
return this.http.post<QuizResults>(`${this.apiUrl}/complete`, { sessionId }).pipe(
tap(results => {
if (results.success) {
this._quizResults.set(results);
// Update session status
const currentSession = this._activeSession();
if (currentSession) {
this._activeSession.set({
...currentSession,
status: 'completed',
completedAt: new Date().toISOString()
});
}
this.toastService.success('Quiz completed successfully!');
// Navigate to results page
this.router.navigate(['/quiz', sessionId, 'results']);
}
}),
catchError(error => {
this.toastService.error(error.error?.message || 'Failed to complete quiz');
return throwError(() => error);
}),
tap(() => this._isCompletingQuiz.set(false))
);
}
/**
* Get quiz session details
*/
getSession(sessionId: string): Observable<QuizSession> {
return this.http.get<{ success: boolean; data: QuizSession }>(`${this.apiUrl}/session/${sessionId}`).pipe(
tap(response => {
if (response.success && response.data) {
this._activeSession.set(response.data);
}
}),
catchError(error => {
if (error.status === 404) {
this.toastService.error('Quiz session not found');
} else {
this.toastService.error(error.error?.message || 'Failed to load session');
}
return throwError(() => error);
}),
map(response => response.data)
);
}
/**
* Get quiz review data
*/
reviewQuiz(sessionId: string): Observable<QuizResults> {
return this.http.get<QuizResults>(`${this.apiUrl}/review/${sessionId}`).pipe(
tap(results => {
if (results.success) {
this._quizResults.set(results);
}
}),
catchError(error => {
if (error.status === 404) {
this.toastService.error('Quiz session not found');
} else {
this.toastService.error(error.error?.message || 'Failed to load quiz review');
}
return throwError(() => error);
})
);
}
/**
* Abandon current quiz session
*/
abandonQuiz(sessionId: string): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/abandon`, { sessionId }).pipe(
tap(() => {
this._activeSession.set(null);
this.toastService.info('Quiz abandoned');
}),
catchError(error => {
this.toastService.error(error.error?.message || 'Failed to abandon quiz');
return throwError(() => error);
})
);
}
/**
* Check for incomplete quiz session
* Returns the session ID if an incomplete session exists
*/
checkIncompleteSession(): string | null {
const sessionId = localStorage.getItem('activeQuizSessionId');
if (sessionId) {
const session = this._activeSession();
if (session && session.status === 'in_progress') {
return sessionId;
}
}
return null;
}
/**
* Restore incomplete session
* Fetches session details and questions from backend
*/
restoreSession(sessionId: string): Observable<{ session: QuizSession; hasQuestions: boolean }> {
return this.getSession(sessionId).pipe(
tap(session => {
// Store session ID in localStorage for future restoration
localStorage.setItem('activeQuizSessionId', sessionId);
// Check if we have questions stored
const hasQuestions = this._questions().length > 0;
if (!hasQuestions) {
// Questions need to be fetched separately if not in memory
// For now, we'll navigate to the quiz page which will handle loading
console.log('Session restored, questions need to be loaded');
}
}),
map(session => ({
session,
hasQuestions: this._questions().length > 0
})),
catchError(error => {
// If session not found, clear the stored session ID
localStorage.removeItem('activeQuizSessionId');
return throwError(() => error);
})
);
}
/**
* Store session ID for restoration
*/
private storeSessionId(sessionId: string): void {
localStorage.setItem('activeQuizSessionId', sessionId);
}
/**
* Clear stored session ID
*/
private clearStoredSessionId(): void {
localStorage.removeItem('activeQuizSessionId');
}
/**
* Clear active session (client-side only)
*/
clearSession(): void {
this._activeSession.set(null);
this._questions.set([]);
this._quizResults.set(null);
this.clearStoredSessionId();
}
/**
* Check if user can access a category
*/
private canAccessCategory(categoryId: string): boolean {
// If authenticated, can access all categories
if (this.storageService.isAuthenticated()) {
return true;
}
// Guest users need to check category accessibility
// This should be validated on the backend as well
return true; // Simplified - backend will enforce
}
/**
* Get estimated time for quiz
*/
getEstimatedTime(questionCount: number, quizType: 'practice' | 'timed'): number {
// Average time per question in minutes
const timePerQuestion = quizType === 'timed' ? 1.5 : 2;
return Math.ceil(questionCount * timePerQuestion);
}
/**
* Calculate quiz time limit for timed quizzes
*/
calculateTimeLimit(questionCount: number): number {
// 1.5 minutes per question for timed mode
return questionCount * 1.5;
}
}

View File

@@ -65,7 +65,15 @@ export class StorageService {
// User Data Methods // User Data Methods
getUserData(): any { getUserData(): any {
const userData = this.getItem(this.USER_KEY); const userData = this.getItem(this.USER_KEY);
return userData ? JSON.parse(userData) : null; if (!userData || userData === 'undefined' || userData === 'null') {
return null;
}
try {
return JSON.parse(userData);
} catch (error) {
console.error('Error parsing user data:', error);
return null;
}
} }
setUserData(user: any, rememberMe: boolean = true): void { setUserData(user: any, rememberMe: boolean = true): void {

View File

@@ -0,0 +1,172 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, tap, map } from 'rxjs/operators';
import { of, Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UserDashboard, QuizHistoryResponse, UserProfileUpdate } from '../models/dashboard.model';
import { ToastService } from './toast.service';
interface CacheEntry<T> {
data: T;
timestamp: number;
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private http = inject(HttpClient);
private router = inject(Router);
private toastService = inject(ToastService);
private readonly API_URL = `${environment.apiUrl}/users`;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
// Signals
dashboardState = signal<UserDashboard | null>(null);
historyState = signal<QuizHistoryResponse | null>(null);
isLoading = signal<boolean>(false);
error = signal<string | null>(null);
// Cache
private dashboardCache = new Map<string, CacheEntry<UserDashboard>>();
// Computed values
totalQuizzes = computed(() => this.dashboardState()?.totalQuizzes || 0);
overallAccuracy = computed(() => this.dashboardState()?.overallAccuracy || 0);
currentStreak = computed(() => this.dashboardState()?.currentStreak || 0);
/**
* Get user dashboard with statistics
*/
getDashboard(userId: string, forceRefresh = false): Observable<UserDashboard> {
// Check cache if not forcing refresh
if (!forceRefresh) {
const cached = this.dashboardCache.get(userId);
if (cached && (Date.now() - cached.timestamp < this.CACHE_TTL)) {
this.dashboardState.set(cached.data);
return of(cached.data);
}
}
this.isLoading.set(true);
this.error.set(null);
return this.http.get<UserDashboard>(`${this.API_URL}/${userId}/dashboard`).pipe(
tap(response => {
this.dashboardState.set(response);
// Cache the response
this.dashboardCache.set(userId, {
data: response,
timestamp: Date.now()
});
this.isLoading.set(false);
}),
catchError(error => {
console.error('Error fetching dashboard:', error);
this.error.set(error.error?.message || 'Failed to load dashboard');
this.isLoading.set(false);
if (error.status === 401) {
this.toastService.error('Please log in to view your dashboard');
this.router.navigate(['/login']);
} else {
this.toastService.error('Failed to load dashboard data');
}
throw error;
})
);
}
/**
* Get user quiz history with pagination and filters
*/
getHistory(
userId: string,
page = 1,
limit = 10,
category?: string,
sortBy: 'date' | 'score' = 'date'
): Observable<QuizHistoryResponse> {
this.isLoading.set(true);
this.error.set(null);
let params: any = { page, limit, sortBy };
if (category) {
params.category = category;
}
return this.http.get<QuizHistoryResponse>(`${this.API_URL}/${userId}/history`, { params }).pipe(
tap(response => {
this.historyState.set(response);
this.isLoading.set(false);
}),
catchError(error => {
console.error('Error fetching history:', error);
this.error.set(error.error?.message || 'Failed to load quiz history');
this.isLoading.set(false);
if (error.status === 401) {
this.toastService.error('Please log in to view your history');
this.router.navigate(['/login']);
} else {
this.toastService.error('Failed to load quiz history');
}
throw error;
})
);
}
/**
* Update user profile
*/
updateProfile(userId: string, data: UserProfileUpdate): Observable<any> {
this.isLoading.set(true);
this.error.set(null);
return this.http.put(`${this.API_URL}/${userId}`, data).pipe(
tap(response => {
this.isLoading.set(false);
this.toastService.success('Profile updated successfully');
// Invalidate dashboard cache
this.dashboardCache.delete(userId);
}),
catchError(error => {
console.error('Error updating profile:', error);
this.error.set(error.error?.message || 'Failed to update profile');
this.isLoading.set(false);
if (error.status === 401) {
this.toastService.error('Please log in to update your profile');
} else if (error.status === 409) {
this.toastService.error('Email or username already exists');
} else {
this.toastService.error('Failed to update profile');
}
throw error;
})
);
}
/**
* Clear cache (useful after logout or data updates)
*/
clearCache(): void {
this.dashboardCache.clear();
this.dashboardState.set(null);
this.historyState.set(null);
this.error.set(null);
}
/**
* Check if dashboard data is empty (no quizzes taken)
*/
isDashboardEmpty(): boolean {
const dashboard = this.dashboardState();
return dashboard ? dashboard.totalQuizzes === 0 : true;
}
}

View File

@@ -0,0 +1,154 @@
<div class="admin-category-list-container">
<mat-card>
<mat-card-header>
<mat-card-title>
<div class="header-title">
<h1>Manage Categories</h1>
<button
mat-raised-button
color="primary"
(click)="createCategory()">
<mat-icon>add</mat-icon>
Create Category
</button>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="60"></mat-spinner>
<p>Loading categories...</p>
</div>
}
<!-- Error State -->
@if (error() && !isLoading()) {
<div class="error-container">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Failed to load categories</h2>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="retry()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
</div>
}
<!-- Categories Table -->
@if (!isLoading() && !error()) {
@if (categories().length === 0) {
<div class="empty-container">
<mat-icon class="empty-icon">folder_open</mat-icon>
<h2>No Categories Yet</h2>
<p>Create your first category to get started.</p>
<button mat-raised-button color="primary" (click)="createCategory()">
<mat-icon>add</mat-icon>
Create Category
</button>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="categories()" class="categories-table">
<!-- Icon Column -->
<ng-container matColumnDef="icon">
<th mat-header-cell *matHeaderCellDef>Icon</th>
<td mat-cell *matCellDef="let category">
<div
class="category-icon-cell"
[style.background-color]="category.color || '#2196F3'">
<mat-icon>{{ category.icon || 'category' }}</mat-icon>
</div>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let category">
<div class="category-name">
<strong>{{ category.name }}</strong>
<span class="category-description">{{ category.description }}</span>
</div>
</td>
</ng-container>
<!-- Slug Column -->
<ng-container matColumnDef="slug">
<th mat-header-cell *matHeaderCellDef>Slug</th>
<td mat-cell *matCellDef="let category">
<code>{{ category.slug }}</code>
</td>
</ng-container>
<!-- Question Count Column -->
<ng-container matColumnDef="questionCount">
<th mat-header-cell *matHeaderCellDef>Questions</th>
<td mat-cell *matCellDef="let category">
<mat-chip>{{ category.questionCount || 0 }}</mat-chip>
</td>
</ng-container>
<!-- Guest Accessible Column -->
<ng-container matColumnDef="guestAccessible">
<th mat-header-cell *matHeaderCellDef>Access</th>
<td mat-cell *matCellDef="let category">
@if (category.guestAccessible) {
<mat-chip class="access-chip guest">
<mat-icon>public</mat-icon>
Guest
</mat-chip>
} @else {
<mat-chip class="access-chip auth">
<mat-icon>lock</mat-icon>
Auth
</mat-chip>
}
</td>
</ng-container>
<!-- Display Order Column -->
<ng-container matColumnDef="displayOrder">
<th mat-header-cell *matHeaderCellDef>Order</th>
<td mat-cell *matCellDef="let category">
{{ category.displayOrder ?? '-' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let category">
<div class="action-buttons">
<button
mat-icon-button
color="primary"
(click)="editCategory(category)"
matTooltip="Edit category"
aria-label="Edit category">
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="deleteCategory(category)"
matTooltip="Delete category"
aria-label="Delete category">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
}
}
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,236 @@
.admin-category-list-container {
max-width: 1400px;
margin: 24px auto;
padding: 0 16px;
mat-card {
mat-card-header {
margin-bottom: 24px;
.header-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
gap: 16px;
h1 {
margin: 0;
font-size: 28px;
font-weight: 500;
}
@media (max-width: 600px) {
h1 {
font-size: 24px;
}
}
}
}
mat-card-content {
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 20px;
p {
font-size: 16px;
color: rgba(0, 0, 0, 0.6);
}
}
// Error State
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
text-align: center;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #f44336;
}
h2 {
margin: 0;
font-size: 24px;
}
p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
}
// Empty State
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
text-align: center;
.empty-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: rgba(0, 0, 0, 0.3);
}
h2 {
margin: 0;
font-size: 24px;
}
p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
}
// Table Container
.table-container {
overflow-x: auto;
.categories-table {
width: 100%;
th {
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td, th {
padding: 16px 12px;
}
.category-icon-cell {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
mat-icon {
color: white;
font-size: 24px;
width: 24px;
height: 24px;
}
}
.category-name {
display: flex;
flex-direction: column;
gap: 4px;
strong {
font-size: 16px;
}
.category-description {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
code {
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
mat-chip {
&.access-chip {
display: inline-flex;
align-items: center;
gap: 4px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&.guest {
background-color: rgba(76, 175, 80, 0.1) !important;
color: #4CAF50 !important;
}
&.auth {
background-color: rgba(255, 152, 0, 0.1) !important;
color: #FF9800 !important;
}
}
}
.action-buttons {
display: flex;
gap: 4px;
}
// Responsive table
@media (max-width: 960px) {
// Hide less important columns on smaller screens
th:nth-child(3),
td:nth-child(3),
th:nth-child(6),
td:nth-child(6) {
display: none;
}
}
@media (max-width: 600px) {
th:nth-child(4),
td:nth-child(4) {
display: none;
}
}
}
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.admin-category-list-container {
.loading-container p,
.error-container p,
.empty-container p {
color: rgba(255, 255, 255, 0.7);
}
.categories-table {
.category-name .category-description {
color: rgba(255, 255, 255, 0.7);
}
code {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}

View File

@@ -0,0 +1,111 @@
import { Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CategoryService } from '../../../core/services/category.service';
import { Category } from '../../../core/models/category.model';
import { Subject, takeUntil } from 'rxjs';
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog';
@Component({
selector: 'app-admin-category-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatTableModule,
MatChipsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatTooltipModule
],
templateUrl: './admin-category-list.html',
styleUrls: ['./admin-category-list.scss']
})
export class AdminCategoryListComponent implements OnInit, OnDestroy {
private categoryService = inject(CategoryService);
private router = inject(Router);
private dialog = inject(MatDialog);
private destroy$ = new Subject<void>();
categories = this.categoryService.categories;
isLoading = this.categoryService.isLoading;
error = this.categoryService.error;
displayedColumns = ['icon', 'name', 'slug', 'questionCount', 'guestAccessible', 'displayOrder', 'actions'];
ngOnInit(): void {
this.loadCategories();
}
loadCategories(): void {
this.categoryService.getCategories(true)
.pipe(takeUntil(this.destroy$))
.subscribe();
}
createCategory(): void {
this.router.navigate(['/admin/categories/new']);
}
editCategory(category: Category): void {
this.router.navigate(['/admin/categories/edit', category.id]);
}
deleteCategory(category: Category): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '450px',
data: {
title: 'Delete Category',
message: `Are you sure you want to delete "${category.name}"?`,
warning: category.questionCount > 0
? `This category has ${category.questionCount} question(s). Deleting it may affect existing quizzes.`
: null,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmColor: 'warn'
}
});
dialogRef.afterClosed()
.pipe(takeUntil(this.destroy$))
.subscribe(confirmed => {
if (confirmed) {
this.performDelete(category);
}
});
}
private performDelete(category: Category): void {
this.categoryService.deleteCategory(category.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
// Category is automatically removed from state by the service
// Toast notification is also handled by the service
},
error: () => {
// Error toast is handled by the service
}
});
}
retry(): void {
this.loadCategories();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,179 @@
<div class="category-form-container">
<mat-card>
<mat-card-header>
<mat-card-title>
<div class="header-title">
<button mat-icon-button (click)="cancel()" aria-label="Go back">
<mat-icon>arrow_back</mat-icon>
</button>
<h1>{{ pageTitle() }}</h1>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="categoryForm" (ngSubmit)="onSubmit()">
<!-- Name Field -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Category Name</mat-label>
<input
matInput
formControlName="name"
placeholder="e.g., JavaScript Fundamentals"
required>
<mat-icon matPrefix>label</mat-icon>
<mat-error>{{ getErrorMessage('name') }}</mat-error>
</mat-form-field>
<!-- Slug Field with Preview -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Slug (URL-friendly)</mat-label>
<input
matInput
formControlName="slug"
placeholder="e.g., javascript-fundamentals"
required>
<mat-icon matPrefix>link</mat-icon>
<mat-hint>Preview: /categories/{{ slugPreview() }}</mat-hint>
<mat-error>{{ getErrorMessage('slug') }}</mat-error>
</mat-form-field>
<!-- Description Field -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
matInput
formControlName="description"
rows="4"
placeholder="Brief description of the category..."
required>
</textarea>
<mat-icon matPrefix>description</mat-icon>
<mat-hint align="end">
{{ categoryForm.get('description')?.value?.length || 0 }} / 500
</mat-hint>
<mat-error>{{ getErrorMessage('description') }}</mat-error>
</mat-form-field>
<!-- Icon and Color Row -->
<div class="form-row">
<!-- Icon Selector -->
<mat-form-field appearance="outline" class="half-width">
<mat-label>Icon</mat-label>
<mat-select formControlName="icon" required>
@for (icon of iconOptions; track icon.value) {
<mat-option [value]="icon.value">
<mat-icon>{{ icon.value }}</mat-icon>
<span>{{ icon.label }}</span>
</mat-option>
}
</mat-select>
<mat-icon matPrefix>{{ categoryForm.get('icon')?.value }}</mat-icon>
<mat-error>{{ getErrorMessage('icon') }}</mat-error>
</mat-form-field>
<!-- Color Picker -->
<mat-form-field appearance="outline" class="half-width">
<mat-label>Color</mat-label>
<mat-select formControlName="color" required>
@for (color of colorOptions; track color.value) {
<mat-option [value]="color.value">
<span class="color-option">
<span
class="color-preview"
[style.background-color]="color.value">
</span>
{{ color.label }}
</span>
</mat-option>
}
</mat-select>
<span
matPrefix
class="color-preview"
[style.background-color]="categoryForm.get('color')?.value">
</span>
<mat-error>{{ getErrorMessage('color') }}</mat-error>
</mat-form-field>
</div>
<!-- Display Order -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Display Order</mat-label>
<input
matInput
type="number"
formControlName="displayOrder"
placeholder="0"
min="0">
<mat-icon matPrefix>sort</mat-icon>
<mat-hint>Lower numbers appear first in the category list</mat-hint>
<mat-error>{{ getErrorMessage('displayOrder') }}</mat-error>
</mat-form-field>
<!-- Guest Accessible Checkbox -->
<div class="checkbox-field">
<mat-checkbox formControlName="guestAccessible">
<strong>Guest Accessible</strong>
</mat-checkbox>
<p class="checkbox-hint">
Allow guest users to access this category without authentication
</p>
</div>
<!-- Preview Card -->
<div class="preview-section">
<h3>Preview</h3>
<div class="preview-card">
<div
class="preview-icon"
[style.background-color]="categoryForm.get('color')?.value">
<mat-icon>{{ categoryForm.get('icon')?.value }}</mat-icon>
</div>
<div class="preview-content">
<h4>{{ categoryForm.get('name')?.value || 'Category Name' }}</h4>
<p>{{ categoryForm.get('description')?.value || 'Category description will appear here...' }}</p>
@if (categoryForm.get('guestAccessible')?.value) {
<span class="preview-badge">
<mat-icon>public</mat-icon>
Guest Accessible
</span>
} @else {
<span class="preview-badge locked">
<mat-icon>lock</mat-icon>
Login Required
</span>
}
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button
mat-stroked-button
type="button"
(click)="cancel()"
[disabled]="isSubmitting()">
<mat-icon>close</mat-icon>
Cancel
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="categoryForm.invalid || isSubmitting()">
@if (isSubmitting()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Saving...</span>
} @else {
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
<span>{{ isEditMode() ? 'Save Changes' : 'Create Category' }}</span>
}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,243 @@
.category-form-container {
max-width: 800px;
margin: 24px auto;
padding: 0 16px;
mat-card {
mat-card-header {
margin-bottom: 24px;
.header-title {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
h1 {
margin: 0;
font-size: 28px;
font-weight: 500;
}
}
}
mat-card-content {
form {
display: flex;
flex-direction: column;
gap: 16px;
.full-width {
width: 100%;
}
.form-row {
display: flex;
gap: 16px;
@media (max-width: 600px) {
flex-direction: column;
}
.half-width {
flex: 1;
min-width: 0;
}
}
// Icon prefix styling
mat-form-field {
mat-icon[matPrefix] {
margin-right: 8px;
color: rgba(0, 0, 0, 0.54);
}
.color-preview {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.12);
margin-right: 8px;
vertical-align: middle;
}
}
// Color option styling
.color-option {
display: flex;
align-items: center;
gap: 8px;
}
// Checkbox field
.checkbox-field {
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
mat-checkbox {
display: block;
margin-bottom: 8px;
}
.checkbox-hint {
margin: 0;
padding-left: 32px;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
}
}
// Preview section
.preview-section {
margin-top: 24px;
padding: 20px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 8px;
h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
}
.preview-card {
display: flex;
gap: 16px;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@media (max-width: 480px) {
flex-direction: column;
align-items: center;
text-align: center;
}
.preview-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 8px;
flex-shrink: 0;
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: white;
}
}
.preview-content {
flex: 1;
h4 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 500;
}
p {
margin: 0 0 12px 0;
font-size: 14px;
line-height: 1.5;
color: rgba(0, 0, 0, 0.6);
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background-color: rgba(76, 175, 80, 0.1);
color: #4CAF50;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&.locked {
background-color: rgba(255, 152, 0, 0.1);
color: #FF9800;
}
}
}
}
}
// Form actions
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.12);
@media (max-width: 480px) {
flex-direction: column-reverse;
button {
width: 100%;
}
}
button {
display: flex;
align-items: center;
gap: 8px;
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}
}
}
}
}
// Select option with icon styling
::ng-deep .mat-mdc-option {
mat-icon {
vertical-align: middle;
margin-right: 8px;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.category-form-container {
.checkbox-field,
.preview-section {
background-color: rgba(255, 255, 255, 0.05);
}
.preview-section .preview-card {
background-color: rgba(255, 255, 255, 0.08);
}
mat-form-field mat-icon[matPrefix] {
color: rgba(255, 255, 255, 0.7);
}
.checkbox-field .checkbox-hint {
color: rgba(255, 255, 255, 0.7);
}
.preview-section .preview-card .preview-content p {
color: rgba(255, 255, 255, 0.7);
}
}
}

View File

@@ -0,0 +1,230 @@
import { Component, inject, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CategoryService } from '../../../core/services/category.service';
import { CategoryFormData } from '../../../core/models/category.model';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-category-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule,
MatCheckboxModule,
MatSelectModule,
MatProgressSpinnerModule
],
templateUrl: './category-form.html',
styleUrls: ['./category-form.scss']
})
export class CategoryFormComponent implements OnInit, OnDestroy {
private fb = inject(FormBuilder);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>();
categoryForm!: FormGroup;
isEditMode = signal<boolean>(false);
categoryId = signal<string | null>(null);
isSubmitting = signal<boolean>(false);
// Icon options for dropdown
iconOptions = [
{ value: 'code', label: 'Code' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'language', label: 'Language' },
{ value: 'web', label: 'Web' },
{ value: 'storage', label: 'Storage' },
{ value: 'cloud', label: 'Cloud' },
{ value: 'category', label: 'Category' },
{ value: 'folder', label: 'Folder' },
{ value: 'description', label: 'Description' },
{ value: 'psychology', label: 'Psychology' },
{ value: 'science', label: 'Science' },
{ value: 'school', label: 'School' }
];
// Color options
colorOptions = [
{ value: '#2196F3', label: 'Blue' },
{ value: '#4CAF50', label: 'Green' },
{ value: '#FF9800', label: 'Orange' },
{ value: '#F44336', label: 'Red' },
{ value: '#9C27B0', label: 'Purple' },
{ value: '#00BCD4', label: 'Cyan' },
{ value: '#FFEB3B', label: 'Yellow' },
{ value: '#607D8B', label: 'Blue Grey' }
];
// Computed slug preview
slugPreview = computed(() => {
const name = this.categoryForm?.get('name')?.value || '';
return this.generateSlug(name);
});
pageTitle = computed(() => {
return this.isEditMode() ? 'Edit Category' : 'Create New Category';
});
ngOnInit(): void {
this.initializeForm();
// Check if we're in edit mode
this.route.params
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
if (params['id']) {
this.isEditMode.set(true);
this.categoryId.set(params['id']);
this.loadCategoryData(params['id']);
}
});
// Auto-generate slug from name
this.categoryForm.get('name')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(name => {
if (!this.isEditMode() && !this.categoryForm.get('slug')?.touched) {
this.categoryForm.patchValue({ slug: this.generateSlug(name) }, { emitEvent: false });
}
});
}
private initializeForm(): void {
this.categoryForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
slug: ['', [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)]],
description: ['', [Validators.required, Validators.minLength(10), Validators.maxLength(500)]],
icon: ['category', Validators.required],
color: ['#2196F3', Validators.required],
displayOrder: [0, [Validators.min(0)]],
guestAccessible: [false]
});
}
private loadCategoryData(id: string): void {
this.categoryService.getCategoryById(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (category) => {
this.categoryForm.patchValue({
name: category.name,
slug: category.slug,
description: category.description,
icon: category.icon || 'category',
color: category.color || '#2196F3',
displayOrder: category.displayOrder || 0,
guestAccessible: category.guestAccessible
});
},
error: () => {
this.router.navigate(['/admin/categories']);
}
});
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
onSubmit(): void {
if (this.categoryForm.invalid || this.isSubmitting()) {
this.categoryForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
const formData: CategoryFormData = this.categoryForm.value;
const request$ = this.isEditMode()
? this.categoryService.updateCategory(this.categoryId()!, formData)
: this.categoryService.createCategory(formData);
request$
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.isSubmitting.set(false);
this.router.navigate(['/admin/categories']);
},
error: () => {
this.isSubmitting.set(false);
}
});
}
cancel(): void {
this.router.navigate(['/admin/categories']);
}
getErrorMessage(controlName: string): string {
const control = this.categoryForm.get(controlName);
if (!control || !control.touched) {
return '';
}
if (control.hasError('required')) {
return `${this.getFieldLabel(controlName)} is required`;
}
if (control.hasError('minlength')) {
const minLength = control.getError('minlength').requiredLength;
return `Must be at least ${minLength} characters`;
}
if (control.hasError('maxlength')) {
const maxLength = control.getError('maxlength').requiredLength;
return `Must not exceed ${maxLength} characters`;
}
if (control.hasError('pattern') && controlName === 'slug') {
return 'Slug must contain only lowercase letters, numbers, and hyphens';
}
if (control.hasError('min')) {
return 'Must be a positive number';
}
return '';
}
private getFieldLabel(controlName: string): string {
const labels: { [key: string]: string } = {
name: 'Category name',
slug: 'Slug',
description: 'Description',
icon: 'Icon',
color: 'Color',
displayOrder: 'Display order'
};
return labels[controlName] || controlName;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, RouterModule, ActivatedRoute } from '@angular/router'; import { Router, RouterModule, ActivatedRoute } from '@angular/router';
@@ -12,6 +12,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { GuestService } from '../../../core/services/guest.service'; import { GuestService } from '../../../core/services/guest.service';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -31,17 +32,18 @@ import { GuestService } from '../../../core/services/guest.service';
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss' styleUrl: './login.scss'
}) })
export class LoginComponent { export class LoginComponent implements OnDestroy {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private authService = inject(AuthService); private authService = inject(AuthService);
private guestService = inject(GuestService); private guestService = inject(GuestService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>();
// Signals // Signals
isSubmitting = signal<boolean>(false); isSubmitting = signal<boolean>(false);
hidePassword = signal<boolean>(true); hidePassword = signal<boolean>(true);
returnUrl = signal<string>('/dashboard'); returnUrl = signal<string>('/categories');
isStartingGuestSession = signal<boolean>(false); isStartingGuestSession = signal<boolean>(false);
// Form // Form
@@ -56,13 +58,15 @@ export class LoginComponent {
}); });
// Get return URL from query params // Get return URL from query params
this.route.queryParams.subscribe(params => { this.route.queryParams
this.returnUrl.set(params['returnUrl'] || '/dashboard'); .pipe(takeUntil(this.destroy$))
}); .subscribe(params => {
this.returnUrl.set(params['returnUrl'] || '/categories');
});
// Redirect if already authenticated // Redirect if already authenticated
if (this.authService.isAuthenticated()) { if (this.authService.isAuthenticated()) {
this.router.navigate(['/dashboard']); this.router.navigate(['/categories']);
} }
} }
@@ -86,15 +90,17 @@ export class LoginComponent {
const { email, password, rememberMe } = this.loginForm.value; const { email, password, rememberMe } = this.loginForm.value;
this.authService.login(email, password, rememberMe, this.returnUrl()).subscribe({ this.authService.login(email, password, rememberMe, this.returnUrl())
next: () => { .pipe(takeUntil(this.destroy$))
this.isSubmitting.set(false); .subscribe({
// Navigation is handled by AuthService next: () => {
}, this.isSubmitting.set(false);
error: () => { // Navigation is handled by AuthService
this.isSubmitting.set(false); },
} error: () => {
}); this.isSubmitting.set(false);
}
});
} }
/** /**
@@ -139,14 +145,21 @@ export class LoginComponent {
*/ */
continueAsGuest(): void { continueAsGuest(): void {
this.isStartingGuestSession.set(true); this.isStartingGuestSession.set(true);
this.guestService.startSession().subscribe({ this.guestService.startSession()
next: () => { .pipe(takeUntil(this.destroy$))
this.isStartingGuestSession.set(false); .subscribe({
this.router.navigate(['/guest-welcome']); next: () => {
}, this.isStartingGuestSession.set(false);
error: () => { this.router.navigate(['/guest-welcome']);
this.isStartingGuestSession.set(false); },
} error: () => {
}); this.isStartingGuestSession.set(false);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
} }
} }

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, computed } from '@angular/core'; import { Component, inject, signal, computed, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
@@ -11,6 +11,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { StorageService } from '../../../core/services/storage.service'; import { StorageService } from '../../../core/services/storage.service';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
@@ -29,11 +30,12 @@ import { StorageService } from '../../../core/services/storage.service';
templateUrl: './register.html', templateUrl: './register.html',
styleUrl: './register.scss' styleUrl: './register.scss'
}) })
export class RegisterComponent { export class RegisterComponent implements OnDestroy {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private authService = inject(AuthService); private authService = inject(AuthService);
private storageService = inject(StorageService); private storageService = inject(StorageService);
private router = inject(Router); private router = inject(Router);
private destroy$ = new Subject<void>();
// Signals // Signals
isSubmitting = signal<boolean>(false); isSubmitting = signal<boolean>(false);
@@ -181,15 +183,17 @@ export class RegisterComponent {
const { username, email, password } = this.registerForm.value; const { username, email, password } = this.registerForm.value;
const guestSessionId = this.storageService.getGuestToken() || undefined; const guestSessionId = this.storageService.getGuestToken() || undefined;
this.authService.register(username, email, password, guestSessionId).subscribe({ this.authService.register(username, email, password, guestSessionId)
next: () => { .pipe(takeUntil(this.destroy$))
this.isSubmitting.set(false); .subscribe({
// Navigation handled by service next: () => {
}, this.isSubmitting.set(false);
error: () => { // Navigation handled by service
this.isSubmitting.set(false); },
} error: () => {
}); this.isSubmitting.set(false);
}
});
} }
/** /**
@@ -251,5 +255,10 @@ export class RegisterComponent {
const confirmControl = this.registerForm.get('confirmPassword'); const confirmControl = this.registerForm.get('confirmPassword');
return !!confirmControl?.touched && this.registerForm.hasError('passwordMismatch'); return !!confirmControl?.touched && this.registerForm.hasError('passwordMismatch');
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@@ -0,0 +1,216 @@
<div class="category-detail-container">
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="60"></mat-spinner>
<p class="loading-text">Loading category details...</p>
</div>
}
<!-- Error State -->
@if (error() && !isLoading()) {
<div class="error-container">
<mat-card class="error-card">
<mat-card-content>
<div class="error-content">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Oops! Something went wrong</h2>
<p>{{ error() }}</p>
<div class="error-actions">
<button mat-raised-button color="primary" (click)="retry()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
<button mat-stroked-button (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
Back to Categories
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
}
<!-- Category Detail Content -->
@if (category() && !isLoading() && !error()) {
<div class="category-content">
<!-- Breadcrumb Navigation -->
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol>
<li><a routerLink="/">Home</a></li>
<li><a routerLink="/categories">Categories</a></li>
<li aria-current="page">{{ category()?.name }}</li>
</ol>
</nav>
<!-- Category Header -->
<mat-card class="category-header">
<mat-card-content>
<div class="header-content">
<div class="category-icon-wrapper" [style.background-color]="category()?.color || '#2196F3'">
<mat-icon class="category-icon">{{ category()?.icon || 'category' }}</mat-icon>
</div>
<div class="header-text">
<h1>{{ category()?.name }}</h1>
<p class="description">{{ category()?.description }}</p>
<div class="metadata">
<mat-chip-set aria-label="Category metadata">
<mat-chip class="stat-chip">
<mat-icon>quiz</mat-icon>
{{ category()?.stats?.totalQuestions || category()?.questionCount || 0 }} Questions
</mat-chip>
@if (category()?.guestAccessible) {
<mat-chip class="stat-chip">
<mat-icon>public</mat-icon>
Guest Accessible
</mat-chip>
}
@if (!category()?.guestAccessible) {
<mat-chip class="stat-chip">
<mat-icon>lock</mat-icon>
Login Required
</mat-chip>
}
</mat-chip-set>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<!-- Statistics Section -->
@if (category()?.stats) {
<div class="statistics-section">
<h2>Statistics</h2>
<div class="stats-grid">
<mat-card class="stat-card">
<mat-card-content>
<div class="stat-icon-wrapper primary">
<mat-icon>quiz</mat-icon>
</div>
<h3>{{ category()?.stats?.totalQuestions || 0 }}</h3>
<p>Total Questions</p>
</mat-card-content>
</mat-card>
<mat-card class="stat-card">
<mat-card-content>
<div class="stat-icon-wrapper success">
<mat-icon>trending_up</mat-icon>
</div>
<h3>{{ category()?.stats?.averageAccuracy || 0 }}%</h3>
<p>Average Accuracy</p>
</mat-card-content>
</mat-card>
<mat-card class="stat-card">
<mat-card-content>
<div class="stat-icon-wrapper accent">
<mat-icon>people</mat-icon>
</div>
<h3>{{ category()?.stats?.totalAttempts || 0 }}</h3>
<p>Total Attempts</p>
</mat-card-content>
</mat-card>
<mat-card class="stat-card">
<mat-card-content>
<div class="stat-icon-wrapper warn">
<mat-icon>speed</mat-icon>
</div>
<h3>{{ category()?.stats?.averageScore || 0 }}%</h3>
<p>Average Score</p>
</mat-card-content>
</mat-card>
</div>
</div>
}
<!-- Difficulty Breakdown -->
@if (category()?.difficultyBreakdown) {
<div class="difficulty-section">
<h2>Difficulty Breakdown</h2>
<div class="difficulty-grid">
<mat-card class="difficulty-card easy">
<mat-card-content>
<mat-icon>sentiment_satisfied</mat-icon>
<h3>{{ category()?.difficultyBreakdown?.easy || 0 }}</h3>
<p>Easy</p>
</mat-card-content>
</mat-card>
<mat-card class="difficulty-card medium">
<mat-card-content>
<mat-icon>sentiment_neutral</mat-icon>
<h3>{{ category()?.difficultyBreakdown?.medium || 0 }}</h3>
<p>Medium</p>
</mat-card-content>
</mat-card>
<mat-card class="difficulty-card hard">
<mat-card-content>
<mat-icon>sentiment_very_dissatisfied</mat-icon>
<h3>{{ category()?.difficultyBreakdown?.hard || 0 }}</h3>
<p>Hard</p>
</mat-card-content>
</mat-card>
</div>
</div>
}
<!-- Question Preview -->
@if (category()?.questionPreview?.length) {
<div class="questions-section">
<h2>Sample Questions</h2>
<div class="questions-list">
@for (question of category()?.questionPreview; track question.id; let i = $index) {
<mat-card class="question-card">
<mat-card-content>
<div class="question-header">
<span class="question-number">#{{ i + 1 }}</span>
<mat-chip-set>
<mat-chip [class]="'difficulty-' + question.difficulty">
{{ question.difficulty }}
</mat-chip>
<mat-chip>{{ question.questionType }}</mat-chip>
</mat-chip-set>
</div>
<p class="question-text">{{ question.questionText }}</p>
</mat-card-content>
</mat-card>
}
</div>
</div>
}
<!-- Action Buttons -->
<div class="actions-section">
<h2>Ready to test your knowledge?</h2>
<p>Choose a difficulty level to start your quiz</p>
<div class="action-buttons">
<button mat-raised-button color="primary" class="start-button" (click)="startQuiz('easy')">
<mat-icon>play_arrow</mat-icon>
Start Easy Quiz
</button>
<button mat-raised-button color="primary" class="start-button" (click)="startQuiz('medium')">
<mat-icon>play_arrow</mat-icon>
Start Medium Quiz
</button>
<button mat-raised-button color="primary" class="start-button" (click)="startQuiz('hard')">
<mat-icon>play_arrow</mat-icon>
Start Hard Quiz
</button>
<button mat-raised-button color="accent" class="start-button" (click)="startQuiz('mixed')">
<mat-icon>shuffle</mat-icon>
Mixed Difficulty
</button>
</div>
<button mat-stroked-button class="back-button" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
Back to Categories
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,425 @@
.category-detail-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
@media (max-width: 768px) {
padding: 16px;
}
}
/* Loading State */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 24px;
.loading-text {
font-size: 16px;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
}
/* Error State */
.error-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
.error-card {
max-width: 500px;
width: 100%;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px;
gap: 16px;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mdc-theme-error, #f44336);
}
h2 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
p {
margin: 0;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
.error-actions {
display: flex;
gap: 12px;
margin-top: 16px;
@media (max-width: 480px) {
flex-direction: column;
width: 100%;
}
}
}
}
/* Breadcrumb */
.breadcrumb {
margin-bottom: 24px;
ol {
display: flex;
list-style: none;
padding: 0;
margin: 0;
gap: 8px;
flex-wrap: wrap;
li {
display: flex;
align-items: center;
font-size: 14px;
&:not(:last-child)::after {
content: '';
margin-left: 8px;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
a {
color: var(--mdc-theme-primary, #2196F3);
text-decoration: none;
transition: color 0.2s;
&:hover {
text-decoration: underline;
}
}
&[aria-current="page"] {
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
}
}
}
/* Category Header */
.category-header {
margin-bottom: 32px;
.header-content {
display: flex;
gap: 24px;
align-items: flex-start;
@media (max-width: 600px) {
flex-direction: column;
align-items: center;
text-align: center;
}
.category-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 12px;
flex-shrink: 0;
.category-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: white;
}
}
.header-text {
flex: 1;
h1 {
margin: 0 0 12px 0;
font-size: 32px;
font-weight: 600;
@media (max-width: 600px) {
font-size: 28px;
}
}
.description {
margin: 0 0 16px 0;
font-size: 16px;
line-height: 1.6;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
.metadata {
.stat-chip {
display: inline-flex;
align-items: center;
gap: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
}
}
}
/* Statistics Section */
.statistics-section,
.difficulty-section,
.questions-section,
.actions-section {
margin-bottom: 32px;
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
@media (max-width: 600px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 400px) {
grid-template-columns: 1fr;
}
.stat-card {
text-align: center;
mat-card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px !important;
.stat-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: white;
}
&.primary {
background-color: var(--mdc-theme-primary, #2196F3);
}
&.success {
background-color: #4caf50;
}
&.accent {
background-color: var(--mdc-theme-secondary, #ff4081);
}
&.warn {
background-color: #ff9800;
}
}
h3 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
p {
margin: 0;
font-size: 14px;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
}
}
}
}
/* Difficulty Breakdown */
.difficulty-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
.difficulty-card {
text-align: center;
mat-card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px !important;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
h3 {
margin: 0;
font-size: 32px;
font-weight: 600;
}
p {
margin: 0;
font-size: 16px;
font-weight: 500;
}
}
&.easy {
mat-icon, h3, p {
color: #4caf50;
}
}
&.medium {
mat-icon, h3, p {
color: #ff9800;
}
}
&.hard {
mat-icon, h3, p {
color: #f44336;
}
}
}
}
/* Questions Section */
.questions-list {
display: flex;
flex-direction: column;
gap: 16px;
.question-card {
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.question-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
align-items: flex-start;
}
.question-number {
font-size: 18px;
font-weight: 600;
color: var(--mdc-theme-primary, #2196F3);
}
mat-chip {
&.difficulty-Easy {
background-color: #4caf50 !important;
color: white !important;
}
&.difficulty-Medium {
background-color: #ff9800 !important;
color: white !important;
}
&.difficulty-Hard {
background-color: #f44336 !important;
color: white !important;
}
}
}
.question-text {
margin: 0;
font-size: 16px;
line-height: 1.6;
color: var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87));
}
}
}
/* Actions Section */
.actions-section {
text-align: center;
h2 {
font-size: 28px;
margin-bottom: 12px;
}
p {
font-size: 16px;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
margin-bottom: 24px;
}
.action-buttons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
.start-button {
padding: 16px 24px;
font-size: 16px;
font-weight: 500;
mat-icon {
margin-right: 8px;
}
}
}
.back-button {
mat-icon {
margin-right: 8px;
}
}
}

View File

@@ -0,0 +1,98 @@
import { Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDividerModule } from '@angular/material/divider';
import { CategoryService } from '../../../core/services/category.service';
import { CategoryDetail } from '../../../core/models/category.model';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-category-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatDividerModule
],
templateUrl: './category-detail.html',
styleUrls: ['./category-detail.scss']
})
export class CategoryDetailComponent implements OnInit, OnDestroy {
private categoryService = inject(CategoryService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private destroy$ = new Subject<void>();
category = signal<CategoryDetail | null>(null);
isLoading = signal<boolean>(true);
error = signal<string | null>(null);
ngOnInit(): void {
this.route.params
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
const categoryId = params['id'];
if (categoryId) {
this.loadCategoryDetails(categoryId);
}
});
}
private loadCategoryDetails(id: string): void {
this.isLoading.set(true);
this.error.set(null);
this.categoryService.getCategoryById(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (category) => {
this.category.set(category);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to load category details');
this.isLoading.set(false);
}
});
}
startQuiz(difficulty?: string): void {
const category = this.category();
if (!category) return;
// Navigate to quiz setup with category pre-selected
this.router.navigate(['/quiz/setup'], {
queryParams: {
category: category.id,
difficulty: difficulty || 'mixed'
}
});
}
goBack(): void {
this.router.navigate(['/categories']);
}
retry(): void {
const categoryId = this.route.snapshot.params['id'];
if (categoryId) {
this.loadCategoryDetails(categoryId);
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.categoryService.clearSelectedCategory();
}
}

View File

@@ -0,0 +1,169 @@
<div class="category-list-container">
<!-- Header -->
<div class="header">
<h1>Quiz Categories</h1>
<p class="subtitle">Choose a category to start your quiz journey</p>
</div>
<!-- Search Bar -->
<div class="search-section">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search categories</mat-label>
<input
matInput
[formControl]="searchControl"
placeholder="Search by name or description..."
aria-label="Search categories">
<mat-icon matPrefix>search</mat-icon>
@if (searchControl.value) {
<button
mat-icon-button
matSuffix
(click)="searchControl.setValue('')"
aria-label="Clear search">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div>
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<div class="skeleton-grid">
@for (item of [1,2,3,4,5,6]; track item) {
<mat-card class="skeleton-card">
<mat-card-content>
<div class="skeleton-icon"></div>
<div class="skeleton-title"></div>
<div class="skeleton-description"></div>
<div class="skeleton-meta"></div>
</mat-card-content>
</mat-card>
}
</div>
</div>
}
<!-- Error State -->
@if (error() && !isLoading()) {
<div class="error-container">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Oops! Something went wrong</h2>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="retry()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
</div>
}
<!-- Categories Grid -->
@if (!isLoading() && !error()) {
@if (isEmpty()) {
<!-- Empty State -->
<div class="empty-container">
<mat-icon class="empty-icon">folder_open</mat-icon>
<h2>No Categories Found</h2>
<p>
@if (searchControl.value) {
No categories match your search. Try a different keyword.
} @else {
No categories are available at the moment.
}
</p>
@if (searchControl.value) {
<button mat-raised-button color="primary" (click)="searchControl.setValue('')">
<mat-icon>clear</mat-icon>
Clear Search
</button>
}
</div>
} @else {
<!-- Category Cards -->
<div class="categories-grid" role="list">
@for (category of filteredCategories(); track category.id) {
<mat-card
class="category-card"
[class.locked]="isCategoryLocked(category)"
(click)="viewCategory(category)"
(keydown.enter)="viewCategory(category)"
(keydown.space)="viewCategory(category)"
tabindex="0"
role="listitem"
[attr.aria-label]="category.name + ' - ' + category.description">
<!-- Locked Badge -->
@if (isCategoryLocked(category)) {
<div class="locked-badge">
<mat-icon>lock</mat-icon>
<span>Sign up to access</span>
</div>
}
<mat-card-content>
<!-- Icon -->
<div class="category-icon" [style.background-color]="category.color || '#3f51b5'">
<mat-icon>{{ getCategoryIcon(category) }}</mat-icon>
</div>
<!-- Name -->
<h3 class="category-name">{{ category.name }}</h3>
<!-- Description -->
<p class="category-description">{{ category.description }}</p>
<!-- Meta Info -->
<div class="category-meta">
<div class="meta-item">
<mat-icon>quiz</mat-icon>
<span>{{ category.questionCount }} questions</span>
</div>
@if (!category.guestAccessible) {
<mat-chip class="auth-chip" disabled>
<mat-icon>person</mat-icon>
Members Only
</mat-chip>
}
</div>
<!-- Action Button -->
<div class="card-actions">
@if (!isCategoryLocked(category)) {
<button
mat-raised-button
color="primary"
(click)="viewCategory(category); $event.stopPropagation()"
aria-label="Start quiz in {{ category.name }}">
Start Quiz
<mat-icon>arrow_forward</mat-icon>
</button>
} @else {
<button
mat-stroked-button
disabled
aria-label="{{ category.name }} is locked">
<mat-icon>lock</mat-icon>
Locked
</button>
}
</div>
</mat-card-content>
</mat-card>
}
</div>
}
}
<!-- Guest Banner (if in guest mode) -->
@if (isGuestMode()) {
<div class="guest-info-banner">
<mat-icon>info</mat-icon>
<p>
You're browsing as a guest. Some categories require registration.
<a routerLink="/register">Sign up</a> to access all content!
</p>
</div>
}
</div>

View File

@@ -0,0 +1,381 @@
.category-list-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 1rem;
}
}
// Header
.header {
text-align: center;
margin-bottom: 2rem;
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--primary-color, #3f51b5);
@media (max-width: 768px) {
font-size: 2rem;
}
}
.subtitle {
font-size: 1.1rem;
color: var(--text-secondary, #666);
margin: 0;
}
}
// Search Section
.search-section {
margin-bottom: 2rem;
display: flex;
justify-content: center;
.search-field {
width: 100%;
max-width: 600px;
}
}
// Categories Grid
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 769px) and (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1025px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1400px) {
grid-template-columns: repeat(4, 1fr);
}
}
// Category Card
.category-card {
position: relative;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 12px;
overflow: hidden;
&:hover:not(.locked) {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
&:focus {
outline: 2px solid var(--primary-color, #3f51b5);
outline-offset: 2px;
}
&.locked {
cursor: not-allowed;
opacity: 0.7;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.05);
pointer-events: none;
}
}
mat-card-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
}
// Locked Badge
.locked-badge {
position: absolute;
top: 12px;
right: 12px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
z-index: 1;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// Category Icon
.category-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
}
// Category Name
.category-name {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #333);
}
// Category Description
.category-description {
font-size: 0.9rem;
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.7rem;
}
// Category Meta
.category-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.auth-chip {
height: 24px;
font-size: 0.75rem;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
// Card Actions
.card-actions {
margin-top: 0.5rem;
button {
width: 100%;
mat-icon {
margin-left: 0.5rem;
}
}
}
// Loading Skeleton
.loading-container {
margin: 2rem 0;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.skeleton-card {
mat-card-content {
padding: 1.5rem;
}
.skeleton-icon {
width: 60px;
height: 60px;
border-radius: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 1rem;
}
.skeleton-title {
width: 70%;
height: 24px;
border-radius: 4px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 0.75rem;
}
.skeleton-description {
width: 100%;
height: 40px;
border-radius: 4px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 0.75rem;
}
.skeleton-meta {
width: 50%;
height: 20px;
border-radius: 4px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
// Error State
.error-container {
text-align: center;
padding: 3rem 1rem;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--error-color, #f44336);
margin-bottom: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: var(--text-secondary, #666);
margin-bottom: 1.5rem;
}
button {
mat-icon {
margin-right: 0.5rem;
}
}
}
// Empty State
.empty-container {
text-align: center;
padding: 3rem 1rem;
.empty-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--text-secondary, #999);
margin-bottom: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: var(--text-secondary, #666);
margin-bottom: 1.5rem;
}
button {
mat-icon {
margin-right: 0.5rem;
}
}
}
// Guest Info Banner
.guest-info-banner {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
margin-top: 2rem;
mat-icon {
color: #1976d2;
flex-shrink: 0;
}
p {
margin: 0;
color: #1565c0;
font-size: 0.9rem;
a {
color: #1976d2;
font-weight: 600;
text-decoration: underline;
&:hover {
color: #0d47a1;
}
}
}
}

View File

@@ -0,0 +1,130 @@
import { Component, inject, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CategoryService } from '../../../core/services/category.service';
import { GuestService } from '../../../core/services/guest.service';
import { Category } from '../../../core/models/category.model';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-category-list',
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatChipsModule,
MatProgressSpinnerModule
],
templateUrl: './category-list.html',
styleUrl: './category-list.scss'
})
export class CategoryListComponent implements OnInit, OnDestroy {
private categoryService = inject(CategoryService);
private guestService = inject(GuestService);
private router = inject(Router);
private destroy$ = new Subject<void>();
// Signals
searchControl = new FormControl('');
filteredCategories = signal<Category[]>([]);
// Computed signals from service
readonly categories = this.categoryService.categoriesByDisplayOrder;
readonly isLoading = this.categoryService.isLoading;
readonly error = this.categoryService.error;
readonly isGuestMode = computed(() => this.guestService.guestState().isGuest);
// Local computed
readonly isEmpty = computed(() => {
return !this.isLoading() && this.filteredCategories().length === 0;
});
ngOnInit(): void {
// Load categories
this.categoryService.getCategories()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.filteredCategories.set(this.categories());
}
});
// Setup search
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe(query => {
this.handleSearch(query || '');
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Handle search input
*/
handleSearch(query: string): void {
if (!query.trim()) {
this.filteredCategories.set(this.categories());
return;
}
const results = this.categoryService.searchCategories(query);
this.filteredCategories.set(results);
}
/**
* Navigate to category detail
*/
viewCategory(category: Category): void {
if (!category.guestAccessible && this.isGuestMode()) {
return; // Don't navigate if locked for guest
}
this.router.navigate(['/categories', category.id]);
}
/**
* Check if category is locked for current user
*/
isCategoryLocked(category: Category): boolean {
return !category.guestAccessible && this.isGuestMode();
}
/**
* Get category icon or default
*/
getCategoryIcon(category: Category): string {
return category.icon || 'folder';
}
/**
* Retry loading categories
*/
retry(): void {
this.categoryService.getCategories(true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.filteredCategories.set(this.categories());
}
});
}
}

View File

@@ -0,0 +1,212 @@
<!-- Loading State -->
<div *ngIf="isLoading()" class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading your dashboard...</p>
</div>
<!-- Error State -->
<div *ngIf="error() && !isLoading()" class="error-container">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Failed to Load Dashboard</h2>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="loadDashboard()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
</div>
<!-- Dashboard Content -->
<div *ngIf="!isLoading() && !error()" class="dashboard-container">
<!-- Welcome Header -->
<div class="welcome-section">
<div class="welcome-content">
<h1>Welcome back, {{ username() }}! 👋</h1>
<p class="subtitle">Ready to test your knowledge today?</p>
</div>
<button mat-raised-button color="primary" class="start-quiz-btn" (click)="startNewQuiz()">
<mat-icon>play_arrow</mat-icon>
Start New Quiz
</button>
</div>
<!-- Empty State -->
<div *ngIf="isEmpty()" class="empty-state">
<mat-icon class="empty-icon">quiz</mat-icon>
<h2>Start Your Journey!</h2>
<p>You haven't taken any quizzes yet. Start your first quiz to see your progress here.</p>
<button mat-raised-button color="primary" (click)="startNewQuiz()">
<mat-icon>play_arrow</mat-icon>
Take Your First Quiz
</button>
</div>
<!-- Statistics Section -->
<div *ngIf="!isEmpty()" class="content-section">
<!-- Statistics Cards -->
<div class="stats-grid">
<mat-card *ngFor="let stat of statCards()" class="stat-card" [ngClass]="'card-' + stat.color">
<mat-card-content>
<div class="stat-header">
<mat-icon class="stat-icon">{{ stat.icon }}</mat-icon>
<span *ngIf="stat.badge" class="stat-badge">{{ stat.badge }}</span>
</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-description">{{ stat.description }}</div>
</mat-card-content>
</mat-card>
</div>
<!-- Category Performance Chart -->
<mat-card class="performance-card" *ngIf="topCategories().length > 0">
<mat-card-header>
<mat-card-title>
<mat-icon>bar_chart</mat-icon>
Top Categories Performance
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="category-performance">
<div
*ngFor="let category of topCategories()"
class="category-bar"
(click)="viewCategory(category.categoryId)"
>
<div class="category-info">
<span class="category-name">{{ category.categoryName }}</span>
<span class="category-stats">
{{ category.quizzesTaken }} {{ category.quizzesTaken === 1 ? 'quiz' : 'quizzes' }}
</span>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
[style.width.%]="category.accuracy"
[ngClass]="getAccuracyColor(category.accuracy)"
></div>
</div>
<span class="accuracy-value">{{ category.accuracy.toFixed(1) }}%</span>
</div>
</div>
<!-- Empty state for categories -->
<div *ngIf="topCategories().length === 0" class="empty-section">
<p>No category data available yet</p>
</div>
</mat-card-content>
</mat-card>
<!-- Recent Quiz Sessions -->
<mat-card class="recent-quizzes-card" *ngIf="recentSessions().length > 0">
<mat-card-header>
<mat-card-title>
<mat-icon>history</mat-icon>
Recent Quiz Sessions
</mat-card-title>
<button
mat-button
color="primary"
class="view-all-btn"
(click)="viewAllHistory()"
>
View All
<mat-icon>arrow_forward</mat-icon>
</button>
</mat-card-header>
<mat-card-content>
<div class="sessions-list">
<div
*ngFor="let session of recentSessions()"
class="session-item"
(click)="viewQuizResults(session.id)"
>
<div class="session-icon">
<mat-icon>quiz</mat-icon>
</div>
<div class="session-info">
<div class="session-title">{{ session.categoryName || 'Quiz' }}</div>
<div class="session-meta">
<span class="session-date">{{ formatDate(session.completedAt) }}</span>
<span class="session-separator"></span>
<span class="session-time">{{ formatDuration(session.timeSpent) }}</span>
<span class="session-separator" *ngIf="session.difficulty"></span>
<mat-chip *ngIf="session.difficulty" class="difficulty-chip">
{{ session.difficulty }}
</mat-chip>
</div>
</div>
<div class="session-score">
<span
class="score-value"
[ngClass]="getScoreColor(session.score, session.totalQuestions)"
>
{{ session.score }}/{{ session.totalQuestions }}
</span>
<span class="score-percentage">{{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%</span>
</div>
<mat-icon class="session-arrow">chevron_right</mat-icon>
</div>
</div>
<!-- Empty state for sessions -->
<div *ngIf="recentSessions().length === 0" class="empty-section">
<p>No recent quiz sessions</p>
</div>
</mat-card-content>
</mat-card>
<!-- Achievements Section -->
<mat-card class="achievements-card" *ngIf="achievements().length > 0">
<mat-card-header>
<mat-card-title>
<mat-icon>emoji_events</mat-icon>
Achievements & Badges
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="achievements-grid">
<div
*ngFor="let achievement of achievements()"
class="achievement-item"
[matTooltip]="achievement.description"
>
<div class="achievement-icon">
<mat-icon>{{ achievement.icon }}</mat-icon>
</div>
<div class="achievement-name">{{ achievement.name }}</div>
<div class="achievement-date" *ngIf="achievement.earnedAt">
{{ formatDate(achievement.earnedAt) }}
</div>
</div>
</div>
<!-- Empty state for achievements -->
<div *ngIf="achievements().length === 0" class="empty-section">
<p>No achievements earned yet. Keep taking quizzes to unlock badges!</p>
</div>
</mat-card-content>
</mat-card>
<!-- Quick Actions -->
<div class="quick-actions">
<button mat-stroked-button (click)="viewAllHistory()">
<mat-icon>history</mat-icon>
View Full History
</button>
<button mat-stroked-button [routerLink]="['/categories']">
<mat-icon>category</mat-icon>
Browse Categories
</button>
<button mat-stroked-button [routerLink]="['/bookmarks']">
<mat-icon>bookmark</mat-icon>
My Bookmarks
</button>
<button mat-stroked-button (click)="refresh()">
<mat-icon>refresh</mat-icon>
Refresh
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,711 @@
// Dashboard Container
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
animation: fadeIn 0.4s ease-in;
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1.5rem;
p {
color: var(--text-secondary);
font-size: 1rem;
}
}
// Error State
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
text-align: center;
padding: 2rem;
.error-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
color: var(--color-error);
}
h2 {
margin: 0;
color: var(--text-primary);
}
p {
color: var(--text-secondary);
margin: 0.5rem 0 1.5rem;
}
}
// Welcome Section
.welcome-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 2rem;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
border-radius: 12px;
color: white;
animation: slideDown 0.5s ease-out;
.welcome-content {
flex: 1;
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
}
.subtitle {
margin: 0;
opacity: 0.9;
font-size: 1.1rem;
}
}
.start-quiz-btn {
background-color: white;
color: var(--color-primary);
font-weight: 600;
padding: 0 2rem;
height: 48px;
mat-icon {
margin-right: 0.5rem;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
}
// Empty State
.empty-state {
text-align: center;
padding: 4rem 2rem;
animation: fadeIn 0.5s ease-in;
.empty-icon {
font-size: 5rem;
width: 5rem;
height: 5rem;
color: var(--color-primary);
opacity: 0.5;
margin-bottom: 1rem;
}
h2 {
margin: 0 0 1rem;
color: var(--text-primary);
}
p {
color: var(--text-secondary);
margin: 0 0 2rem;
font-size: 1.1rem;
}
button {
mat-icon {
margin-right: 0.5rem;
}
}
}
// Content Section
.content-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
// Statistics Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1rem;
}
.stat-card {
position: relative;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
animation: slideUp 0.5s ease-out;
animation-fill-mode: both;
@for $i from 1 through 4 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
mat-card-content {
padding: 1.5rem;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stat-icon {
font-size: 2.5rem;
width: 2.5rem;
height: 2.5rem;
opacity: 0.8;
}
.stat-badge {
background-color: rgba(0, 0, 0, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
line-height: 1;
}
.stat-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.8;
margin-bottom: 0.25rem;
}
.stat-description {
font-size: 0.875rem;
opacity: 0.6;
}
// Card Colors
&.card-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
&.card-success {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
&.card-warning {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
color: white;
}
&.card-accent {
background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);
color: white;
}
}
// Performance Card
.performance-card {
animation: slideUp 0.6s ease-out;
mat-card-header {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
mat-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
mat-icon {
color: var(--color-primary);
}
}
}
.category-performance {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.category-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
background-color: var(--bg-secondary);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background-color: var(--bg-hover);
transform: translateX(4px);
}
.category-info {
min-width: 200px;
display: flex;
flex-direction: column;
gap: 0.25rem;
.category-name {
font-weight: 600;
color: var(--text-primary);
}
.category-stats {
font-size: 0.875rem;
color: var(--text-secondary);
}
}
.progress-bar-container {
flex: 1;
height: 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
position: relative;
.progress-bar {
height: 100%;
border-radius: 6px;
transition: width 0.6s ease, background-color 0.3s ease;
animation: progressFill 1s ease-out;
&.success {
background: linear-gradient(90deg, #4caf50 0%, #81c784 100%);
}
&.warning {
background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%);
}
&.error {
background: linear-gradient(90deg, #f44336 0%, #e57373 100%);
}
}
}
.accuracy-value {
min-width: 60px;
text-align: right;
font-weight: 600;
font-size: 1.1rem;
color: var(--text-primary);
}
}
}
// Recent Quizzes Card
.recent-quizzes-card {
animation: slideUp 0.7s ease-out;
mat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
mat-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
mat-icon {
color: var(--color-primary);
}
}
.view-all-btn {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.sessions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.session-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
background-color: var(--bg-secondary);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background-color: var(--bg-hover);
transform: translateX(4px);
}
.session-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--color-primary-light);
mat-icon {
color: var(--color-primary);
}
}
.session-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.session-title {
font-weight: 600;
color: var(--text-primary);
font-size: 1rem;
}
.session-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
.session-separator {
opacity: 0.5;
}
.difficulty-chip {
height: 20px;
font-size: 0.75rem;
text-transform: capitalize;
}
}
}
.session-score {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
.score-value {
font-weight: 700;
font-size: 1.25rem;
&.success {
color: #4caf50;
}
&.warning {
color: #ff9800;
}
&.error {
color: #f44336;
}
}
.score-percentage {
font-size: 0.875rem;
color: var(--text-secondary);
}
}
.session-arrow {
color: var(--text-secondary);
opacity: 0.5;
transition: opacity 0.3s ease;
}
&:hover .session-arrow {
opacity: 1;
}
}
}
// Achievements Card
.achievements-card {
animation: slideUp 0.8s ease-out;
mat-card-header {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
mat-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
mat-icon {
color: #ffd700;
}
}
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
}
.achievement-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem;
border-radius: 8px;
background-color: var(--bg-secondary);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background-color: var(--bg-hover);
transform: translateY(-4px);
}
.achievement-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
mat-icon {
font-size: 2rem;
width: 2rem;
height: 2rem;
color: white;
}
}
.achievement-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.achievement-date {
font-size: 0.75rem;
color: var(--text-secondary);
}
}
}
// Quick Actions
.quick-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
padding-top: 1rem;
animation: fadeIn 0.9s ease-out;
button {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 160px;
justify-content: center;
}
}
// Empty Section
.empty-section {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-style: italic;
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes progressFill {
from {
width: 0;
}
}
// Responsive Design
@media (max-width: 1024px) {
.dashboard-container {
padding: 1.5rem;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.welcome-section {
flex-direction: column;
gap: 1.5rem;
text-align: center;
.welcome-content h1 {
font-size: 1.75rem;
}
.start-quiz-btn {
width: 100%;
}
}
}
@media (max-width: 768px) {
.dashboard-container {
padding: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.welcome-section {
padding: 1.5rem;
.welcome-content h1 {
font-size: 1.5rem;
}
}
.category-bar {
flex-direction: column;
align-items: flex-start !important;
.category-info {
width: 100%;
}
.progress-bar-container {
width: 100%;
}
.accuracy-value {
width: 100%;
text-align: left;
}
}
.session-item {
flex-wrap: wrap;
.session-score {
width: 100%;
flex-direction: row;
justify-content: space-between;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
margin-top: 0.5rem;
}
}
.achievements-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.quick-actions {
button {
width: 100%;
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.stat-card {
&.card-primary,
&.card-success,
&.card-warning,
&.card-accent {
.stat-badge {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
.welcome-section {
background: linear-gradient(135deg, var(--color-primary-dark) 0%, var(--color-primary) 100%);
}
}

View File

@@ -0,0 +1,252 @@
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { UserDashboard, } from '../../core/models/dashboard.model';
import { UserService } from '../../core/services/user.service';
import { AuthService } from '../../core/services';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatChipsModule,
MatTooltipModule,
RouterLink
],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private router = inject(Router);
// Signals
isLoading = signal<boolean>(true);
dashboard = signal<UserDashboard | null>(null);
error = signal<string | null>(null);
// Computed values
username = computed(() => {
try {
const state = (this.authService as any).authState();
return state?.user?.username || 'User';
} catch {
return 'User';
}
});
isEmpty = computed(() => {
const dash = this.dashboard();
return dash ? dash.totalQuizzes === 0 : true;
});
// Stat cards computed
statCards = computed(() => {
const dash = this.dashboard();
if (!dash) return [];
return [
{
title: 'Total Quizzes',
value: dash.totalQuizzes,
icon: 'quiz',
color: 'primary',
description: 'Quizzes completed'
},
{
title: 'Overall Accuracy',
value: `${dash.overallAccuracy.toFixed(1)}%`,
icon: 'percent',
color: 'success',
description: 'Correct answers'
},
{
title: 'Current Streak',
value: dash.currentStreak,
icon: 'local_fire_department',
color: 'warning',
description: 'Days in a row',
badge: dash.longestStreak > 0 ? `Best: ${dash.longestStreak}` : undefined
},
{
title: 'Questions Answered',
value: dash.totalQuestionsAnswered,
icon: 'question_answer',
color: 'accent',
description: 'Total questions'
}
];
});
// Top categories computed
topCategories = computed(() => {
const dash = this.dashboard();
if (!dash || !dash.categoryPerformance) return [];
return [...dash.categoryPerformance]
.sort((a, b) => b.accuracy - a.accuracy)
.slice(0, 5);
});
// Recent sessions computed
recentSessions = computed(() => {
const dash = this.dashboard();
if (!dash || !dash.recentQuizzes) return [];
return dash.recentQuizzes.slice(0, 5);
});
// Achievements computed
achievements = computed(() => {
const dash = this.dashboard();
return dash?.achievements || [];
});
ngOnInit(): void {
this.loadDashboard();
}
/**
* Load dashboard data
*/
loadDashboard(): void {
const state: any = (this.authService as any).authState();
const user = state?.user;
if (!user || !user.id) {
this.router.navigate(['/login']);
return;
}
this.isLoading.set(true);
this.error.set(null);
(this.userService as any).getDashboard(user.id).subscribe({
next: (data: UserDashboard) => {
this.dashboard.set(data);
this.isLoading.set(false);
},
error: (err: any) => {
console.error('Dashboard error:', err);
this.error.set('Failed to load dashboard');
this.isLoading.set(false);
}
});
}
/**
* Navigate to quiz setup
*/
startNewQuiz(): void {
this.router.navigate(['/quiz/setup']);
}
/**
* Navigate to category detail
*/
viewCategory(categoryId: string | undefined): void {
if (categoryId) {
this.router.navigate(['/categories', categoryId]);
}
}
/**
* Navigate to quiz results
*/
viewQuizResults(sessionId: string | undefined): void {
if (sessionId) {
this.router.navigate(['/quiz', sessionId, 'results']);
}
}
/**
* Navigate to full history
*/
viewAllHistory(): void {
this.router.navigate(['/history']);
}
/**
* Get color class for accuracy
*/
getAccuracyColor(accuracy: number): string {
if (accuracy >= 80) return 'success';
if (accuracy >= 60) return 'warning';
return 'error';
}
/**
* Get score display color
*/
getScoreColor(score: number, total: number): string {
const percentage = (score / total) * 100;
if (percentage >= 80) return 'success';
if (percentage >= 60) return 'warning';
return 'error';
}
/**
* Format time duration
*/
formatDuration(seconds: number | undefined): string {
if (!seconds) return '0s';
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes === 0) {
return `${secs}s`;
}
return `${minutes}m ${secs}s`;
}
/**
* Format date for display
*/
formatDate(dateString: string | undefined): string {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
/**
* Refresh dashboard data
*/
refresh(): void {
const state: any = (this.authService as any).authState();
const user = state?.user;
if (user) {
(this.userService as any).getDashboard(user.id, true).subscribe({
next: (data: UserDashboard) => {
this.dashboard.set(data);
}
});
}
}
}

View File

@@ -0,0 +1,214 @@
<!-- Loading State -->
<div *ngIf="isLoading()" class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading quiz history...</p>
</div>
<!-- Error State -->
<div *ngIf="error() && !isLoading()" class="error-container">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Failed to Load History</h2>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="loadHistory()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
</div>
<!-- History Content -->
<div *ngIf="!isLoading() && !error()" class="history-container">
<!-- Header -->
<div class="history-header">
<div class="header-title">
<h1>
<mat-icon>history</mat-icon>
Quiz History
</h1>
<p class="subtitle">View all your completed quizzes</p>
</div>
<button mat-raised-button color="primary" (click)="exportToCSV()" [disabled]="isEmpty()">
<mat-icon>download</mat-icon>
Export CSV
</button>
</div>
<!-- Filters and Sort -->
<mat-card class="filters-card">
<mat-card-content>
<div class="filters-row">
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Filter by Category</mat-label>
<mat-select [value]="selectedCategory()" (selectionChange)="onCategoryChange($event.value)">
<mat-option [value]="null">All Categories</mat-option>
<mat-option *ngFor="let category of categories()" [value]="category.id">
{{ category.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Sort By</mat-label>
<mat-select [value]="sortBy()" (selectionChange)="onSortChange($event.value)">
<mat-option value="date">Date (Newest First)</mat-option>
<mat-option value="score">Score (Highest First)</mat-option>
</mat-select>
</mat-form-field>
<button mat-icon-button (click)="refresh()" matTooltip="Refresh">
<mat-icon>refresh</mat-icon>
</button>
</div>
</mat-card-content>
</mat-card>
<!-- Empty State -->
<div *ngIf="isEmpty()" class="empty-state">
<mat-icon class="empty-icon">quiz</mat-icon>
<h2>No Quiz History</h2>
<p>You haven't completed any quizzes yet. Start your first quiz to see it here!</p>
<button mat-raised-button color="primary" [routerLink]="['/quiz/setup']">
<mat-icon>play_arrow</mat-icon>
Start a Quiz
</button>
</div>
<!-- Desktop Table View -->
<mat-card class="table-card desktop-only" *ngIf="!isEmpty()">
<table mat-table [dataSource]="history()" class="history-table">
<!-- Date Column -->
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let session">
{{ formatDate(session.completedAt || session.startedAt) }}
</td>
</ng-container>
<!-- Category Column -->
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let session">
{{ session.categoryName || 'Unknown' }}
</td>
</ng-container>
<!-- Score Column -->
<ng-container matColumnDef="score">
<th mat-header-cell *matHeaderCellDef>Score</th>
<td mat-cell *matCellDef="let session">
<span class="score-badge" [ngClass]="getScoreColor(session.score, session.totalQuestions)">
{{ session.score }}/{{ session.totalQuestions }}
<span class="percentage">({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%)</span>
</span>
</td>
</ng-container>
<!-- Time Column -->
<ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time Spent</th>
<td mat-cell *matCellDef="let session">
{{ formatDuration(session.timeSpent) }}
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let session">
<mat-chip [ngClass]="getStatusClass(session.status)">
{{ session.status === 'in_progress' ? 'In Progress' :
session.status === 'completed' ? 'Completed' :
'Abandoned' }}
</mat-chip>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let session">
<button
mat-icon-button
(click)="viewResults(session.id)"
matTooltip="View Results"
*ngIf="session.status === 'completed'"
>
<mat-icon>visibility</mat-icon>
</button>
<button
mat-icon-button
(click)="reviewQuiz(session.id)"
matTooltip="Review Quiz"
*ngIf="session.status === 'completed'"
>
<mat-icon>rate_review</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</mat-card>
<!-- Mobile Card View -->
<div class="mobile-cards mobile-only" *ngIf="!isEmpty()">
<mat-card *ngFor="let session of history()" class="history-card">
<mat-card-content>
<div class="card-header">
<div class="card-title">
<mat-icon>quiz</mat-icon>
<span>{{ session.categoryName || 'Unknown' }}</span>
</div>
<mat-chip [ngClass]="getStatusClass(session.status)">
{{ session.status === 'in_progress' ? 'In Progress' :
session.status === 'completed' ? 'Completed' :
'Abandoned' }}
</mat-chip>
</div>
<div class="card-details">
<div class="detail-row">
<mat-icon>calendar_today</mat-icon>
<span>{{ formatDate(session.completedAt || session.startedAt) }}</span>
</div>
<div class="detail-row">
<mat-icon>timer</mat-icon>
<span>{{ formatDuration(session.timeSpent) }}</span>
</div>
<div class="detail-row score-row">
<span class="score-label">Score:</span>
<span class="score-value" [ngClass]="getScoreColor(session.score, session.totalQuestions)">
{{ session.score }}/{{ session.totalQuestions }}
({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%)
</span>
</div>
</div>
<div class="card-actions" *ngIf="session.status === 'completed'">
<button mat-button color="primary" (click)="viewResults(session.id)">
<mat-icon>visibility</mat-icon>
View Results
</button>
<button mat-button color="accent" (click)="reviewQuiz(session.id)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Pagination -->
<mat-paginator
*ngIf="!isEmpty()"
[length]="totalItems()"
[pageSize]="pageSize()"
[pageIndex]="currentPage() - 1"
[pageSizeOptions]="[5, 10, 20, 50]"
(page)="onPageChange($event)"
showFirstLastButtons
>
</mat-paginator>
</div>

View File

@@ -0,0 +1,485 @@
// History Container
.history-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
animation: fadeIn 0.4s ease-in;
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1.5rem;
p {
color: var(--text-secondary);
font-size: 1rem;
}
}
// Error State
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
text-align: center;
padding: 2rem;
.error-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
color: var(--color-error);
}
h2 {
margin: 0;
color: var(--text-primary);
}
p {
color: var(--text-secondary);
margin: 0.5rem 0 1.5rem;
}
}
// Header
.history-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
animation: slideDown 0.5s ease-out;
.header-title {
h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
color: var(--text-primary);
mat-icon {
font-size: 2rem;
width: 2rem;
height: 2rem;
color: var(--color-primary);
}
}
.subtitle {
margin: 0;
color: var(--text-secondary);
font-size: 1rem;
}
}
button {
mat-icon {
margin-right: 0.5rem;
}
}
}
// Filters Card
.filters-card {
margin-bottom: 2rem;
animation: slideUp 0.5s ease-out;
.filters-row {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
.filter-field {
min-width: 200px;
flex: 1;
max-width: 300px;
}
button[mat-icon-button] {
margin-top: -8px;
}
}
}
// Empty State
.empty-state {
text-align: center;
padding: 4rem 2rem;
animation: fadeIn 0.6s ease-in;
.empty-icon {
font-size: 5rem;
width: 5rem;
height: 5rem;
color: var(--color-primary);
opacity: 0.5;
margin-bottom: 1rem;
}
h2 {
margin: 0 0 1rem;
color: var(--text-primary);
}
p {
color: var(--text-secondary);
margin: 0 0 2rem;
font-size: 1.1rem;
}
button {
mat-icon {
margin-right: 0.5rem;
}
}
}
// Desktop Table Card
.table-card {
animation: slideUp 0.6s ease-out;
overflow-x: auto;
margin-bottom: 1.5rem;
.history-table {
width: 100%;
th {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
td {
padding: 1rem;
color: var(--text-primary);
}
tr {
transition: background-color 0.2s ease;
&:hover {
background-color: var(--bg-hover);
}
}
.score-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 16px;
font-weight: 600;
font-size: 0.875rem;
&.success {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
&.warning {
background-color: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
&.error {
background-color: rgba(244, 67, 54, 0.1);
color: #f44336;
}
.percentage {
font-size: 0.75rem;
opacity: 0.8;
}
}
mat-chip {
font-size: 0.75rem;
height: 24px;
&.status-completed {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
&.status-in-progress {
background-color: rgba(33, 150, 243, 0.1);
color: #2196f3;
}
&.status-abandoned {
background-color: rgba(158, 158, 158, 0.1);
color: #9e9e9e;
}
}
button[mat-icon-button] {
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
}
}
// Mobile Cards
.mobile-cards {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
animation: slideUp 0.6s ease-out;
.history-card {
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
.card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 1.1rem;
color: var(--text-primary);
mat-icon {
color: var(--color-primary);
}
}
mat-chip {
font-size: 0.75rem;
height: 24px;
&.status-completed {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
&.status-in-progress {
background-color: rgba(33, 150, 243, 0.1);
color: #2196f3;
}
&.status-abandoned {
background-color: rgba(158, 158, 158, 0.1);
color: #9e9e9e;
}
}
}
.card-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
.detail-row {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-secondary);
font-size: 0.875rem;
mat-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-primary);
}
&.score-row {
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
margin-top: 0.25rem;
.score-label {
font-weight: 600;
color: var(--text-primary);
}
.score-value {
margin-left: auto;
font-weight: 700;
font-size: 1rem;
&.success {
color: #4caf50;
}
&.warning {
color: #ff9800;
}
&.error {
color: #f44336;
}
}
}
}
}
.card-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
button {
mat-icon {
margin-right: 0.25rem;
}
}
}
}
}
// Pagination
mat-paginator {
background-color: transparent;
animation: fadeIn 0.7s ease-out;
}
// Responsive Utilities
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Responsive Design
@media (max-width: 1024px) {
.history-container {
padding: 1.5rem;
}
.filters-card {
.filters-row {
.filter-field {
max-width: 100%;
}
}
}
}
@media (max-width: 768px) {
.history-container {
padding: 1rem;
}
.history-header {
flex-direction: column;
gap: 1rem;
.header-title {
h1 {
font-size: 1.5rem;
}
}
button {
width: 100%;
}
}
.filters-card {
.filters-row {
flex-direction: column;
.filter-field {
width: 100%;
max-width: 100%;
}
button[mat-icon-button] {
align-self: flex-start;
}
}
}
.desktop-only {
display: none;
}
.mobile-only {
display: flex;
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.table-card {
.history-table {
tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
}
.mobile-cards {
.history-card {
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
}
}

View File

@@ -0,0 +1,312 @@
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatChipsModule } from '@angular/material/chips';
import { UserService } from '../../core/services/user.service';
import { AuthService } from '../../core/services/auth.service';
import { CategoryService } from '../../core/services/category.service';
import { QuizHistoryResponse, PaginationInfo } from '../../core/models/dashboard.model';
import { QuizSession } from '../../core/models/quiz.model';
import { Category } from '../../core/models/category.model';
@Component({
selector: 'app-quiz-history',
standalone: true,
imports: [
CommonModule,
RouterLink,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTableModule,
MatPaginatorModule,
MatSelectModule,
MatFormFieldModule,
MatTooltipModule,
MatChipsModule
],
templateUrl: './quiz-history.component.html',
styleUrls: ['./quiz-history.component.scss']
})
export class QuizHistoryComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
// Signals
isLoading = signal<boolean>(true);
history = signal<QuizSession[]>([]);
pagination = signal<PaginationInfo | null>(null);
categories = signal<Category[]>([]);
error = signal<string | null>(null);
// Filter and sort state
currentPage = signal<number>(1);
pageSize = signal<number>(10);
selectedCategory = signal<string | null>(null);
sortBy = signal<'date' | 'score'>('date');
// Table columns
displayedColumns: string[] = ['date', 'category', 'score', 'time', 'status', 'actions'];
// Computed values
isEmpty = computed(() => this.history().length === 0 && !this.isLoading());
totalItems = computed(() => this.pagination()?.totalItems || 0);
ngOnInit(): void {
this.loadCategories();
this.loadHistoryFromRoute();
}
/**
* Load categories for filter
*/
loadCategories(): void {
this.categoryService.getCategories().subscribe({
next: (response: any) => {
this.categories.set(response.categories || []);
},
error: (err) => {
console.error('Error loading categories:', err);
}
});
}
/**
* Load history based on route query params
*/
loadHistoryFromRoute(): void {
this.route.queryParams.subscribe(params => {
const page = params['page'] ? parseInt(params['page'], 10) : 1;
const limit = params['limit'] ? parseInt(params['limit'], 10) : 10;
const category = params['category'] || null;
const sortBy = params['sortBy'] || 'date';
this.currentPage.set(page);
this.pageSize.set(limit);
this.selectedCategory.set(category);
this.sortBy.set(sortBy);
this.loadHistory();
});
}
/**
* Load quiz history
*/
loadHistory(): void {
const state: any = (this.authService as any).authState();
const user = state?.user;
if (!user || !user.id) {
this.router.navigate(['/login']);
return;
}
this.isLoading.set(true);
this.error.set(null);
(this.userService as any).getHistory(
user.id,
this.currentPage(),
this.pageSize(),
this.selectedCategory() || undefined,
this.sortBy()
).subscribe({
next: (response: QuizHistoryResponse) => {
this.history.set(response.sessions || []);
this.pagination.set(response.pagination);
this.isLoading.set(false);
},
error: (err: any) => {
console.error('History error:', err);
this.error.set('Failed to load quiz history');
this.isLoading.set(false);
}
});
}
/**
* Handle page change
*/
onPageChange(event: PageEvent): void {
this.currentPage.set(event.pageIndex + 1);
this.pageSize.set(event.pageSize);
this.updateUrlAndLoad();
}
/**
* Handle category filter change
*/
onCategoryChange(categoryId: string | null): void {
this.selectedCategory.set(categoryId);
this.currentPage.set(1); // Reset to first page
this.updateUrlAndLoad();
}
/**
* Handle sort change
*/
onSortChange(sortBy: 'date' | 'score'): void {
this.sortBy.set(sortBy);
this.currentPage.set(1); // Reset to first page
this.updateUrlAndLoad();
}
/**
* Update URL with query params and reload data
*/
updateUrlAndLoad(): void {
const queryParams: any = {
page: this.currentPage(),
limit: this.pageSize(),
sortBy: this.sortBy()
};
if (this.selectedCategory()) {
queryParams.category = this.selectedCategory();
}
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParamsHandling: 'merge'
});
}
/**
* View quiz results
*/
viewResults(sessionId: string | undefined): void {
if (sessionId) {
this.router.navigate(['/quiz', sessionId, 'results']);
}
}
/**
* Review quiz
*/
reviewQuiz(sessionId: string | undefined): void {
if (sessionId) {
this.router.navigate(['/quiz', sessionId, 'review']);
}
}
/**
* Format date
*/
formatDate(dateString: string | undefined): string {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Format duration
*/
formatDuration(seconds: number | undefined): string {
if (!seconds) return '0s';
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes === 0) {
return `${secs}s`;
}
return `${minutes}m ${secs}s`;
}
/**
* Get score color
*/
getScoreColor(score: number, total: number): string {
const percentage = (score / total) * 100;
if (percentage >= 80) return 'success';
if (percentage >= 60) return 'warning';
return 'error';
}
/**
* Get status badge class
*/
getStatusClass(status: string): string {
switch (status) {
case 'completed':
return 'status-completed';
case 'in_progress':
return 'status-in-progress';
case 'abandoned':
return 'status-abandoned';
default:
return '';
}
}
/**
* Export to CSV
*/
exportToCSV(): void {
if (this.history().length === 0) {
return;
}
// Create CSV header
const headers = ['Date', 'Category', 'Score', 'Total Questions', 'Percentage', 'Time Spent', 'Status'];
const csvRows = [headers.join(',')];
// Add data rows
this.history().forEach(session => {
const percentage = ((session.score / session.totalQuestions) * 100).toFixed(2);
const row = [
this.formatDate(session.completedAt || session.startedAt),
session.categoryName || 'Unknown',
session.score.toString(),
session.totalQuestions.toString(),
`${percentage}%`,
this.formatDuration(session.timeSpent),
session.status
];
csvRows.push(row.join(','));
});
// Create blob and download
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `quiz-history-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Refresh history
*/
refresh(): void {
this.loadHistory();
}
}

View File

@@ -0,0 +1,205 @@
<div class="quiz-question-container">
<!-- Progress Header -->
<div class="progress-header">
<div class="progress-info">
<span class="question-counter">
Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions() }}
</span>
@if (activeSession()?.quizType === 'timed') {
<div class="timer" [class.warning]="timeRemaining() < 60">
<mat-icon>timer</mat-icon>
<span>{{ formatTime(timeRemaining()) }}</span>
</div>
}
<div class="score-display">
<mat-icon>stars</mat-icon>
<span>Score: {{ currentScore() }}</span>
</div>
</div>
<mat-progress-bar
mode="determinate"
[value]="progress()"
class="progress-bar">
</mat-progress-bar>
</div>
<!-- Question Card -->
<mat-card class="question-card">
@if (currentQuestion(); as question) {
<!-- Question Header -->
<mat-card-header>
<div class="question-header">
<div class="question-meta">
<mat-chip class="type-chip">{{ questionTypeLabel() }}</mat-chip>
<mat-chip
class="difficulty-chip"
[style.background-color]="getDifficultyColor(question.difficulty) + '20'"
[style.color]="getDifficultyColor(question.difficulty)">
{{ question.difficulty | titlecase }}
</mat-chip>
<span class="points">{{ question.points }} points</span>
</div>
</div>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content>
<!-- Question Text -->
<div class="question-text">
<h2>{{ question.questionText }}</h2>
</div>
<!-- Answer Form -->
<form [formGroup]="answerForm" (ngSubmit)="submitAnswer()" class="answer-form">
<!-- Multiple Choice -->
@if (isMultipleChoice() && question.options) {
<mat-radio-group formControlName="answer" class="radio-group">
@for (option of question.options; track option) {
<mat-radio-button
[value]="option"
[disabled]="answerSubmitted()"
class="radio-option">
{{ option }}
</mat-radio-button>
}
</mat-radio-group>
}
<!-- True/False -->
@if (isTrueFalse()) {
<div class="true-false-buttons">
<button
type="button"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'true'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'true' })"
class="tf-button true-button">
<mat-icon>check_circle</mat-icon>
<span>True</span>
</button>
<button
type="button"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'false'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'false' })"
class="tf-button false-button">
<mat-icon>cancel</mat-icon>
<span>False</span>
</button>
</div>
}
<!-- Written Answer -->
@if (isWritten()) {
<mat-form-field appearance="outline" class="full-width">
<mat-label>Your Answer</mat-label>
<textarea
matInput
formControlName="answer"
[disabled]="answerSubmitted()"
rows="6"
placeholder="Type your answer here...">
</textarea>
<mat-hint>Be as detailed as possible</mat-hint>
</mat-form-field>
}
<!-- Answer Feedback -->
@if (answerSubmitted() && answerResult()) {
<div class="answer-feedback" [class.correct]="answerResult()?.isCorrect" [class.incorrect]="!answerResult()?.isCorrect">
<div class="feedback-header">
<mat-icon [style.color]="getFeedbackColor()">
{{ getFeedbackIcon() }}
</mat-icon>
<h3 [style.color]="getFeedbackColor()">
{{ getFeedbackMessage() }}
</h3>
</div>
@if (!answerResult()?.isCorrect) {
<div class="correct-answer">
<strong>Correct Answer:</strong>
<p>{{ answerResult()?.correctAnswer }}</p>
</div>
}
@if (answerResult()?.explanation) {
<div class="explanation">
<strong>Explanation:</strong>
<p>{{ answerResult()?.explanation }}</p>
</div>
}
<div class="points-earned">
<mat-icon>stars</mat-icon>
<span>Points earned: {{ answerResult()?.points }}</span>
</div>
</div>
}
<!-- Action Buttons -->
<div class="action-buttons">
@if (!answerSubmitted()) {
<button
type="submit"
mat-raised-button
color="primary"
[disabled]="!canSubmitAnswer()">
@if (isSubmittingAnswer()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Submitting...</span>
} @else {
<mat-icon>send</mat-icon>
<span>Submit Answer</span>
}
</button>
} @else {
<button
type="button"
mat-raised-button
color="primary"
(click)="nextQuestion()">
@if (isLastQuestion()) {
<mat-icon>flag</mat-icon>
<span>Complete Quiz</span>
} @else {
<mat-icon>arrow_forward</mat-icon>
<span>Next Question</span>
}
</button>
}
</div>
</form>
</mat-card-content>
} @else {
<!-- Loading State -->
<mat-card-content class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading question...</p>
</mat-card-content>
}
</mat-card>
<!-- Quiz Summary -->
<mat-card class="summary-card">
<h3>Quiz Progress</h3>
<div class="summary-stats">
<div class="stat-item">
<mat-icon>check_circle</mat-icon>
<span>{{ correctAnswers() }} Correct</span>
</div>
<div class="stat-item">
<mat-icon>cancel</mat-icon>
<span>{{ activeSession()?.incorrectAnswers || 0 }} Incorrect</span>
</div>
<div class="stat-item">
<mat-icon>stars</mat-icon>
<span>{{ currentScore() }} Points</span>
</div>
</div>
</mat-card>
</div>

View File

@@ -0,0 +1,515 @@
.quiz-question-container {
max-width: 900px;
margin: 0 auto;
padding: 16px;
// Progress Header
.progress-header {
margin-bottom: 24px;
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
.question-counter {
font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
}
.timer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: rgba(25, 118, 210, 0.1);
border-radius: 20px;
font-size: 16px;
font-weight: 500;
color: #1976d2;
&.warning {
background-color: rgba(244, 67, 54, 0.1);
color: #f44336;
animation: pulse 1s infinite;
mat-icon {
color: #f44336;
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.score-display {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: rgba(76, 175, 80, 0.1);
border-radius: 20px;
font-size: 16px;
font-weight: 500;
color: #4CAF50;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
.progress-bar {
height: 8px;
border-radius: 4px;
}
}
// Question Card
.question-card {
margin-bottom: 24px;
mat-card-header {
margin-bottom: 16px;
.question-header {
width: 100%;
.question-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.type-chip {
background-color: rgba(25, 118, 210, 0.1) !important;
color: #1976d2 !important;
font-weight: 500;
}
.difficulty-chip {
font-weight: 500;
}
.points {
margin-left: auto;
font-weight: 500;
color: rgba(0, 0, 0, 0.7);
}
}
}
}
mat-card-content {
.question-text {
margin: 24px 0;
h2 {
margin: 0;
font-size: 22px;
font-weight: 500;
line-height: 1.6;
color: rgba(0, 0, 0, 0.87);
}
}
.answer-form {
// Multiple Choice
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
margin: 24px 0;
.radio-option {
padding: 16px;
border: 2px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
transition: all 0.2s;
&:hover:not([disabled]) {
border-color: #1976d2;
background-color: rgba(25, 118, 210, 0.05);
}
&.mat-mdc-radio-checked {
border-color: #1976d2;
background-color: rgba(25, 118, 210, 0.08);
}
}
}
// True/False Buttons
.true-false-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin: 24px 0;
.tf-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
height: 120px;
font-size: 18px;
border: 2px solid rgba(0, 0, 0, 0.12);
background-color: white;
transition: all 0.2s;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
&:hover:not([disabled]) {
transform: translateY(-4px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&.selected {
border-width: 3px;
}
&.true-button {
&:hover:not([disabled]),
&.selected {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.08);
color: #4CAF50;
mat-icon {
color: #4CAF50;
}
}
}
&.false-button {
&:hover:not([disabled]),
&.selected {
border-color: #f44336;
background-color: rgba(244, 67, 54, 0.08);
color: #f44336;
mat-icon {
color: #f44336;
}
}
}
}
}
// Written Answer
.full-width {
width: 100%;
margin: 24px 0;
}
// Answer Feedback
.answer-feedback {
margin: 24px 0;
padding: 20px;
border-radius: 8px;
animation: slideIn 0.3s ease-out;
&.correct {
background-color: rgba(76, 175, 80, 0.1);
border: 2px solid #4CAF50;
}
&.incorrect {
background-color: rgba(244, 67, 54, 0.1);
border: 2px solid #f44336;
}
.feedback-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
}
h3 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
}
.correct-answer,
.explanation {
margin: 16px 0;
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 4px;
strong {
display: block;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.87);
}
p {
margin: 0;
line-height: 1.6;
color: rgba(0, 0, 0, 0.7);
}
}
.points-earned {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
font-weight: 500;
font-size: 16px;
color: #4CAF50;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
// Action Buttons
.action-buttons {
display: flex;
justify-content: center;
margin-top: 32px;
button {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
min-width: 200px;
font-size: 16px;
padding: 0 24px;
mat-spinner {
margin-right: 8px;
}
}
}
}
// Loading State
&.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 20px;
p {
font-size: 16px;
color: rgba(0, 0, 0, 0.6);
}
}
}
}
// Summary Card
.summary-card {
padding: 20px;
background-color: rgba(25, 118, 210, 0.05);
border: 1px solid rgba(25, 118, 210, 0.2);
h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
color: #1976d2;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background-color: white;
border-radius: 8px;
font-weight: 500;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
&:first-child {
color: #4CAF50;
}
&:nth-child(2) {
color: #f44336;
}
}
}
}
}
// Animations
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Responsive Design
@media (max-width: 768px) {
.progress-header {
.progress-info {
.question-counter {
font-size: 16px;
}
.timer,
.score-display {
font-size: 14px;
padding: 6px 12px;
}
}
}
.question-card {
mat-card-content {
.question-text h2 {
font-size: 18px;
}
.answer-form {
.true-false-buttons {
grid-template-columns: 1fr;
.tf-button {
height: 80px;
}
}
}
}
}
.summary-card {
.summary-stats {
grid-template-columns: 1fr;
}
}
}
@media (max-width: 600px) {
padding: 12px;
.progress-header {
.progress-info {
flex-direction: column;
align-items: flex-start;
.score-display {
margin-left: 0;
}
}
}
.question-card {
mat-card-header {
.question-meta {
.points {
margin-left: 0;
width: 100%;
}
}
}
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.quiz-question-container {
.progress-header {
.progress-info {
.question-counter {
color: rgba(255, 255, 255, 0.87);
}
}
}
.question-card {
mat-card-content {
.question-text h2 {
color: rgba(255, 255, 255, 0.87);
}
.answer-form {
.radio-group .radio-option {
border-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.05);
}
.true-false-buttons .tf-button {
border-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.05);
}
.answer-feedback {
.correct-answer,
.explanation {
background-color: rgba(0, 0, 0, 0.2);
strong {
color: rgba(255, 255, 255, 0.87);
}
p {
color: rgba(255, 255, 255, 0.7);
}
}
}
}
}
}
.summary-card {
.summary-stats .stat-item {
background-color: rgba(255, 255, 255, 0.05);
}
}
}
}

View File

@@ -0,0 +1,378 @@
import { Component, OnInit, OnDestroy, inject, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatRadioModule } from '@angular/material/radio';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatDividerModule } from '@angular/material/divider';
import { Subject, takeUntil, interval } from 'rxjs';
import { QuizService } from '../../../core/services/quiz.service';
import { StorageService } from '../../../core/services/storage.service';
import { QuizAnswerSubmission, QuizAnswerResponse } from '../../../core/models/quiz.model';
import { Question } from '../../../core/models/question.model';
@Component({
selector: 'app-quiz-question',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatCardModule,
MatRadioModule,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatChipsModule,
MatFormFieldModule,
MatInputModule,
MatDividerModule
],
templateUrl: './quiz-question.html',
styleUrls: ['./quiz-question.scss']
})
export class QuizQuestionComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly quizService = inject(QuizService);
private readonly storageService = inject(StorageService);
private readonly destroy$ = new Subject<void>();
// Session ID from route
sessionId: string = '';
// Form
answerForm!: FormGroup;
// State signals
readonly activeSession = this.quizService.activeSession;
readonly isSubmittingAnswer = this.quizService.isSubmittingAnswer;
readonly questions = this.quizService.questions;
// Current question state
readonly currentQuestionIndex = computed(() => this.activeSession()?.currentQuestionIndex ?? 0);
readonly totalQuestions = computed(() => this.activeSession()?.totalQuestions ?? 0);
readonly currentQuestion = signal<Question | null>(null);
// Answer feedback state
readonly answerSubmitted = signal<boolean>(false);
readonly answerResult = signal<QuizAnswerResponse | null>(null);
readonly showExplanation = signal<boolean>(false);
// Timer state (for timed quizzes)
readonly timeRemaining = signal<number>(0); // in seconds
readonly timerRunning = signal<boolean>(false);
// Progress
readonly progress = computed(() => {
const total = this.totalQuestions();
const current = this.currentQuestionIndex();
return total > 0 ? (current / total) * 100 : 0;
});
readonly currentScore = computed(() => this.activeSession()?.score ?? 0);
readonly correctAnswers = computed(() => this.activeSession()?.correctAnswers ?? 0);
// Computed values
readonly isLastQuestion = computed(() => {
return this.currentQuestionIndex() >= this.totalQuestions() - 1;
});
readonly canSubmitAnswer = computed(() => {
return this.answerForm?.valid && !this.answerSubmitted() && !this.isSubmittingAnswer();
});
readonly questionTypeLabel = computed(() => {
const type = this.currentQuestion()?.questionType;
switch (type) {
case 'multiple_choice': return 'Multiple Choice';
case 'true_false': return 'True/False';
case 'written': return 'Written Answer';
default: return '';
}
});
ngOnInit(): void {
this.sessionId = this.route.snapshot.params['sessionId'];
if (!this.sessionId) {
this.router.navigate(['/quiz/setup']);
return;
}
this.initForm();
this.loadQuizSession();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.timerRunning.set(false);
}
/**
* Initialize answer form
*/
private initForm(): void {
this.answerForm = this.fb.group({
answer: ['', Validators.required]
});
}
/**
* Load quiz session and questions
*/
private loadQuizSession(): void {
// Check if we have an active session with questions already loaded
const activeSession = this.activeSession();
const questions = this.questions();
if (activeSession && activeSession.id === this.sessionId && questions.length > 0) {
// Session and questions already loaded from quiz start
if (activeSession.status === 'completed') {
this.router.navigate(['/quiz', this.sessionId, 'results']);
return;
}
this.loadCurrentQuestion();
// Start timer for timed quizzes
if (activeSession.quizType === 'timed' && activeSession.timeSpent) {
const timeLimit = this.calculateTimeLimit(activeSession.totalQuestions);
const remaining = (timeLimit * 60) - (activeSession.timeSpent || 0);
if (remaining > 0) {
this.startTimer(remaining);
}
}
} else {
// Try to restore session from server
this.restoreSessionFromServer();
}
}
/**
* Restore session from server if page was refreshed
*/
private restoreSessionFromServer(): void {
this.quizService.restoreSession(this.sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: ({ session, hasQuestions }) => {
if (session.status === 'completed') {
this.router.navigate(['/quiz', this.sessionId, 'results']);
return;
}
if (!hasQuestions) {
// Questions need to be fetched separately
// For now, redirect to setup as we can't continue without questions
// In a production app, you would fetch questions from the backend
console.warn('Session restored but questions not available');
this.router.navigate(['/quiz/setup']);
return;
}
// Session restored successfully
this.loadCurrentQuestion();
// Start timer for timed quizzes
if (session.quizType === 'timed' && session.timeSpent) {
const timeLimit = this.calculateTimeLimit(session.totalQuestions);
const remaining = (timeLimit * 60) - (session.timeSpent || 0);
if (remaining > 0) {
this.startTimer(remaining);
}
}
},
error: () => {
// Session not found or error occurred
this.router.navigate(['/quiz/setup']);
}
});
}
/**
* Calculate time limit for timed quiz
*/
private calculateTimeLimit(questionCount: number): number {
return questionCount * 1.5; // 1.5 minutes per question
}
/**
* Load current question based on session index
*/
private loadCurrentQuestion(): void {
const index = this.currentQuestionIndex();
const allQuestions = this.questions();
if (index < allQuestions.length) {
this.currentQuestion.set(allQuestions[index]);
this.answerSubmitted.set(false);
this.answerResult.set(null);
this.showExplanation.set(false);
this.answerForm.reset();
this.answerForm.enable();
}
}
/**
* Start timer for timed quiz
*/
private startTimer(initialTime: number): void {
this.timeRemaining.set(initialTime);
this.timerRunning.set(true);
interval(1000)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
const current = this.timeRemaining();
if (current > 0 && this.timerRunning()) {
this.timeRemaining.set(current - 1);
} else if (current === 0) {
this.timerRunning.set(false);
this.autoCompleteQuiz();
}
});
}
/**
* Auto-complete quiz when timer expires
*/
private autoCompleteQuiz(): void {
this.quizService.completeQuiz(this.sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe();
}
/**
* Submit answer
*/
submitAnswer(): void {
if (!this.canSubmitAnswer()) {
return;
}
const question = this.currentQuestion();
if (!question) return;
const answer = this.answerForm.get('answer')?.value;
const submission: QuizAnswerSubmission = {
questionId: question.id,
answer: answer,
quizSessionId: this.sessionId
};
this.quizService.submitAnswer(submission)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.answerSubmitted.set(true);
this.answerResult.set(response);
this.showExplanation.set(true);
this.answerForm.disable();
},
error: (error) => {
console.error('Failed to submit answer:', error);
}
});
}
/**
* Move to next question
*/
nextQuestion(): void {
if (this.isLastQuestion()) {
// Complete quiz
this.completeQuiz();
} else {
// Load next question
this.loadCurrentQuestion();
}
}
/**
* Complete quiz
*/
completeQuiz(): void {
this.timerRunning.set(false);
this.quizService.completeQuiz(this.sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe();
}
/**
* Get feedback icon based on answer correctness
*/
getFeedbackIcon(): string {
const result = this.answerResult();
return result?.isCorrect ? 'check_circle' : 'cancel';
}
/**
* Get feedback color
*/
getFeedbackColor(): string {
const result = this.answerResult();
return result?.isCorrect ? '#4CAF50' : '#f44336';
}
/**
* Get feedback message
*/
getFeedbackMessage(): string {
const result = this.answerResult();
if (!result) return '';
return result.isCorrect ? 'Correct!' : 'Incorrect';
}
/**
* Format time remaining (MM:SS)
*/
formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* Get difficulty color
*/
getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'easy': return '#4CAF50';
case 'medium': return '#FF9800';
case 'hard': return '#f44336';
default: return '#9E9E9E';
}
}
/**
* Check if answer is multiple choice
*/
isMultipleChoice(): boolean {
return this.currentQuestion()?.questionType === 'multiple_choice';
}
/**
* Check if answer is true/false
*/
isTrueFalse(): boolean {
return this.currentQuestion()?.questionType === 'true_false';
}
/**
* Check if answer is written
*/
isWritten(): boolean {
return this.currentQuestion()?.questionType === 'written';
}
}

View File

@@ -0,0 +1,337 @@
<div class="quiz-results-container">
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading results...</p>
</div>
}
<!-- Results Content -->
@if (!isLoading() && results()) {
<!-- Confetti Animation -->
@if (showConfetti()) {
<div class="confetti-container">
@for (i of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]; track i) {
<div class="confetti" [style.left.%]="i * 5" [style.animation-delay.s]="i * 0.1"></div>
}
</div>
}
<div class="results-content">
<!-- Header Section -->
<div class="results-header">
<div class="header-icon" [class]="performanceLevel()">
@if (performanceLevel() === 'excellent') {
<mat-icon>emoji_events</mat-icon>
} @else if (performanceLevel() === 'good') {
<mat-icon>thumb_up</mat-icon>
} @else if (performanceLevel() === 'average') {
<mat-icon>trending_up</mat-icon>
} @else {
<mat-icon>school</mat-icon>
}
</div>
<h1 class="results-title">Quiz Completed!</h1>
<p class="performance-message" [class]="performanceLevel()">
{{ performanceMessage() }}
</p>
</div>
<!-- Score Card -->
<mat-card class="score-card" [class]="performanceLevel()">
<mat-card-content>
<div class="score-display">
<div class="score-circle">
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" class="score-bg"></circle>
<circle
cx="50"
cy="50"
r="45"
class="score-progress"
[style.stroke-dashoffset]="283 - (283 * scorePercentage() / 100)"
></circle>
</svg>
<div class="score-text">
<span class="score-number">{{ scorePercentage() }}%</span>
<span class="score-label">Score</span>
</div>
</div>
<div class="score-details">
<div class="score-stat">
<mat-icon class="stat-icon success">check_circle</mat-icon>
<div>
<div class="stat-value">{{ results()!.correctAnswers }}</div>
<div class="stat-label">Correct</div>
</div>
</div>
<div class="score-stat">
<mat-icon class="stat-icon error">cancel</mat-icon>
<div>
<div class="stat-value">{{ results()!.incorrectAnswers }}</div>
<div class="stat-label">Incorrect</div>
</div>
</div>
@if (results()!.skippedAnswers > 0) {
<div class="score-stat">
<mat-icon class="stat-icon warning">remove_circle</mat-icon>
<div>
<div class="stat-value">{{ results()!.skippedAnswers }}</div>
<div class="stat-label">Skipped</div>
</div>
</div>
}
</div>
</div>
<mat-divider></mat-divider>
<div class="quiz-metadata">
<div class="metadata-item">
<mat-icon>timer</mat-icon>
<span>Time: {{ formatTime(results()!.timeSpent) }}</span>
</div>
<div class="metadata-item">
<mat-icon>quiz</mat-icon>
<span>{{ results()!.totalQuestions }} Questions</span>
</div>
<div class="metadata-item">
@if (results()!.isPassed) {
<mat-icon class="success">verified</mat-icon>
<span class="success">Passed</span>
} @else {
<mat-icon class="error">close</mat-icon>
<span class="error">Not Passed</span>
}
</div>
</div>
</mat-card-content>
</mat-card>
<!-- Pie Chart -->
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>Performance Breakdown</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="pie-chart-container">
<div class="pie-chart">
<svg viewBox="0 0 200 200">
<!-- Correct answers slice -->
<circle
cx="100"
cy="100"
r="80"
fill="transparent"
stroke="#4caf50"
stroke-width="40"
[style.stroke-dasharray]="chartPercentages().correct * 5.03 + ' 503'"
transform="rotate(-90 100 100)"
></circle>
<!-- Incorrect answers slice -->
<circle
cx="100"
cy="100"
r="80"
fill="transparent"
stroke="#f44336"
stroke-width="40"
[style.stroke-dasharray]="chartPercentages().incorrect * 5.03 + ' 503'"
[style.stroke-dashoffset]="-chartPercentages().correct * 5.03"
transform="rotate(-90 100 100)"
></circle>
<!-- Skipped answers slice (if any) -->
@if (chartPercentages().skipped > 0) {
<circle
cx="100"
cy="100"
r="80"
fill="transparent"
stroke="#ff9800"
stroke-width="40"
[style.stroke-dasharray]="chartPercentages().skipped * 5.03 + ' 503'"
[style.stroke-dashoffset]="-(chartPercentages().correct + chartPercentages().incorrect) * 5.03"
transform="rotate(-90 100 100)"
></circle>
}
</svg>
<div class="chart-center">
<span class="chart-total">{{ results()!.totalQuestions }}</span>
<span class="chart-label">Questions</span>
</div>
</div>
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color correct"></span>
<span class="legend-label">Correct ({{ chartData().correct }})</span>
</div>
<div class="legend-item">
<span class="legend-color incorrect"></span>
<span class="legend-label">Incorrect ({{ chartData().incorrect }})</span>
</div>
@if (chartData().skipped > 0) {
<div class="legend-item">
<span class="legend-color skipped"></span>
<span class="legend-label">Skipped ({{ chartData().skipped }})</span>
</div>
}
</div>
</div>
</mat-card-content>
</mat-card>
<!-- Questions Review List -->
<mat-card class="questions-card">
<mat-card-header>
<mat-card-title>Question Review</mat-card-title>
<mat-card-subtitle>Review all questions and answers</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="questions-list">
@for (question of results()!.questions; track question.questionId; let i = $index) {
<div class="question-item" [class.incorrect]="!question.isCorrect">
<div class="question-header">
<div class="question-number">
<span>{{ i + 1 }}</span>
@if (question.isCorrect) {
<mat-icon class="status-icon success">check_circle</mat-icon>
} @else {
<mat-icon class="status-icon error">cancel</mat-icon>
}
</div>
<div class="question-meta">
<mat-chip class="type-chip">{{ getQuestionTypeText(question.questionType) }}</mat-chip>
<span class="points">{{ question.points }} pts</span>
</div>
</div>
<div class="question-text">{{ question.questionText }}</div>
<div class="answer-section">
<div class="answer-row">
<span class="answer-label">Your Answer:</span>
<span class="answer-value" [class.incorrect]="!question.isCorrect">
{{ question.userAnswer || 'Not answered' }}
</span>
</div>
@if (!question.isCorrect) {
<div class="answer-row correct">
<span class="answer-label">Correct Answer:</span>
<span class="answer-value correct">{{ question.correctAnswer }}</span>
</div>
}
</div>
@if (question.explanation) {
<div class="explanation">
<mat-icon>info</mat-icon>
<p>{{ question.explanation }}</p>
</div>
}
</div>
}
</div>
</mat-card-content>
</mat-card>
<!-- Action Buttons -->
<div class="action-buttons">
<button
mat-raised-button
color="primary"
(click)="retakeQuiz()"
class="action-btn"
>
<mat-icon>refresh</mat-icon>
Retake Quiz
</button>
@if (hasIncorrectAnswers()) {
<button
mat-raised-button
color="accent"
(click)="reviewIncorrect()"
class="action-btn"
>
<mat-icon>rate_review</mat-icon>
Review Incorrect Answers
</button>
}
<button
mat-raised-button
(click)="goToDashboard()"
class="action-btn"
>
<mat-icon>dashboard</mat-icon>
Return to Dashboard
</button>
</div>
<!-- Social Share Section -->
<mat-card class="share-card">
<mat-card-header>
<mat-card-title>Share Your Results</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="share-buttons">
<button
mat-mini-fab
color="primary"
(click)="shareResults('twitter')"
matTooltip="Share on Twitter"
class="share-btn twitter"
>
<mat-icon>
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M22.46,6C21.69,6.35 20.86,6.58 20,6.69C20.88,6.16 21.56,5.32 21.88,4.31C21.05,4.81 20.13,5.16 19.16,5.36C18.37,4.5 17.26,4 16,4C13.65,4 11.73,5.92 11.73,8.29C11.73,8.63 11.77,8.96 11.84,9.27C8.28,9.09 5.11,7.38 3,4.79C2.63,5.42 2.42,6.16 2.42,6.94C2.42,8.43 3.17,9.75 4.33,10.5C3.62,10.5 2.96,10.3 2.38,10C2.38,10 2.38,10 2.38,10.03C2.38,12.11 3.86,13.85 5.82,14.24C5.46,14.34 5.08,14.39 4.69,14.39C4.42,14.39 4.15,14.36 3.89,14.31C4.43,16 6,17.26 7.89,17.29C6.43,18.45 4.58,19.13 2.56,19.13C2.22,19.13 1.88,19.11 1.54,19.07C3.44,20.29 5.70,21 8.12,21C16,21 20.33,14.46 20.33,8.79C20.33,8.6 20.33,8.42 20.32,8.23C21.16,7.63 21.88,6.87 22.46,6Z" />
</svg>
</mat-icon>
</button>
<button
mat-mini-fab
color="primary"
(click)="shareResults('linkedin')"
matTooltip="Share on LinkedIn"
class="share-btn linkedin"
>
<mat-icon>
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19 3A2 2 0 0 1 21 5V19A2 2 0 0 1 19 21H5A2 2 0 0 1 3 19V5A2 2 0 0 1 5 3H19M18.5 18.5V13.2A3.26 3.26 0 0 0 15.24 9.94C14.39 9.94 13.4 10.46 12.92 11.24V10.13H10.13V18.5H12.92V13.57C12.92 12.8 13.54 12.17 14.31 12.17A1.4 1.4 0 0 1 15.71 13.57V18.5H18.5M6.88 8.56A1.68 1.68 0 0 0 8.56 6.88C8.56 5.95 7.81 5.19 6.88 5.19A1.69 1.69 0 0 0 5.19 6.88C5.19 7.81 5.95 8.56 6.88 8.56M8.27 18.5V10.13H5.5V18.5H8.27Z" />
</svg>
</mat-icon>
</button>
<button
mat-mini-fab
color="primary"
(click)="shareResults('facebook')"
matTooltip="Share on Facebook"
class="share-btn facebook"
>
<mat-icon>
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12 2.04C6.5 2.04 2 6.53 2 12.06C2 17.06 5.66 21.21 10.44 21.96V14.96H7.9V12.06H10.44V9.85C10.44 7.34 11.93 5.96 14.22 5.96C15.31 5.96 16.45 6.15 16.45 6.15V8.62H15.19C13.95 8.62 13.56 9.39 13.56 10.18V12.06H16.34L15.89 14.96H13.56V21.96A10 10 0 0 0 22 12.06C22 6.53 17.5 2.04 12 2.04Z" />
</svg>
</mat-icon>
</button>
<button
mat-mini-fab
(click)="copyLink()"
matTooltip="Copy Link"
class="share-btn copy"
>
<mat-icon>link</mat-icon>
</button>
</div>
</mat-card-content>
</mat-card>
</div>
}
</div>

View File

@@ -0,0 +1,674 @@
.quiz-results-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
position: relative;
min-height: calc(100vh - 64px);
@media (max-width: 768px) {
padding: 1rem;
}
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
p {
color: var(--text-secondary);
font-size: 1rem;
}
}
// Confetti Animation
.confetti-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.confetti {
position: absolute;
width: 10px;
height: 10px;
background: var(--primary-color);
top: -10px;
animation: confetti-fall 3s linear forwards;
&:nth-child(2n) {
background: var(--accent-color);
width: 12px;
height: 12px;
}
&:nth-child(3n) {
background: #4caf50;
width: 8px;
height: 8px;
}
&:nth-child(4n) {
background: #ff9800;
}
&:nth-child(5n) {
background: #f44336;
width: 6px;
height: 6px;
}
}
@keyframes confetti-fall {
to {
transform: translateY(100vh) rotate(360deg);
opacity: 0;
}
}
// Results Content
.results-content {
display: flex;
flex-direction: column;
gap: 2rem;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Header Section
.results-header {
text-align: center;
margin-bottom: 1rem;
.header-icon {
width: 80px;
height: 80px;
margin: 0 auto 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: scaleIn 0.5s ease-out;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: white;
}
&.excellent {
background: linear-gradient(135deg, #4caf50, #8bc34a);
}
&.good {
background: linear-gradient(135deg, #2196f3, #03a9f4);
}
&.average {
background: linear-gradient(135deg, #ff9800, #ffc107);
}
&.needs-improvement {
background: linear-gradient(135deg, #f44336, #e91e63);
}
}
.results-title {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 0.5rem;
color: var(--text-primary);
@media (max-width: 768px) {
font-size: 2rem;
}
}
.performance-message {
font-size: 1.25rem;
font-weight: 500;
margin: 0;
&.excellent {
color: #4caf50;
}
&.good {
color: #2196f3;
}
&.average {
color: #ff9800;
}
&.needs-improvement {
color: #f44336;
}
}
}
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
// Score Card
.score-card {
animation: slideUp 0.6s ease-out 0.2s both;
&.excellent {
border-left: 4px solid #4caf50;
}
&.good {
border-left: 4px solid #2196f3;
}
&.average {
border-left: 4px solid #ff9800;
}
&.needs-improvement {
border-left: 4px solid #f44336;
}
.score-display {
display: flex;
align-items: center;
justify-content: space-around;
gap: 2rem;
padding: 2rem 0;
@media (max-width: 768px) {
flex-direction: column;
gap: 1.5rem;
}
}
.score-circle {
position: relative;
width: 180px;
height: 180px;
svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.score-bg {
fill: none;
stroke: var(--bg-tertiary);
stroke-width: 10;
}
.score-progress {
fill: none;
stroke: var(--primary-color);
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 283;
transition: stroke-dashoffset 1s ease-out;
}
.score-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
.score-number {
display: block;
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
}
.score-label {
display: block;
font-size: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
}
}
.score-details {
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
}
.score-stat {
display: flex;
align-items: center;
gap: 1rem;
.stat-icon {
font-size: 36px;
width: 36px;
height: 36px;
&.success {
color: #4caf50;
}
&.error {
color: #f44336;
}
&.warning {
color: #ff9800;
}
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
mat-divider {
margin: 2rem 0;
}
.quiz-metadata {
display: flex;
justify-content: space-around;
gap: 1rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
.metadata-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: var(--text-secondary);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.success {
color: #4caf50;
}
.error {
color: #f44336;
}
}
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Chart Card
.chart-card {
animation: slideUp 0.6s ease-out 0.4s both;
.pie-chart-container {
display: flex;
align-items: center;
justify-content: space-around;
gap: 3rem;
padding: 2rem 0;
@media (max-width: 768px) {
flex-direction: column;
gap: 2rem;
}
}
.pie-chart {
position: relative;
width: 200px;
height: 200px;
svg {
width: 100%;
height: 100%;
}
circle {
transition: stroke-dasharray 1s ease-out, stroke-dashoffset 1s ease-out;
}
.chart-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
.chart-total {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
.chart-label {
display: block;
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
}
}
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 1rem;
.legend-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1rem;
color: var(--text-primary);
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
&.correct {
background-color: #4caf50;
}
&.incorrect {
background-color: #f44336;
}
&.skipped {
background-color: #ff9800;
}
}
}
}
}
// Questions Card
.questions-card {
animation: slideUp 0.6s ease-out 0.6s both;
.questions-list {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 600px;
overflow-y: auto;
padding: 0.5rem;
}
.question-item {
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--bg-secondary);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.incorrect {
border-left: 4px solid #f44336;
background-color: rgba(244, 67, 54, 0.05);
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
.question-number {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
.status-icon {
font-size: 24px;
width: 24px;
height: 24px;
&.success {
color: #4caf50;
}
&.error {
color: #f44336;
}
}
}
.question-meta {
display: flex;
align-items: center;
gap: 0.5rem;
.type-chip {
font-size: 0.75rem;
height: 24px;
}
.points {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
}
}
}
.question-text {
font-size: 1rem;
color: var(--text-primary);
margin-bottom: 1rem;
line-height: 1.6;
}
.answer-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
.answer-row {
display: flex;
gap: 0.5rem;
font-size: 0.9375rem;
.answer-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 120px;
}
.answer-value {
color: var(--text-primary);
&.incorrect {
color: #f44336;
text-decoration: line-through;
}
&.correct {
color: #4caf50;
font-weight: 600;
}
}
&.correct {
.answer-label {
color: #4caf50;
}
}
}
}
.explanation {
display: flex;
gap: 0.75rem;
padding: 1rem;
background-color: var(--bg-tertiary);
border-radius: 6px;
border-left: 3px solid var(--primary-color);
mat-icon {
color: var(--primary-color);
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
p {
margin: 0;
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.5;
}
}
}
}
// Action Buttons
.action-buttons {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
animation: slideUp 0.6s ease-out 0.8s both;
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
@media (max-width: 768px) {
width: 100%;
justify-content: center;
}
}
}
// Share Card
.share-card {
animation: slideUp 0.6s ease-out 1s both;
.share-buttons {
display: flex;
justify-content: center;
gap: 1rem;
padding: 1rem 0;
.share-btn {
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.twitter {
background-color: #1da1f2;
}
&.linkedin {
background-color: #0077b5;
}
&.facebook {
background-color: #1877f2;
}
&.copy {
background-color: var(--text-secondary);
}
mat-icon {
color: white;
}
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.question-item {
&.incorrect {
background-color: rgba(244, 67, 54, 0.1);
}
}
.explanation {
background-color: rgba(255, 255, 255, 0.05);
}
}

View File

@@ -0,0 +1,279 @@
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatDividerModule } from '@angular/material/divider';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Subject, takeUntil } from 'rxjs';
import { QuizService } from '../../../core/services/quiz.service';
import { QuizResults, QuizQuestionResult } from '../../../core/models/quiz.model';
@Component({
selector: 'app-quiz-results',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatDividerModule,
MatProgressSpinnerModule,
MatTooltipModule
],
templateUrl: './quiz-results.html',
styleUrls: ['./quiz-results.scss']
})
export class QuizResultsComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly quizService = inject(QuizService);
private readonly destroy$ = new Subject<void>();
readonly sessionId = signal<string>('');
readonly results = this.quizService.quizResults;
readonly isLoading = signal<boolean>(true);
readonly showConfetti = signal<boolean>(false);
// Computed values
readonly scorePercentage = computed(() => {
const res = this.results();
return res?.percentage ?? 0;
});
readonly performanceLevel = computed(() => {
const percentage = this.scorePercentage();
if (percentage >= 90) return 'excellent';
if (percentage >= 70) return 'good';
if (percentage >= 50) return 'average';
return 'needs-improvement';
});
readonly performanceMessage = computed(() => {
const level = this.performanceLevel();
switch (level) {
case 'excellent':
return 'Excellent! Outstanding performance! 🎉';
case 'good':
return 'Good job! Keep up the great work! 👏';
case 'average':
return 'Not bad! Keep practicing to improve! 💪';
default:
return 'Keep practicing! You\'ll do better next time! 📚';
}
});
readonly incorrectQuestions = computed(() => {
const res = this.results();
return res?.questions.filter(q => !q.isCorrect) ?? [];
});
readonly hasIncorrectAnswers = computed(() => this.incorrectQuestions().length > 0);
readonly chartData = computed(() => {
const res = this.results();
if (!res) return { correct: 0, incorrect: 0, skipped: 0 };
return {
correct: res.correctAnswers,
incorrect: res.incorrectAnswers,
skipped: res.skippedAnswers
};
});
readonly chartPercentages = computed(() => {
const data = this.chartData();
const total = data.correct + data.incorrect + data.skipped;
if (total === 0) return { correct: 0, incorrect: 0, skipped: 0 };
return {
correct: Math.round((data.correct / total) * 100),
incorrect: Math.round((data.incorrect / total) * 100),
skipped: Math.round((data.skipped / total) * 100)
};
});
ngOnInit(): void {
// Get session ID from route
this.route.params
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
const id = params['sessionId'];
if (id) {
this.sessionId.set(id);
this.loadResults();
} else {
this.router.navigate(['/dashboard']);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Load quiz results
*/
private loadResults(): void {
this.isLoading.set(true);
// Check if results already in service
if (this.results()) {
this.isLoading.set(false);
this.checkConfetti();
return;
}
// Fetch results from server
this.quizService.reviewQuiz(this.sessionId())
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.isLoading.set(false);
this.checkConfetti();
},
error: () => {
this.isLoading.set(false);
this.router.navigate(['/dashboard']);
}
});
}
/**
* Check if should show confetti animation
*/
private checkConfetti(): void {
if (this.scorePercentage() > 80) {
this.showConfetti.set(true);
setTimeout(() => this.showConfetti.set(false), 5000);
}
}
/**
* Format time in seconds to readable string
*/
formatTime(seconds: number): string {
if (!seconds) return '0s';
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
/**
* Get difficulty color
*/
getDifficultyColor(difficulty: string): string {
switch (difficulty?.toLowerCase()) {
case 'easy':
return 'success';
case 'medium':
return 'warning';
case 'hard':
return 'error';
default:
return 'default';
}
}
/**
* Get question type display text
*/
getQuestionTypeText(type: string): string {
switch (type) {
case 'multiple_choice':
return 'Multiple Choice';
case 'true_false':
return 'True/False';
case 'written':
return 'Written';
default:
return type;
}
}
/**
* Retake quiz with same settings
*/
retakeQuiz(): void {
const session = this.quizService.activeSession();
if (session) {
this.router.navigate(['/quiz/setup'], {
queryParams: {
categoryId: session.categoryId,
difficulty: session.difficulty,
quizType: session.quizType
}
});
} else {
this.router.navigate(['/quiz/setup']);
}
}
/**
* Review incorrect answers
*/
reviewIncorrect(): void {
this.router.navigate(['/quiz', this.sessionId(), 'review'], {
queryParams: { filter: 'incorrect' }
});
}
/**
* Navigate to dashboard
*/
goToDashboard(): void {
this.quizService.clearSession();
this.router.navigate(['/dashboard']);
}
/**
* Share results on social media
*/
shareResults(platform: 'twitter' | 'linkedin' | 'facebook'): void {
const results = this.results();
if (!results) return;
const text = `I scored ${results.percentage}% on my quiz! 🎯`;
const url = window.location.href;
let shareUrl = '';
switch (platform) {
case 'twitter':
shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
break;
case 'linkedin':
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
break;
case 'facebook':
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
break;
}
if (shareUrl) {
window.open(shareUrl, '_blank', 'width=600,height=400');
}
}
/**
* Copy results link to clipboard
*/
copyLink(): void {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
// Toast notification would be shown here
console.log('Link copied to clipboard');
});
}
}

View File

@@ -0,0 +1,243 @@
<div class="quiz-review-container">
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading review...</p>
</div>
}
<!-- Review Content -->
@if (!isLoading() && results()) {
<div class="review-content">
<!-- Header Section -->
<div class="review-header">
<button mat-icon-button (click)="backToResults()" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="header-content">
<h1 class="review-title">Quiz Review</h1>
<p class="review-subtitle">Review your answers and learn from mistakes</p>
</div>
</div>
<!-- Summary Cards -->
<div class="summary-cards">
<mat-card class="summary-card">
<mat-card-content>
<div class="card-icon total">
<mat-icon>quiz</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ allQuestions().length }}</div>
<div class="card-label">Total Questions</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card correct">
<mat-card-content>
<div class="card-icon success">
<mat-icon>check_circle</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ correctCount() }}</div>
<div class="card-label">Correct</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card incorrect">
<mat-card-content>
<div class="card-icon error">
<mat-icon>cancel</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ incorrectCount() }}</div>
<div class="card-label">Incorrect</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card score">
<mat-card-content>
<div class="card-icon primary">
<mat-icon>emoji_events</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ results()!.percentage }}%</div>
<div class="card-label">Score</div>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button
mat-stroked-button
[class.active]="filterType() === 'all'"
(click)="setFilter('all')"
>
<mat-icon>list</mat-icon>
All Questions ({{ allQuestions().length }})
</button>
<button
mat-stroked-button
[class.active]="filterType() === 'correct'"
(click)="setFilter('correct')"
class="correct-tab"
>
<mat-icon>check_circle</mat-icon>
Correct ({{ correctCount() }})
</button>
<button
mat-stroked-button
[class.active]="filterType() === 'incorrect'"
(click)="setFilter('incorrect')"
class="incorrect-tab"
>
<mat-icon>cancel</mat-icon>
Incorrect ({{ incorrectCount() }})
</button>
</div>
<!-- Questions List -->
<div class="questions-list">
@for (question of paginatedQuestions(); track question.questionId; let i = $index) {
<mat-card class="question-card" [class.incorrect]="!question.isCorrect">
<mat-card-header>
<div class="question-header-content">
<div class="question-number-badge" [class.correct]="question.isCorrect">
<span class="number">{{ (pageIndex() * pageSize()) + i + 1 }}</span>
<mat-icon class="status-icon">
{{ question.isCorrect ? 'check_circle' : 'cancel' }}
</mat-icon>
</div>
<div class="question-meta">
<mat-chip class="type-chip">
{{ getQuestionTypeText(question.questionType) }}
</mat-chip>
<span class="points-badge">{{ question.points }} pts</span>
</div>
@if (isAuthenticated()) {
<button
mat-icon-button
class="bookmark-btn"
[class.bookmarked]="isBookmarked(question.questionId)"
(click)="toggleBookmark(question.questionId)"
[matTooltip]="isBookmarked(question.questionId) ? 'Remove bookmark' : 'Bookmark question'"
>
<mat-icon>
{{ isBookmarked(question.questionId) ? 'bookmark' : 'bookmark_border' }}
</mat-icon>
</button>
}
</div>
</mat-card-header>
<mat-card-content>
<div class="question-text">{{ question.questionText }}</div>
<mat-divider></mat-divider>
<div class="answer-section">
<div class="answer-row">
<div class="answer-label">Your Answer:</div>
<div class="answer-value" [class.incorrect]="!question.isCorrect">
{{ formatAnswer(question.userAnswer) || 'Not answered' }}
@if (!question.isCorrect) {
<mat-icon class="answer-icon error">close</mat-icon>
} @else {
<mat-icon class="answer-icon success">check</mat-icon>
}
</div>
</div>
@if (!question.isCorrect) {
<div class="answer-row correct-answer">
<div class="answer-label">Correct Answer:</div>
<div class="answer-value correct">
{{ formatAnswer(question.correctAnswer) }}
<mat-icon class="answer-icon success">check</mat-icon>
</div>
</div>
}
</div>
@if (question.explanation) {
<div class="explanation-section">
<div class="explanation-header">
<mat-icon>lightbulb</mat-icon>
<span>Explanation</span>
</div>
<p class="explanation-text">{{ question.explanation }}</p>
</div>
}
@if (question.timeSpent) {
<div class="time-spent">
<mat-icon>schedule</mat-icon>
<span>Time spent: {{ question.timeSpent }}s</span>
</div>
}
</mat-card-content>
</mat-card>
}
@if (paginatedQuestions().length === 0) {
<div class="empty-state">
<mat-icon>info</mat-icon>
<p>No questions match the selected filter</p>
</div>
}
</div>
<!-- Pagination -->
@if (totalQuestions() > pageSize()) {
<mat-paginator
[length]="totalQuestions()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[5, 10, 20, 50]"
(page)="onPageChange($event)"
showFirstLastButtons
>
</mat-paginator>
}
<!-- Action Buttons -->
<div class="action-buttons">
<button
mat-raised-button
(click)="backToResults()"
class="action-btn"
>
<mat-icon>arrow_back</mat-icon>
Back to Results
</button>
<button
mat-raised-button
color="primary"
(click)="retakeQuiz()"
class="action-btn"
>
<mat-icon>refresh</mat-icon>
Retake Quiz
</button>
<button
mat-raised-button
(click)="goToDashboard()"
class="action-btn"
>
<mat-icon>dashboard</mat-icon>
Dashboard
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,533 @@
.quiz-review-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-height: calc(100vh - 64px);
@media (max-width: 768px) {
padding: 1rem;
}
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
p {
color: var(--text-secondary);
font-size: 1rem;
}
}
// Review Content
.review-content {
display: flex;
flex-direction: column;
gap: 2rem;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Header Section
.review-header {
display: flex;
align-items: center;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
.back-btn {
flex-shrink: 0;
}
.header-content {
flex: 1;
.review-title {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
.review-subtitle {
margin: 0.25rem 0 0;
font-size: 1rem;
color: var(--text-secondary);
}
}
}
// Summary Cards
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.summary-card {
animation: slideUp 0.5s ease-out both;
@for $i from 1 through 4 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
mat-card-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: white;
}
&.total {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
&.success {
background: linear-gradient(135deg, #4caf50, #8bc34a);
}
&.error {
background: linear-gradient(135deg, #f44336, #e91e63);
}
&.primary {
background: linear-gradient(135deg, #2196f3, #03a9f4);
}
}
.card-info {
flex: 1;
.card-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.card-label {
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
}
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Filter Tabs
.filter-tabs {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
button {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
transition: all 0.3s ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
mat-icon {
color: white;
}
}
&.correct-tab.active {
background-color: #4caf50;
border-color: #4caf50;
}
&.incorrect-tab.active {
background-color: #f44336;
border-color: #f44336;
}
}
}
// Questions List
.questions-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
.question-card {
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.incorrect {
border-left: 4px solid #f44336;
}
mat-card-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.question-header-content {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
.question-number-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background-color: var(--bg-tertiary);
font-weight: 700;
&.correct {
background-color: rgba(76, 175, 80, 0.1);
color: #4caf50;
.status-icon {
color: #4caf50;
}
}
&:not(.correct) {
background-color: rgba(244, 67, 54, 0.1);
color: #f44336;
.status-icon {
color: #f44336;
}
}
.number {
font-size: 1.25rem;
}
.status-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.question-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
.type-chip {
font-size: 0.75rem;
height: 28px;
}
.points-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
background-color: var(--primary-color);
color: white;
font-size: 0.875rem;
font-weight: 600;
}
}
.bookmark-btn {
margin-left: auto;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.1);
}
&.bookmarked {
color: #ffc107;
mat-icon {
animation: bookmarkPop 0.3s ease-out;
}
}
}
}
mat-card-content {
padding: 1.5rem;
.question-text {
font-size: 1.125rem;
font-weight: 500;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 1.5rem;
}
mat-divider {
margin: 1.5rem 0;
}
.answer-section {
display: flex;
flex-direction: column;
gap: 1rem;
.answer-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
background-color: var(--bg-secondary);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
&.correct-answer {
background-color: rgba(76, 175, 80, 0.1);
border: 2px solid rgba(76, 175, 80, 0.3);
}
.answer-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 130px;
font-size: 0.9375rem;
}
.answer-value {
flex: 1;
font-size: 1rem;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
&.incorrect {
color: #f44336;
}
&.correct {
color: #4caf50;
}
.answer-icon {
font-size: 20px;
width: 20px;
height: 20px;
&.success {
color: #4caf50;
}
&.error {
color: #f44336;
}
}
}
}
}
.explanation-section {
margin-top: 1.5rem;
padding: 1.25rem;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.05), rgba(var(--accent-rgb), 0.05));
border-radius: 8px;
border-left: 4px solid var(--primary-color);
.explanation-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.75rem;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.explanation-text {
margin: 0;
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.6;
}
}
.time-spent {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
}
}
@keyframes bookmarkPop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--text-secondary);
opacity: 0.5;
margin-bottom: 1rem;
}
p {
font-size: 1.125rem;
color: var(--text-secondary);
margin: 0;
}
}
// Pagination
mat-paginator {
margin-top: 1rem;
background-color: transparent;
}
// Action Buttons
.action-buttons {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 1rem;
border-top: 2px solid var(--border-color);
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
@media (max-width: 768px) {
width: 100%;
justify-content: center;
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.answer-row {
background-color: rgba(255, 255, 255, 0.05);
&.correct-answer {
background-color: rgba(76, 175, 80, 0.15);
}
}
.explanation-section {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--accent-rgb), 0.1));
}
}

View File

@@ -0,0 +1,245 @@
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatDividerModule } from '@angular/material/divider';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Subject, takeUntil } from 'rxjs';
import { QuizService } from '../../../core/services/quiz.service';
import { StorageService } from '../../../core/services/storage.service';
import { QuizResults, QuizQuestionResult } from '../../../core/models/quiz.model';
@Component({
selector: 'app-quiz-review',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatDividerModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatPaginatorModule
],
templateUrl: './quiz-review.html',
styleUrls: ['./quiz-review.scss']
})
export class QuizReviewComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly quizService = inject(QuizService);
private readonly storageService = inject(StorageService);
private readonly destroy$ = new Subject<void>();
readonly sessionId = signal<string>('');
readonly results = this.quizService.quizResults;
readonly isLoading = signal<boolean>(true);
// Pagination
readonly pageSize = signal<number>(10);
readonly pageIndex = signal<number>(0);
// Filter
readonly filterType = signal<'all' | 'correct' | 'incorrect'>('all');
// Computed values
readonly allQuestions = computed(() => this.results()?.questions ?? []);
readonly filteredQuestions = computed(() => {
const questions = this.allQuestions();
const filter = this.filterType();
if (filter === 'all') return questions;
if (filter === 'correct') return questions.filter(q => q.isCorrect);
if (filter === 'incorrect') return questions.filter(q => !q.isCorrect);
return questions;
});
readonly paginatedQuestions = computed(() => {
const questions = this.filteredQuestions();
const start = this.pageIndex() * this.pageSize();
const end = start + this.pageSize();
return questions.slice(start, end);
});
readonly totalQuestions = computed(() => this.filteredQuestions().length);
readonly correctCount = computed(() =>
this.allQuestions().filter(q => q.isCorrect).length
);
readonly incorrectCount = computed(() =>
this.allQuestions().filter(q => !q.isCorrect).length
);
readonly isAuthenticated = computed(() =>
this.storageService.isAuthenticated()
);
// Bookmarked questions tracking
readonly bookmarkedQuestions = signal<Set<string>>(new Set());
ngOnInit(): void {
// Get session ID from route
this.route.params
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
const id = params['sessionId'];
if (id) {
this.sessionId.set(id);
// Check for filter query param
this.route.queryParams
.pipe(takeUntil(this.destroy$))
.subscribe(queryParams => {
if (queryParams['filter'] === 'incorrect') {
this.filterType.set('incorrect');
}
this.loadReview();
});
} else {
this.router.navigate(['/dashboard']);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Load quiz review
*/
private loadReview(): void {
this.isLoading.set(true);
// Check if results already in service
if (this.results() && this.results()!.questions) {
this.isLoading.set(false);
return;
}
// Fetch review from server
this.quizService.reviewQuiz(this.sessionId())
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.router.navigate(['/dashboard']);
}
});
}
/**
* Handle page change
*/
onPageChange(event: PageEvent): void {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
// Scroll to top of questions list
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/**
* Set filter type
*/
setFilter(filter: 'all' | 'correct' | 'incorrect'): void {
this.filterType.set(filter);
this.pageIndex.set(0); // Reset to first page
}
/**
* Toggle bookmark for a question
*/
toggleBookmark(questionId: string): void {
if (!this.isAuthenticated()) {
// Show login prompt for guests
this.router.navigate(['/login'], {
queryParams: { returnUrl: this.router.url }
});
return;
}
const bookmarks = this.bookmarkedQuestions();
const newBookmarks = new Set(bookmarks);
if (newBookmarks.has(questionId)) {
newBookmarks.delete(questionId);
// TODO: Call bookmark service to remove bookmark
console.log('Remove bookmark:', questionId);
} else {
newBookmarks.add(questionId);
// TODO: Call bookmark service to add bookmark
console.log('Add bookmark:', questionId);
}
this.bookmarkedQuestions.set(newBookmarks);
}
/**
* Check if question is bookmarked
*/
isBookmarked(questionId: string): boolean {
return this.bookmarkedQuestions().has(questionId);
}
/**
* Get question type display text
*/
getQuestionTypeText(type: string): string {
switch (type) {
case 'multiple_choice':
return 'Multiple Choice';
case 'true_false':
return 'True/False';
case 'written':
return 'Written';
default:
return type;
}
}
/**
* Format answer for display
*/
formatAnswer(answer: string | string[]): string {
if (Array.isArray(answer)) {
return answer.join(', ');
}
return answer;
}
/**
* Navigate back to results
*/
backToResults(): void {
this.router.navigate(['/quiz', this.sessionId(), 'results']);
}
/**
* Retake quiz
*/
retakeQuiz(): void {
this.router.navigate(['/quiz/setup']);
}
/**
* Navigate to dashboard
*/
goToDashboard(): void {
this.quizService.clearSession();
this.router.navigate(['/dashboard']);
}
}

View File

@@ -0,0 +1,201 @@
<div class="quiz-setup-container">
<mat-card>
<mat-card-header>
<div class="header-content">
<div class="header-title">
<mat-icon class="header-icon">play_circle</mat-icon>
<h1>Start New Quiz</h1>
</div>
<p class="subtitle">Configure your quiz settings and challenge yourself!</p>
</div>
</mat-card-header>
<mat-card-content>
<!-- Guest Warning -->
@if (showGuestWarning()) {
<div class="guest-warning">
<mat-icon class="warning-icon">warning</mat-icon>
<div class="warning-content">
<p><strong>Limited Quizzes Remaining</strong></p>
<p>You have {{ remainingQuizzes() }} quiz(es) left as a guest.</p>
<button mat-stroked-button color="primary" (click)="navigateToRegister()">
Sign Up for Unlimited Access
</button>
</div>
</div>
}
<!-- Loading State -->
@if (isLoadingCategories()) {
<div class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading categories...</p>
</div>
}
<!-- Setup Form -->
@if (!isLoadingCategories()) {
<form [formGroup]="setupForm" (ngSubmit)="startQuiz()" class="setup-form">
<!-- Category Selection -->
<div class="form-section">
<h2>
<mat-icon>category</mat-icon>
Select Category
</h2>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Choose a category</mat-label>
<mat-select formControlName="categoryId" required>
@for (category of getAvailableCategories(); track category.id) {
<mat-option [value]="category.id">
<div class="category-option">
@if (category.icon) {
<mat-icon [style.color]="category.color">{{ category.icon }}</mat-icon>
}
<span class="category-name">{{ category.name }}</span>
<span class="question-count">({{ category.questionCount }} questions)</span>
</div>
</mat-option>
}
</mat-select>
@if (setupForm.get('categoryId')?.hasError('required') && setupForm.get('categoryId')?.touched) {
<mat-error>Please select a category</mat-error>
}
</mat-form-field>
@if (selectedCategory()) {
<div class="category-preview">
<mat-icon [style.color]="selectedCategory()?.color">{{ selectedCategory()?.icon }}</mat-icon>
<div class="category-info">
<h3>{{ selectedCategory()?.name }}</h3>
<p>{{ selectedCategory()?.description }}</p>
</div>
</div>
}
</div>
<!-- Question Count -->
<div class="form-section">
<h2>
<mat-icon>format_list_numbered</mat-icon>
Number of Questions
</h2>
<div class="question-count-selector">
@for (count of questionCountOptions; track count) {
<button
type="button"
mat-stroked-button
[class.selected]="setupForm.get('questionCount')?.value === count"
(click)="setupForm.patchValue({ questionCount: count })">
{{ count }}
</button>
}
</div>
<p class="helper-text">Selected: {{ setupForm.get('questionCount')?.value }} questions</p>
</div>
<!-- Difficulty Selection -->
<div class="form-section">
<h2>
<mat-icon>tune</mat-icon>
Difficulty Level
</h2>
<div class="difficulty-selector">
@for (difficulty of difficultyOptions; track difficulty.value) {
<button
type="button"
mat-stroked-button
class="difficulty-option"
[class.selected]="setupForm.get('difficulty')?.value === difficulty.value"
(click)="setupForm.patchValue({ difficulty: difficulty.value })">
<mat-icon [style.color]="difficulty.color">{{ difficulty.icon }}</mat-icon>
<span>{{ difficulty.label }}</span>
</button>
}
</div>
</div>
<!-- Quiz Type Selection -->
<div class="form-section">
<h2>
<mat-icon>mode</mat-icon>
Quiz Mode
</h2>
<div class="quiz-type-selector">
@for (type of quizTypeOptions; track type.value) {
<mat-card
class="quiz-type-card"
[class.selected]="setupForm.get('quizType')?.value === type.value"
(click)="setupForm.patchValue({ quizType: type.value })">
<mat-icon class="type-icon">{{ type.icon }}</mat-icon>
<h3>{{ type.label }}</h3>
<p>{{ type.description }}</p>
</mat-card>
}
</div>
</div>
<!-- Summary Card -->
<div class="summary-section">
<mat-card class="summary-card">
<h3>
<mat-icon>info</mat-icon>
Quiz Summary
</h3>
<div class="summary-details">
<div class="summary-item">
<span class="label">Category:</span>
<span class="value">{{ selectedCategory()?.name || 'Not selected' }}</span>
</div>
<div class="summary-item">
<span class="label">Questions:</span>
<span class="value">{{ setupForm.get('questionCount')?.value }}</span>
</div>
<div class="summary-item">
<span class="label">Difficulty:</span>
<span class="value">
{{ getSelectedDifficultyLabel() }}
</span>
</div>
<div class="summary-item">
<span class="label">Mode:</span>
<span class="value">
{{ getSelectedQuizTypeLabel() }}
</span>
</div>
<div class="summary-item">
<span class="label">Estimated Time:</span>
<span class="value">~{{ estimatedTime() }} minutes</span>
</div>
</div>
</mat-card>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
type="button"
mat-stroked-button
routerLink="/categories">
<mat-icon>arrow_back</mat-icon>
Back to Categories
</button>
<button
type="submit"
mat-raised-button
color="primary"
[disabled]="!canStartQuiz()">
@if (isStartingQuiz()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Starting...</span>
} @else {
<mat-icon>play_arrow</mat-icon>
<span>Start Quiz</span>
}
</button>
</div>
</form>
}
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,433 @@
.quiz-setup-container {
max-width: 900px;
margin: 24px auto;
padding: 0 16px;
mat-card {
mat-card-header {
margin-bottom: 32px;
.header-content {
width: 100%;
.header-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.header-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #1976d2;
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 500;
}
}
.subtitle {
margin: 0;
color: rgba(0, 0, 0, 0.6);
font-size: 16px;
}
}
}
mat-card-content {
// Guest Warning
.guest-warning {
display: flex;
gap: 16px;
padding: 16px;
margin-bottom: 24px;
background-color: rgba(255, 152, 0, 0.1);
border-left: 4px solid #FF9800;
border-radius: 4px;
.warning-icon {
flex-shrink: 0;
color: #FF9800;
font-size: 28px;
width: 28px;
height: 28px;
}
.warning-content {
flex: 1;
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 12px;
}
strong {
font-size: 16px;
}
}
}
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 20px;
p {
font-size: 16px;
color: rgba(0, 0, 0, 0.6);
}
}
// Setup Form
.setup-form {
.form-section {
margin-bottom: 32px;
h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #1976d2;
}
}
.full-width {
width: 100%;
}
// Category Option
.category-option {
display: flex;
align-items: center;
gap: 12px;
.category-name {
flex: 1;
font-weight: 500;
}
.question-count {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
}
// Category Preview
.category-preview {
display: flex;
gap: 16px;
padding: 16px;
margin-top: 16px;
background-color: rgba(0, 0, 0, 0.03);
border-radius: 8px;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
}
.category-info {
flex: 1;
h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 500;
}
p {
margin: 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
line-height: 1.5;
}
}
}
// Question Count Selector
.question-count-selector {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
button {
height: 56px;
font-size: 18px;
font-weight: 500;
transition: all 0.2s;
&.selected {
background-color: #1976d2;
color: white;
border-color: #1976d2;
}
&:hover:not(.selected) {
background-color: rgba(25, 118, 210, 0.08);
}
}
}
.helper-text {
margin: 12px 0 0 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
}
// Difficulty Selector
.difficulty-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.difficulty-option {
display: flex;
align-items: center;
gap: 8px;
height: 56px;
font-size: 15px;
transition: all 0.2s;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
&.selected {
background-color: #1976d2;
color: white;
border-color: #1976d2;
mat-icon {
color: white !important;
}
}
}
}
// Quiz Type Selector
.quiz-type-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
.quiz-type-card {
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid rgba(0, 0, 0, 0.12);
&:hover {
border-color: #1976d2;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&.selected {
border-color: #1976d2;
background-color: rgba(25, 118, 210, 0.08);
}
.type-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin: 0 auto 12px;
color: #1976d2;
}
h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 500;
}
p {
margin: 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
line-height: 1.4;
}
}
}
}
// Summary Section
.summary-section {
margin-bottom: 24px;
.summary-card {
background-color: rgba(25, 118, 210, 0.05);
border: 1px solid rgba(25, 118, 210, 0.2);
h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
color: #1976d2;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.summary-details {
display: grid;
gap: 12px;
.summary-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
&:last-child {
border-bottom: none;
}
.label {
font-weight: 500;
color: rgba(0, 0, 0, 0.7);
}
.value {
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
}
}
}
}
}
// Action Buttons
.action-buttons {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 32px;
button {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
font-size: 16px;
padding: 0 24px;
mat-spinner {
margin-right: 8px;
}
}
}
}
}
}
// Responsive Design
@media (max-width: 768px) {
.setup-form {
.form-section {
.question-count-selector {
grid-template-columns: repeat(2, 1fr);
}
.difficulty-selector {
grid-template-columns: 1fr;
}
.quiz-type-selector {
grid-template-columns: 1fr;
}
}
.action-buttons {
flex-direction: column-reverse;
button {
width: 100%;
}
}
}
}
@media (max-width: 600px) {
mat-card-header {
.header-content {
.header-title {
h1 {
font-size: 24px;
}
.header-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
}
}
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.quiz-setup-container {
.subtitle,
.helper-text,
.category-option .question-count {
color: rgba(255, 255, 255, 0.7);
}
.category-preview {
background-color: rgba(255, 255, 255, 0.05);
.category-info p {
color: rgba(255, 255, 255, 0.7);
}
}
.summary-card {
background-color: rgba(25, 118, 210, 0.1);
.summary-item {
border-color: rgba(255, 255, 255, 0.1);
.label {
color: rgba(255, 255, 255, 0.7);
}
.value {
color: rgba(255, 255, 255, 0.9);
}
}
}
}
}

View File

@@ -0,0 +1,238 @@
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSliderModule } from '@angular/material/slider';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Subject, takeUntil } from 'rxjs';
import { QuizService } from '../../../core/services/quiz.service';
import { CategoryService } from '../../../core/services/category.service';
import { GuestService } from '../../../core/services/guest.service';
import { StorageService } from '../../../core/services/storage.service';
import { Category } from '../../../core/models/category.model';
import { QuizStartRequest } from '../../../core/models/quiz.model';
@Component({
selector: 'app-quiz-setup',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterLink,
MatCardModule,
MatFormFieldModule,
MatSelectModule,
MatSliderModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatChipsModule,
MatTooltipModule
],
templateUrl: './quiz-setup.html',
styleUrls: ['./quiz-setup.scss']
})
export class QuizSetupComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly quizService = inject(QuizService);
private readonly categoryService = inject(CategoryService);
private readonly guestService = inject(GuestService);
private readonly storageService = inject(StorageService);
private readonly destroy$ = new Subject<void>();
// Form
setupForm!: FormGroup;
// State signals
readonly categories = this.categoryService.categories;
readonly isLoadingCategories = this.categoryService.isLoading;
readonly isStartingQuiz = this.quizService.isStartingQuiz;
// Guest limit
readonly isGuest = computed(() => !this.storageService.isAuthenticated());
readonly guestState = this.guestService.guestState;
readonly remainingQuizzes = computed(() => this.guestState().quizLimit?.quizzesRemaining ?? null);
readonly showGuestWarning = computed(() => {
const remaining = this.remainingQuizzes();
return this.isGuest() && remaining !== null && remaining <= 2;
});
// Question count options
readonly questionCountOptions = [5, 10, 15, 20];
// Difficulty options
readonly difficultyOptions = [
{ value: 'easy', label: 'Easy', icon: 'sentiment_satisfied', color: '#4CAF50' },
{ value: 'medium', label: 'Medium', icon: 'sentiment_neutral', color: '#FF9800' },
{ value: 'hard', label: 'Hard', icon: 'sentiment_very_dissatisfied', color: '#f44336' },
{ value: 'mixed', label: 'Mixed (All Levels)', icon: 'shuffle', color: '#9C27B0' }
];
// Quiz type options
readonly quizTypeOptions = [
{
value: 'practice',
label: 'Practice Mode',
icon: 'school',
description: 'No time limit, learn at your own pace'
},
{
value: 'timed',
label: 'Timed Mode',
icon: 'timer',
description: 'Challenge yourself with time constraints'
}
];
// Computed values
readonly selectedCategory = computed(() => {
const categoryId = this.setupForm?.get('categoryId')?.value;
return this.categories()?.find(cat => cat.id === categoryId);
});
readonly estimatedTime = computed(() => {
const questionCount = this.setupForm?.get('questionCount')?.value || 10;
const quizType = this.setupForm?.get('quizType')?.value || 'practice';
return this.quizService.getEstimatedTime(questionCount, quizType);
});
readonly canStartQuiz = computed(() => {
return this.setupForm?.valid && !this.isStartingQuiz();
});
ngOnInit(): void {
this.initForm();
this.loadCategories();
this.checkPreselectedCategory();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Initialize form
*/
private initForm(): void {
this.setupForm = this.fb.group({
categoryId: ['', Validators.required],
questionCount: [10, [Validators.required, Validators.min(5), Validators.max(20)]],
difficulty: ['mixed', Validators.required],
quizType: ['practice', Validators.required]
});
}
/**
* Load categories
*/
private loadCategories(): void {
this.categoryService.getCategories()
.pipe(takeUntil(this.destroy$))
.subscribe();
}
/**
* Check if category was preselected via route params
*/
private checkPreselectedCategory(): void {
const categoryId = this.route.snapshot.queryParams['category'];
if (categoryId) {
this.setupForm.patchValue({ categoryId });
}
}
/**
* Start quiz
*/
startQuiz(): void {
if (!this.setupForm.valid) {
return;
}
const formValue = this.setupForm.value;
const request: QuizStartRequest = {
categoryId: formValue.categoryId,
questionCount: formValue.questionCount,
difficulty: formValue.difficulty,
quizType: formValue.quizType
};
this.quizService.startQuiz(request)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
if (response.success) {
// Navigate to quiz page
this.router.navigate(['/quiz', response.sessionId]);
}
},
error: (error) => {
console.error('Failed to start quiz:', error);
}
});
}
/**
* Get available categories for selection
*/
getAvailableCategories(): Category[] {
const allCategories = this.categories() || [];
if (this.isGuest()) {
// Filter to show only guest-accessible categories
return allCategories.filter(cat => cat.guestAccessible);
}
return allCategories;
}
/**
* Format slider value display
*/
formatSliderLabel(value: number): string {
return `${value} questions`;
}
/**
* Navigate to register
*/
navigateToRegister(): void {
this.router.navigate(['/register']);
}
/**
* Get difficulty icon color
*/
getDifficultyColor(difficulty: string): string {
const option = this.difficultyOptions.find(opt => opt.value === difficulty);
return option?.color || '#9E9E9E';
}
/**
* Get selected difficulty label
*/
getSelectedDifficultyLabel(): string {
const value = this.setupForm?.get('difficulty')?.value;
const option = this.difficultyOptions.find(d => d.value === value);
return option?.label || 'Not selected';
}
/**
* Get selected quiz type label
*/
getSelectedQuizTypeLabel(): string {
const value = this.setupForm?.get('quizType')?.value;
const option = this.quizTypeOptions.find(t => t.value === value);
return option?.label || 'Not selected';
}
}

View File

@@ -1,6 +1,6 @@
import { Component, inject, output, signal } from '@angular/core'; import { Component, inject, output, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule, NavigationEnd } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@@ -10,10 +10,13 @@ import { MatDividerModule } from '@angular/material/divider';
import { MatDialogModule, MatDialog } from '@angular/material/dialog'; import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { filter } from 'rxjs';
import { ThemeService } from '../../../core/services/theme.service'; import { ThemeService } from '../../../core/services/theme.service';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { GuestService } from '../../../core/services/guest.service'; import { GuestService } from '../../../core/services/guest.service';
import { QuizService } from '../../../core/services/quiz.service';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog'; import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@@ -33,13 +36,16 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.scss' styleUrl: './header.scss'
}) })
export class HeaderComponent { export class HeaderComponent implements OnInit {
private themeService = inject(ThemeService); private themeService = inject(ThemeService);
private authService = inject(AuthService); private authService = inject(AuthService);
private guestService = inject(GuestService); private guestService = inject(GuestService);
private quizService = inject(QuizService);
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private hasCheckedForIncompleteSession = false;
// Output event for mobile menu toggle // Output event for mobile menu toggle
menuToggle = output<void>(); menuToggle = output<void>();
@@ -67,6 +73,68 @@ export class HeaderComponent {
get isGuest() { get isGuest() {
return this.guestState().isGuest && !this.isAuthenticated; return this.guestState().isGuest && !this.isAuthenticated;
} }
ngOnInit(): void {
// Check for incomplete session on navigation
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
// Only check once and not when already on a quiz page
if (!this.hasCheckedForIncompleteSession && !this.router.url.includes('/quiz/')) {
this.checkForIncompleteSession();
this.hasCheckedForIncompleteSession = true;
}
});
// Initial check
if (!this.router.url.includes('/quiz/')) {
this.checkForIncompleteSession();
this.hasCheckedForIncompleteSession = true;
}
}
/**
* Check for incomplete quiz session and show resume dialog
*/
private checkForIncompleteSession(): void {
const sessionId = this.quizService.checkIncompleteSession();
if (sessionId) {
// Restore session from server to get details
this.quizService.restoreSession(sessionId).subscribe({
next: ({ session }) => {
if (session.status === 'in_progress') {
// Show resume dialog
this.showResumeDialog(session);
}
},
error: () => {
// Session no longer exists, ignore
console.log('Incomplete session check: Session not found or expired');
}
});
}
}
/**
* Show resume quiz dialog
*/
private showResumeDialog(session: any): void {
const dialogRef = this.dialog.open(ResumeQuizDialogComponent, {
width: '600px',
maxWidth: '95vw',
disableClose: false,
data: { session }
});
dialogRef.afterClosed().subscribe(result => {
if (result?.action === 'new') {
// User wants to start a new quiz, clear the old session
this.quizService.clearSession();
}
// If 'resume', the dialog already navigated to the quiz page
// If 'cancel', do nothing
});
}
/** /**
* Toggle between light and dark theme * Toggle between light and dark theme

View File

@@ -0,0 +1,107 @@
<div class="resume-quiz-dialog">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>history</mat-icon>
</div>
<h2 mat-dialog-title>Resume Quiz?</h2>
<button mat-icon-button class="close-btn" (click)="cancel()">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<div class="incomplete-session-info">
<p class="message">
You have an incomplete quiz session. Would you like to continue where you left off?
</p>
<div class="session-details">
<div class="detail-row">
<mat-icon>quiz</mat-icon>
<span class="label">Progress:</span>
<span class="value">
Question {{ session().currentQuestionIndex + 1 }} of {{ session().totalQuestions }}
</span>
</div>
<div class="progress-container">
<mat-progress-bar
mode="determinate"
[value]="progress()"
[color]="progress() > 66 ? 'primary' : progress() > 33 ? 'accent' : 'warn'"
></mat-progress-bar>
<span class="progress-text">{{ progress() }}% Complete</span>
</div>
<div class="detail-row">
<mat-icon>category</mat-icon>
<span class="label">Category:</span>
<span class="value">{{ session().categoryName || 'Quiz' }}</span>
</div>
<div class="detail-row">
<mat-icon>tune</mat-icon>
<span class="label">Difficulty:</span>
<span class="value">{{ formatDifficulty(session().difficulty) }}</span>
</div>
<div class="detail-row">
<mat-icon>timer</mat-icon>
<span class="label">Quiz Type:</span>
<span class="value">{{ getQuizTypeText(session().quizType) }}</span>
</div>
<div class="detail-row">
<mat-icon>schedule</mat-icon>
<span class="label">Started:</span>
<span class="value">{{ formatTimeElapsed() }}</span>
</div>
<div class="stats-row">
<div class="stat-item success">
<mat-icon>check_circle</mat-icon>
<span class="stat-value">{{ session().correctAnswers }}</span>
<span class="stat-label">Correct</span>
</div>
<div class="stat-item error">
<mat-icon>cancel</mat-icon>
<span class="stat-value">{{ session().incorrectAnswers }}</span>
<span class="stat-label">Incorrect</span>
</div>
<div class="stat-item">
<mat-icon>help_outline</mat-icon>
<span class="stat-value">{{ questionsRemaining() }}</span>
<span class="stat-label">Remaining</span>
</div>
</div>
@if (session().score > 0) {
<div class="current-score">
<mat-icon>emoji_events</mat-icon>
<span>Current Score: <strong>{{ session().score }} points</strong></span>
</div>
}
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
(click)="startNewQuiz()"
class="action-btn secondary"
>
<mat-icon>add</mat-icon>
Start New Quiz
</button>
<button
mat-raised-button
color="primary"
(click)="resumeQuiz()"
class="action-btn primary"
>
<mat-icon>play_arrow</mat-icon>
Continue Quiz
</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,259 @@
.resume-quiz-dialog {
display: flex;
flex-direction: column;
min-width: 500px;
max-width: 600px;
@media (max-width: 768px) {
min-width: unset;
max-width: unset;
width: 100%;
}
}
.dialog-header {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid var(--border-color);
.header-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
animation: pulse 2s infinite;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(var(--primary-rgb), 0.7);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(var(--primary-rgb), 0);
}
}
mat-dialog-content {
padding: 1.5rem;
overflow-y: auto;
max-height: 70vh;
}
.incomplete-session-info {
.message {
font-size: 1rem;
color: var(--text-secondary);
text-align: center;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.session-details {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
.detail-row {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9375rem;
mat-icon {
color: var(--primary-color);
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.label {
color: var(--text-secondary);
font-weight: 500;
min-width: 80px;
}
.value {
color: var(--text-primary);
font-weight: 600;
}
}
.progress-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.5rem 0;
mat-progress-bar {
height: 8px;
border-radius: 4px;
}
.progress-text {
font-size: 0.875rem;
color: var(--text-secondary);
text-align: right;
font-weight: 600;
}
}
.stats-row {
display: flex;
justify-content: space-around;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--text-secondary);
}
&.success mat-icon {
color: #4caf50;
}
&.error mat-icon {
color: #f44336;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.current-score {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--accent-rgb), 0.1));
border-radius: 6px;
margin-top: 0.5rem;
mat-icon {
color: #ffc107;
font-size: 24px;
width: 24px;
height: 24px;
}
span {
font-size: 1rem;
color: var(--text-primary);
strong {
color: var(--primary-color);
}
}
}
}
}
mat-dialog-actions {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
gap: 0.75rem;
display: flex;
justify-content: flex-end;
@media (max-width: 768px) {
flex-direction: column-reverse;
button {
width: 100%;
}
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&.primary {
min-width: 160px;
}
&.secondary {
color: var(--text-secondary);
&:hover {
background-color: var(--bg-hover);
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.session-details {
background-color: rgba(255, 255, 255, 0.05);
}
.current-score {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.2), rgba(var(--accent-rgb), 0.2));
}
}

View File

@@ -0,0 +1,116 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { QuizSession } from '../../../core/models/quiz.model';
export interface ResumeQuizDialogData {
session: QuizSession;
}
@Component({
selector: 'app-resume-quiz-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressBarModule
],
templateUrl: './resume-quiz-dialog.html',
styleUrls: ['./resume-quiz-dialog.scss']
})
export class ResumeQuizDialogComponent {
private readonly dialogRef = inject(MatDialogRef<ResumeQuizDialogComponent>);
readonly data = inject<ResumeQuizDialogData>(MAT_DIALOG_DATA);
private readonly router = inject(Router);
readonly session = signal<QuizSession>(this.data.session);
readonly progress = computed(() => {
const sess = this.session();
if (!sess) return 0;
return Math.round((sess.currentQuestionIndex / sess.totalQuestions) * 100);
});
readonly questionsRemaining = computed(() => {
const sess = this.session();
if (!sess) return 0;
return sess.totalQuestions - sess.currentQuestionIndex;
});
/**
* Resume the quiz
*/
resumeQuiz(): void {
const sess = this.session();
if (sess) {
this.dialogRef.close({ action: 'resume' });
this.router.navigate(['/quiz', sess.id]);
}
}
/**
* Start a new quiz
*/
startNewQuiz(): void {
this.dialogRef.close({ action: 'new' });
this.router.navigate(['/quiz/setup']);
}
/**
* Close dialog
*/
cancel(): void {
this.dialogRef.close({ action: 'cancel' });
}
/**
* Format difficulty
*/
formatDifficulty(difficulty: string): string {
return difficulty.charAt(0).toUpperCase() + difficulty.slice(1);
}
/**
* Get quiz type display text
*/
getQuizTypeText(type: string): string {
switch (type) {
case 'practice':
return 'Practice';
case 'timed':
return 'Timed';
case 'exam':
return 'Exam';
default:
return type;
}
}
/**
* Format time elapsed
*/
formatTimeElapsed(): string {
const sess = this.session();
if (!sess?.startedAt) return 'Just now';
const startTime = new Date(sess.startedAt).getTime();
const now = Date.now();
const diff = now - startTime;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days > 1 ? 's' : ''} ago`;
}
}

View File

@@ -109,6 +109,12 @@ export class SidebarComponent {
route: '/admin', route: '/admin',
requiresAdmin: true requiresAdmin: true
}, },
{
label: 'Manage Categories',
icon: 'category',
route: '/admin/categories',
requiresAdmin: true
},
{ {
label: 'User Management', label: 'User Management',
icon: 'people', icon: 'people',

View File

@@ -1,6 +1,6 @@
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'https://api.yourdomain.com/api', apiUrl: 'http://localhost:3000/api',
apiTimeout: 30000, apiTimeout: 30000,
cacheTimeout: 300000, // 5 minutes cacheTimeout: 300000, // 5 minutes
enableLogging: false, enableLogging: false,