add changes
This commit is contained in:
@@ -226,22 +226,22 @@
|
||||
**Purpose:** Fetch all active categories
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Create `CategoryService` with `getCategories()` method
|
||||
- [ ] Store categories in `categoriesState` signal
|
||||
- [ ] Implement caching strategy for categories (1 hour TTL)
|
||||
- [ ] Handle guest vs authenticated user filtering (guestAccessible flag)
|
||||
- [ ] Sort categories by displayOrder or name
|
||||
- [x] Create `CategoryService` with `getCategories()` method
|
||||
- [x] Store categories in `categoriesState` signal
|
||||
- [x] Implement caching strategy for categories (1 hour TTL)
|
||||
- [x] Handle guest vs authenticated user filtering (guestAccessible flag)
|
||||
- [x] Sort categories by displayOrder or name
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `CategoryListComponent` to display all categories
|
||||
- [ ] Design category card with icon, name, description, question count
|
||||
- [ ] Show "Locked" badge for auth-only categories (guest users)
|
||||
- [ ] Implement grid layout (responsive: 1 col mobile, 2 cols tablet, 3-4 cols desktop)
|
||||
- [ ] Add search/filter bar for categories
|
||||
- [ ] Show loading skeleton while fetching
|
||||
- [ ] Display empty state if no categories available
|
||||
- [ ] Add hover effects and click animation
|
||||
- [ ] Ensure keyboard navigation and ARIA labels
|
||||
- [x] Build `CategoryListComponent` to display all categories
|
||||
- [x] Design category card with icon, name, description, question count
|
||||
- [x] Show "Locked" badge for auth-only categories (guest users)
|
||||
- [x] Implement grid layout (responsive: 1 col mobile, 2 cols tablet, 3-4 cols desktop)
|
||||
- [x] Add search/filter bar for categories
|
||||
- [x] Show loading skeleton while fetching
|
||||
- [x] Display empty state if no categories available
|
||||
- [x] Add hover effects and click animation
|
||||
- [x] Ensure keyboard navigation and ARIA labels
|
||||
|
||||
---
|
||||
|
||||
@@ -249,21 +249,21 @@
|
||||
**Purpose:** Get category details with question preview
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `CategoryService.getCategoryById(id)` method
|
||||
- [ ] Store selected category in `selectedCategoryState` signal
|
||||
- [ ] Fetch category stats (total questions, difficulty breakdown, accuracy)
|
||||
- [ ] Handle 404 if category not found
|
||||
- [ ] Handle 403 for guest users accessing auth-only categories
|
||||
- [x] Add `CategoryService.getCategoryById(id)` method
|
||||
- [x] Store selected category in `selectedCategoryState` signal
|
||||
- [x] Fetch category stats (total questions, difficulty breakdown, accuracy)
|
||||
- [x] Handle 404 if category not found
|
||||
- [x] Handle 403 for guest users accessing auth-only categories
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `CategoryDetailComponent` showing full category info
|
||||
- [ ] Display category header with icon, name, description
|
||||
- [ ] Show statistics (total questions, difficulty breakdown chart)
|
||||
- [ ] Display question preview (first 5 questions)
|
||||
- [ ] Add "Start Quiz" button with difficulty selector
|
||||
- [ ] Show loading spinner while fetching details
|
||||
- [ ] Display error message if category not accessible
|
||||
- [ ] Implement breadcrumb navigation (Home > Categories > Category Name)
|
||||
- [x] Build `CategoryDetailComponent` showing full category info
|
||||
- [x] Display category header with icon, name, description
|
||||
- [x] Show statistics (total questions, difficulty breakdown chart)
|
||||
- [x] Display question preview (first 5 questions)
|
||||
- [x] Add "Start Quiz" button with difficulty selector
|
||||
- [x] Show loading spinner while fetching details
|
||||
- [x] Display error message if category not accessible
|
||||
- [x] Implement breadcrumb navigation (Home > Categories > Category Name)
|
||||
|
||||
---
|
||||
|
||||
@@ -271,19 +271,19 @@
|
||||
**Purpose:** Create new category
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `CategoryService.createCategory(data)` method (admin only)
|
||||
- [ ] Validate form data (name, slug, description)
|
||||
- [ ] Handle 401/403 authorization errors
|
||||
- [ ] Invalidate category cache after creation
|
||||
- [x] Add `CategoryService.createCategory(data)` method (admin only)
|
||||
- [x] Validate form data (name, slug, description)
|
||||
- [x] Handle 401/403 authorization errors
|
||||
- [x] Invalidate category cache after creation
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `CategoryFormComponent` (admin) for creating categories
|
||||
- [ ] Design form with name, slug, description, icon, color fields
|
||||
- [ ] Add guest accessible checkbox
|
||||
- [ ] Show slug preview/auto-generation
|
||||
- [ ] Display validation errors inline
|
||||
- [ ] Show success toast after creation
|
||||
- [ ] Redirect to category list after success
|
||||
- [x] Build `CategoryFormComponent` (admin) for creating categories
|
||||
- [x] Design form with name, slug, description, icon, color fields
|
||||
- [x] Add guest accessible checkbox
|
||||
- [x] Show slug preview/auto-generation
|
||||
- [x] Display validation errors inline
|
||||
- [x] Show success toast after creation
|
||||
- [x] Redirect to category list after success
|
||||
|
||||
---
|
||||
|
||||
@@ -291,17 +291,17 @@
|
||||
**Purpose:** Update category
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `CategoryService.updateCategory(id, data)` method (admin only)
|
||||
- [ ] Pre-fill form with existing category data
|
||||
- [ ] Handle 404 if category not found
|
||||
- [ ] Invalidate cache after update
|
||||
- [x] Add `CategoryService.updateCategory(id, data)` method (admin only)
|
||||
- [x] Pre-fill form with existing category data
|
||||
- [x] Handle 404 if category not found
|
||||
- [x] Invalidate cache after update
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Reuse `CategoryFormComponent` in edit mode
|
||||
- [ ] Pre-populate form fields with existing data
|
||||
- [ ] Show "Editing: Category Name" header
|
||||
- [ ] Add "Cancel" and "Save Changes" buttons
|
||||
- [ ] Display success toast after update
|
||||
- [x] Reuse `CategoryFormComponent` in edit mode
|
||||
- [x] Pre-populate form fields with existing data
|
||||
- [x] Show "Editing: Category Name" header
|
||||
- [x] Add "Cancel" and "Save Changes" buttons
|
||||
- [x] Display success toast after update
|
||||
|
||||
---
|
||||
|
||||
@@ -309,17 +309,17 @@
|
||||
**Purpose:** Delete category
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `CategoryService.deleteCategory(id)` method (admin only)
|
||||
- [ ] Handle soft delete
|
||||
- [ ] Invalidate cache after deletion
|
||||
- [ ] Handle 404 if category not found
|
||||
- [x] Add `CategoryService.deleteCategory(id)` method (admin only)
|
||||
- [x] Handle soft delete
|
||||
- [x] Invalidate cache after deletion
|
||||
- [x] Handle 404 if category not found
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Add delete button in admin category list
|
||||
- [ ] Show confirmation dialog before deletion
|
||||
- [ ] Display warning if category has questions
|
||||
- [ ] Show success toast after deletion
|
||||
- [ ] Remove category from list immediately
|
||||
- [x] Add delete button in admin category list
|
||||
- [x] Show confirmation dialog before deletion
|
||||
- [x] Display warning if category has questions
|
||||
- [x] Show success toast after deletion
|
||||
- [x] Remove category from list immediately
|
||||
|
||||
---
|
||||
|
||||
@@ -329,23 +329,23 @@
|
||||
**Purpose:** Start a new quiz session
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Create `QuizService` with `startQuiz(categoryId, questionCount, difficulty, quizType)` method
|
||||
- [ ] Store active session in `quizSessionState` signal
|
||||
- [ ] Validate category accessibility (guest vs authenticated)
|
||||
- [ ] Check guest quiz limit before starting
|
||||
- [ ] Handle JWT token or guest token header
|
||||
- [ ] Navigate to quiz page after starting
|
||||
- [x] Create `QuizService` with `startQuiz(categoryId, questionCount, difficulty, quizType)` method
|
||||
- [x] Store active session in `quizSessionState` signal
|
||||
- [x] Validate category accessibility (guest vs authenticated)
|
||||
- [x] Check guest quiz limit before starting
|
||||
- [x] Handle JWT token or guest token header
|
||||
- [x] Navigate to quiz page after starting
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `QuizSetupComponent` for configuring quiz
|
||||
- [ ] Add category selector dropdown
|
||||
- [ ] Add question count slider (5, 10, 15, 20)
|
||||
- [ ] Add difficulty selector (Easy, Medium, Hard, Mixed)
|
||||
- [ ] Add quiz type selector (Practice, Timed)
|
||||
- [ ] Show estimated time for quiz
|
||||
- [ ] Display "Start Quiz" button with loading state
|
||||
- [ ] Show guest limit warning if applicable
|
||||
- [ ] Implement responsive design
|
||||
- [x] Build `QuizSetupComponent` for configuring quiz
|
||||
- [x] Add category selector dropdown
|
||||
- [x] Add question count slider (5, 10, 15, 20)
|
||||
- [x] Add difficulty selector (Easy, Medium, Hard, Mixed)
|
||||
- [x] Add quiz type selector (Practice, Timed)
|
||||
- [x] Show estimated time for quiz
|
||||
- [x] Display "Start Quiz" button with loading state
|
||||
- [x] Show guest limit warning if applicable
|
||||
- [x] Implement responsive design
|
||||
|
||||
---
|
||||
|
||||
@@ -353,28 +353,28 @@
|
||||
**Purpose:** Submit answer for current question
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `QuizService.submitAnswer(questionId, answer, sessionId)` method
|
||||
- [ ] Update quiz session state with answer result
|
||||
- [ ] Increment current question index
|
||||
- [ ] Calculate and update score in real-time
|
||||
- [ ] Handle validation errors
|
||||
- [ ] Store answer history for review
|
||||
- [x] Add `QuizService.submitAnswer(questionId, answer, sessionId)` method
|
||||
- [x] Update quiz session state with answer result
|
||||
- [x] Increment current question index
|
||||
- [x] Calculate and update score in real-time
|
||||
- [x] Handle validation errors
|
||||
- [x] Store answer history for review
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `QuizQuestionComponent` displaying current question
|
||||
- [ ] Show question text, type, and options
|
||||
- [ ] Create answer input based on question type:
|
||||
- [x] Build `QuizQuestionComponent` displaying current question
|
||||
- [x] Show question text, type, and options
|
||||
- [x] Create answer input based on question type:
|
||||
- Multiple choice: Radio buttons
|
||||
- True/False: Toggle buttons
|
||||
- Written: Text area
|
||||
- [ ] Show "Submit Answer" button (disabled until answer selected)
|
||||
- [ ] Display loading spinner during submission
|
||||
- [ ] Show immediate feedback (correct/incorrect) with animation
|
||||
- [ ] Display explanation after submission
|
||||
- [ ] Show "Next Question" button after submission
|
||||
- [ ] Update progress bar and score
|
||||
- [ ] Add timer display if timed quiz
|
||||
- [ ] Prevent answer changes after submission
|
||||
- [x] Show "Submit Answer" button (disabled until answer selected)
|
||||
- [x] Display loading spinner during submission
|
||||
- [x] Show immediate feedback (correct/incorrect) with animation
|
||||
- [x] Display explanation after submission
|
||||
- [x] Show "Next Question" button after submission
|
||||
- [x] Update progress bar and score
|
||||
- [x] Add timer display if timed quiz
|
||||
- [x] Prevent answer changes after submission
|
||||
|
||||
---
|
||||
|
||||
@@ -382,25 +382,25 @@
|
||||
**Purpose:** Complete quiz session and get results
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `QuizService.completeQuiz(sessionId)` method
|
||||
- [ ] Store final results in `quizResultsState` signal
|
||||
- [ ] Calculate percentage score
|
||||
- [ ] Fetch detailed answer breakdown
|
||||
- [ ] Clear active session state
|
||||
- [ ] Redirect to results page
|
||||
- [x] Add `QuizService.completeQuiz(sessionId)` method
|
||||
- [x] Store final results in `quizResultsState` signal
|
||||
- [x] Calculate percentage score
|
||||
- [x] Fetch detailed answer breakdown
|
||||
- [x] Clear active session state
|
||||
- [x] Redirect to results page
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `QuizResultsComponent` showing final score
|
||||
- [ ] Display score with percentage and message (Excellent, Good, Keep Practicing)
|
||||
- [ ] Show time taken and questions answered
|
||||
- [ ] Display pie chart for correct/incorrect breakdown
|
||||
- [ ] List all questions with user answers and correct answers
|
||||
- [ ] Highlight incorrect answers in red
|
||||
- [ ] Add "Review Incorrect Answers" button
|
||||
- [ ] Add "Retake Quiz" button
|
||||
- [ ] Add "Return to Dashboard" button
|
||||
- [ ] Show confetti animation for high scores (>80%)
|
||||
- [ ] Add social share buttons (Twitter, LinkedIn, Facebook)
|
||||
- [x] Build `QuizResultsComponent` showing final score
|
||||
- [x] Display score with percentage and message (Excellent, Good, Keep Practicing)
|
||||
- [x] Show time taken and questions answered
|
||||
- [x] Display pie chart for correct/incorrect breakdown
|
||||
- [x] List all questions with user answers and correct answers
|
||||
- [x] Highlight incorrect answers in red
|
||||
- [x] Add "Review Incorrect Answers" button
|
||||
- [x] Add "Retake Quiz" button
|
||||
- [x] Add "Return to Dashboard" button
|
||||
- [x] Show confetti animation for high scores (>80%)
|
||||
- [x] Add social share buttons (Twitter, LinkedIn, Facebook)
|
||||
|
||||
---
|
||||
|
||||
@@ -408,15 +408,15 @@
|
||||
**Purpose:** Get current quiz session details
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `QuizService.getSession(sessionId)` method
|
||||
- [ ] Restore session state if user refreshes page
|
||||
- [ ] Handle 404 if session not found
|
||||
- [ ] Resume quiz from current question index
|
||||
- [x] Add `QuizService.getSession(sessionId)` method
|
||||
- [x] Restore session state if user refreshes page
|
||||
- [x] Handle 404 if session not found
|
||||
- [x] Resume quiz from current question index
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Show "Resume Quiz" prompt if incomplete session exists
|
||||
- [ ] Display current progress in prompt (e.g., "Question 5 of 10")
|
||||
- [ ] Allow user to continue or start new quiz
|
||||
- [x] Show "Resume Quiz" prompt if incomplete session exists
|
||||
- [x] Display current progress in prompt (e.g., "Question 5 of 10")
|
||||
- [x] Allow user to continue or start new quiz
|
||||
|
||||
---
|
||||
|
||||
@@ -424,19 +424,19 @@
|
||||
**Purpose:** Review completed quiz
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `QuizService.reviewQuiz(sessionId)` method
|
||||
- [ ] Fetch all questions and answers for session
|
||||
- [ ] Store review data in signal
|
||||
- [ ] Handle 404 if session not found
|
||||
- [x] Add `QuizService.reviewQuiz(sessionId)` method
|
||||
- [x] Fetch all questions and answers for session
|
||||
- [x] Store review data in signal
|
||||
- [x] Handle 404 if session not found
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `QuizReviewComponent` for reviewing completed quiz
|
||||
- [ ] Display each question with user answer and correct answer
|
||||
- [ ] Highlight correct answers in green, incorrect in red
|
||||
- [ ] Show explanations for all questions
|
||||
- [ ] Add "Bookmark" button for difficult questions
|
||||
- [ ] Implement pagination or infinite scroll for long quizzes
|
||||
- [ ] Add "Back to Results" button
|
||||
- [x] Build `QuizReviewComponent` for reviewing completed quiz
|
||||
- [x] Display each question with user answer and correct answer
|
||||
- [x] Highlight correct answers in green, incorrect in red
|
||||
- [x] Show explanations for all questions
|
||||
- [x] Add "Bookmark" button for difficult questions
|
||||
- [x] Implement pagination or infinite scroll for long quizzes
|
||||
- [x] Add "Back to Results" button
|
||||
|
||||
---
|
||||
|
||||
@@ -446,27 +446,27 @@
|
||||
**Purpose:** Get user dashboard with statistics
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Create `UserService` with `getDashboard(userId)` method
|
||||
- [ ] Store dashboard data in `dashboardState` signal
|
||||
- [ ] Fetch on dashboard component load
|
||||
- [ ] Implement caching (5 min TTL)
|
||||
- [ ] Handle 401 if not authenticated
|
||||
- [x] Create `UserService` with `getDashboard(userId)` method
|
||||
- [x] Store dashboard data in `dashboardState` signal
|
||||
- [x] Fetch on dashboard component load
|
||||
- [x] Implement caching (5 min TTL)
|
||||
- [x] Handle 401 if not authenticated
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `DashboardComponent` as main user landing page
|
||||
- [ ] Display welcome message with username
|
||||
- [ ] Show overall statistics cards:
|
||||
- [x] Build `DashboardComponent` as main user landing page
|
||||
- [x] Display welcome message with username
|
||||
- [x] Show overall statistics cards:
|
||||
- Total quizzes taken
|
||||
- Overall accuracy percentage
|
||||
- Current streak
|
||||
- Total questions answered
|
||||
- [ ] Create category-wise performance chart (bar chart or pie chart)
|
||||
- [ ] Display recent quiz sessions (last 5) with scores
|
||||
- [ ] Show achievements and badges earned
|
||||
- [ ] Add "Start New Quiz" CTA button
|
||||
- [ ] Implement responsive grid layout (stack on mobile)
|
||||
- [ ] Add loading skeletons for data sections
|
||||
- [ ] Show empty state if no quizzes taken yet
|
||||
- [x] Create category-wise performance chart (bar chart or pie chart)
|
||||
- [x] Display recent quiz sessions (last 5) with scores
|
||||
- [x] Show achievements and badges earned
|
||||
- [x] Add "Start New Quiz" CTA button
|
||||
- [x] Implement responsive grid layout (stack on mobile)
|
||||
- [x] Add loading skeletons for data sections
|
||||
- [x] Show empty state if no quizzes taken yet
|
||||
|
||||
---
|
||||
|
||||
@@ -474,24 +474,24 @@
|
||||
**Purpose:** Get quiz history with pagination
|
||||
|
||||
**Frontend Tasks:**
|
||||
- [ ] Add `UserService.getHistory(userId, page, limit, category?, sortBy?)` method
|
||||
- [ ] Store history in `historyState` signal
|
||||
- [ ] Implement pagination state management
|
||||
- [ ] Add filtering by category
|
||||
- [ ] Add sorting by date or score
|
||||
- [ ] Handle query parameters in URL
|
||||
- [x] Add `UserService.getHistory(userId, page, limit, category?, sortBy?)` method
|
||||
- [x] Store history in `historyState` signal
|
||||
- [x] Implement pagination state management
|
||||
- [x] Add filtering by category
|
||||
- [x] Add sorting by date or score
|
||||
- [x] Handle query parameters in URL
|
||||
|
||||
**UI Tasks:**
|
||||
- [ ] Build `QuizHistoryComponent` displaying all past quizzes
|
||||
- [ ] Create history table/list with columns: Date, Category, Score, Time, Actions
|
||||
- [ ] Add filter dropdown for category
|
||||
- [ ] Add sort dropdown (Date, Score)
|
||||
- [ ] Implement pagination controls (Previous, Next, Page numbers)
|
||||
- [ ] Show "View Details" button for each quiz
|
||||
- [ ] Display loading spinner during fetch
|
||||
- [ ] Show empty state if no history
|
||||
- [ ] Make table responsive (collapse to cards on mobile)
|
||||
- [ ] Add export functionality (CSV download)
|
||||
- [x] Build `QuizHistoryComponent` displaying all past quizzes
|
||||
- [x] Create history table/list with columns: Date, Category, Score, Time, Actions
|
||||
- [x] Add filter dropdown for category
|
||||
- [x] Add sort dropdown (Date, Score)
|
||||
- [x] Implement pagination controls (Previous, Next, Page numbers)
|
||||
- [x] Show "View Details" button for each quiz
|
||||
- [x] Display loading spinner during fetch
|
||||
- [x] Show empty state if no history
|
||||
- [x] Make table responsive (collapse to cards on mobile)
|
||||
- [x] Add export functionality (CSV download)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,12 +23,79 @@ export const routes: Routes = [
|
||||
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
|
||||
// - Home page (public)
|
||||
// - Dashboard (protected with authGuard)
|
||||
// - Quiz routes (protected with authGuard)
|
||||
// - Results routes (protected with authGuard)
|
||||
// - Admin routes (protected with adminGuard)
|
||||
// - Quiz history (protected with authGuard)
|
||||
// - Bookmarks (protected with authGuard)
|
||||
// - Profile settings (protected with authGuard)
|
||||
// - More Admin routes (protected with adminGuard)
|
||||
|
||||
// Fallback - redirect to login for now
|
||||
{
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface Category {
|
||||
export interface CategoryDetail extends Category {
|
||||
questionPreview?: QuestionPreview[];
|
||||
stats?: CategoryStats;
|
||||
difficultyBreakdown?: {
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +43,7 @@ export interface CategoryStats {
|
||||
totalAttempts: number;
|
||||
totalCorrect: number;
|
||||
averageAccuracy: number;
|
||||
averageScore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,8 +41,11 @@ export interface UserLogin {
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
data: {
|
||||
user: User;
|
||||
token: string;
|
||||
};
|
||||
|
||||
message?: string;
|
||||
migratedStats?: {
|
||||
quizzesTaken: number;
|
||||
|
||||
@@ -57,8 +57,8 @@ export class AuthService {
|
||||
return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe(
|
||||
tap((response) => {
|
||||
// Store token and user data
|
||||
this.storageService.setToken(response.token, true); // Remember me by default
|
||||
this.storageService.setUserData(response.user);
|
||||
this.storageService.setToken(response.data.token, true); // Remember me by default
|
||||
this.storageService.setUserData(response.data.user);
|
||||
|
||||
// Clear guest token if converting
|
||||
if (guestSessionId) {
|
||||
@@ -66,16 +66,16 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Update auth state
|
||||
this.updateAuthState(response.user, null);
|
||||
this.updateAuthState(response.data.user, null);
|
||||
|
||||
// Show success message
|
||||
const message = response.migratedStats
|
||||
? `Welcome ${response.user.username}! Your guest progress has been saved.`
|
||||
: `Welcome ${response.user.username}! Your account has been created.`;
|
||||
? `Welcome ${response.data.user.username}! Your guest progress has been saved.`
|
||||
: `Welcome ${response.data.user.username}! Your account has been created.`;
|
||||
this.toastService.success(message);
|
||||
|
||||
// Auto-login: redirect to dashboard
|
||||
this.router.navigate(['/dashboard']);
|
||||
// Auto-login: redirect to categories
|
||||
this.router.navigate(['/categories']);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.handleAuthError(error);
|
||||
@@ -87,7 +87,7 @@ export class AuthService {
|
||||
/**
|
||||
* 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);
|
||||
|
||||
const loginData: UserLogin = { email, password };
|
||||
@@ -95,17 +95,19 @@ export class AuthService {
|
||||
return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe(
|
||||
tap((response) => {
|
||||
// Store token and user data
|
||||
this.storageService.setToken(response.token, rememberMe);
|
||||
this.storageService.setUserData(response.user);
|
||||
console.log(response.data.user);
|
||||
|
||||
this.storageService.setToken(response.data.token, rememberMe);
|
||||
this.storageService.setUserData(response.data.user);
|
||||
|
||||
// Clear guest token
|
||||
this.storageService.clearGuestToken();
|
||||
|
||||
// Update auth state
|
||||
this.updateAuthState(response.user, null);
|
||||
this.updateAuthState(response.data.user, null);
|
||||
|
||||
// Show success message
|
||||
this.toastService.success(`Welcome back, ${response.user.username}!`);
|
||||
this.toastService.success(`Welcome back, ${response.data.user.username}!`);
|
||||
|
||||
// Redirect to requested URL
|
||||
this.router.navigate([redirectUrl]);
|
||||
|
||||
313
frontend/src/app/core/services/category.service.ts
Normal file
313
frontend/src/app/core/services/category.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export * from './state.service';
|
||||
export * from './loading.service';
|
||||
export * from './theme.service';
|
||||
export * from './auth.service';
|
||||
export * from './category.service';
|
||||
export * from './guest.service';
|
||||
|
||||
356
frontend/src/app/core/services/quiz.service.ts
Normal file
356
frontend/src/app/core/services/quiz.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,15 @@ export class StorageService {
|
||||
// User Data Methods
|
||||
getUserData(): any {
|
||||
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 {
|
||||
|
||||
172
frontend/src/app/core/services/user.service.ts
Normal file
172
frontend/src/app/core/services/user.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
179
frontend/src/app/features/admin/category-form/category-form.html
Normal file
179
frontend/src/app/features/admin/category-form/category-form.html
Normal 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>
|
||||
243
frontend/src/app/features/admin/category-form/category-form.scss
Normal file
243
frontend/src/app/features/admin/category-form/category-form.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
frontend/src/app/features/admin/category-form/category-form.ts
Normal file
230
frontend/src/app/features/admin/category-form/category-form.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
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 { AuthService } from '../../../core/services/auth.service';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -31,17 +32,18 @@ import { GuestService } from '../../../core/services/guest.service';
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss'
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnDestroy {
|
||||
private fb = inject(FormBuilder);
|
||||
private authService = inject(AuthService);
|
||||
private guestService = inject(GuestService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Signals
|
||||
isSubmitting = signal<boolean>(false);
|
||||
hidePassword = signal<boolean>(true);
|
||||
returnUrl = signal<string>('/dashboard');
|
||||
returnUrl = signal<string>('/categories');
|
||||
isStartingGuestSession = signal<boolean>(false);
|
||||
|
||||
// Form
|
||||
@@ -56,13 +58,15 @@ export class LoginComponent {
|
||||
});
|
||||
|
||||
// Get return URL from query params
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.returnUrl.set(params['returnUrl'] || '/dashboard');
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(params => {
|
||||
this.returnUrl.set(params['returnUrl'] || '/categories');
|
||||
});
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
this.router.navigate(['/categories']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +90,9 @@ export class LoginComponent {
|
||||
|
||||
const { email, password, rememberMe } = this.loginForm.value;
|
||||
|
||||
this.authService.login(email, password, rememberMe, this.returnUrl()).subscribe({
|
||||
this.authService.login(email, password, rememberMe, this.returnUrl())
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
// Navigation is handled by AuthService
|
||||
@@ -139,7 +145,9 @@ export class LoginComponent {
|
||||
*/
|
||||
continueAsGuest(): void {
|
||||
this.isStartingGuestSession.set(true);
|
||||
this.guestService.startSession().subscribe({
|
||||
this.guestService.startSession()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isStartingGuestSession.set(false);
|
||||
this.router.navigate(['/guest-welcome']);
|
||||
@@ -149,4 +157,9 @@ export class LoginComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
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 { AuthService } from '../../../core/services/auth.service';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -29,11 +30,12 @@ import { StorageService } from '../../../core/services/storage.service';
|
||||
templateUrl: './register.html',
|
||||
styleUrl: './register.scss'
|
||||
})
|
||||
export class RegisterComponent {
|
||||
export class RegisterComponent implements OnDestroy {
|
||||
private fb = inject(FormBuilder);
|
||||
private authService = inject(AuthService);
|
||||
private storageService = inject(StorageService);
|
||||
private router = inject(Router);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Signals
|
||||
isSubmitting = signal<boolean>(false);
|
||||
@@ -181,7 +183,9 @@ export class RegisterComponent {
|
||||
const { username, email, password } = this.registerForm.value;
|
||||
const guestSessionId = this.storageService.getGuestToken() || undefined;
|
||||
|
||||
this.authService.register(username, email, password, guestSessionId).subscribe({
|
||||
this.authService.register(username, email, password, guestSessionId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
// Navigation handled by service
|
||||
@@ -251,5 +255,10 @@ export class RegisterComponent {
|
||||
const confirmControl = this.registerForm.get('confirmPassword');
|
||||
return !!confirmControl?.touched && this.registerForm.hasError('passwordMismatch');
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
212
frontend/src/app/features/dashboard/dashboard.component.html
Normal file
212
frontend/src/app/features/dashboard/dashboard.component.html
Normal 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>
|
||||
711
frontend/src/app/features/dashboard/dashboard.component.scss
Normal file
711
frontend/src/app/features/dashboard/dashboard.component.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
252
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
252
frontend/src/app/features/dashboard/dashboard.component.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
214
frontend/src/app/features/history/quiz-history.component.html
Normal file
214
frontend/src/app/features/history/quiz-history.component.html
Normal 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>
|
||||
485
frontend/src/app/features/history/quiz-history.component.scss
Normal file
485
frontend/src/app/features/history/quiz-history.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
312
frontend/src/app/features/history/quiz-history.component.ts
Normal file
312
frontend/src/app/features/history/quiz-history.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
205
frontend/src/app/features/quiz/quiz-question/quiz-question.html
Normal file
205
frontend/src/app/features/quiz/quiz-question/quiz-question.html
Normal 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>
|
||||
515
frontend/src/app/features/quiz/quiz-question/quiz-question.scss
Normal file
515
frontend/src/app/features/quiz/quiz-question/quiz-question.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
378
frontend/src/app/features/quiz/quiz-question/quiz-question.ts
Normal file
378
frontend/src/app/features/quiz/quiz-question/quiz-question.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
337
frontend/src/app/features/quiz/quiz-results/quiz-results.html
Normal file
337
frontend/src/app/features/quiz/quiz-results/quiz-results.html
Normal 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>
|
||||
674
frontend/src/app/features/quiz/quiz-results/quiz-results.scss
Normal file
674
frontend/src/app/features/quiz/quiz-results/quiz-results.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
279
frontend/src/app/features/quiz/quiz-results/quiz-results.ts
Normal file
279
frontend/src/app/features/quiz/quiz-results/quiz-results.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
243
frontend/src/app/features/quiz/quiz-review/quiz-review.html
Normal file
243
frontend/src/app/features/quiz/quiz-review/quiz-review.html
Normal 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>
|
||||
533
frontend/src/app/features/quiz/quiz-review/quiz-review.scss
Normal file
533
frontend/src/app/features/quiz/quiz-review/quiz-review.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
245
frontend/src/app/features/quiz/quiz-review/quiz-review.ts
Normal file
245
frontend/src/app/features/quiz/quiz-review/quiz-review.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
201
frontend/src/app/features/quiz/quiz-setup/quiz-setup.html
Normal file
201
frontend/src/app/features/quiz/quiz-setup/quiz-setup.html
Normal 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>
|
||||
433
frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss
Normal file
433
frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
238
frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts
Normal file
238
frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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 { Router, RouterModule } from '@angular/router';
|
||||
import { Router, RouterModule, NavigationEnd } from '@angular/router';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -10,10 +10,13 @@ import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { filter } from 'rxjs';
|
||||
import { ThemeService } from '../../../core/services/theme.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
import { QuizService } from '../../../core/services/quiz.service';
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
||||
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@@ -33,13 +36,16 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
||||
templateUrl: './header.html',
|
||||
styleUrl: './header.scss'
|
||||
})
|
||||
export class HeaderComponent {
|
||||
export class HeaderComponent implements OnInit {
|
||||
private themeService = inject(ThemeService);
|
||||
private authService = inject(AuthService);
|
||||
private guestService = inject(GuestService);
|
||||
private quizService = inject(QuizService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private hasCheckedForIncompleteSession = false;
|
||||
|
||||
// Output event for mobile menu toggle
|
||||
menuToggle = output<void>();
|
||||
|
||||
@@ -68,6 +74,68 @@ export class HeaderComponent {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,12 @@ export class SidebarComponent {
|
||||
route: '/admin',
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
label: 'Manage Categories',
|
||||
icon: 'category',
|
||||
route: '/admin/categories',
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
label: 'User Management',
|
||||
icon: 'people',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.yourdomain.com/api',
|
||||
apiUrl: 'http://localhost:3000/api',
|
||||
apiTimeout: 30000,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
enableLogging: false,
|
||||
|
||||
Reference in New Issue
Block a user