From 6f23890407c8c921003dca9b62a76ee130c2b187 Mon Sep 17 00:00:00 2001 From: AD2025 Date: Fri, 14 Nov 2025 02:04:33 +0200 Subject: [PATCH] add changes --- frontend/FRONTEND_UI_TASKS.md | 308 ++++---- frontend/src/app/app.routes.ts | 75 +- .../src/app/core/models/category.model.ts | 6 + frontend/src/app/core/models/user.model.ts | 7 +- .../src/app/core/services/auth.service.ts | 26 +- .../src/app/core/services/category.service.ts | 313 ++++++++ frontend/src/app/core/services/index.ts | 2 + .../src/app/core/services/quiz.service.ts | 356 +++++++++ .../src/app/core/services/storage.service.ts | 10 +- .../src/app/core/services/user.service.ts | 172 +++++ .../admin-category-list.html | 154 ++++ .../admin-category-list.scss | 236 ++++++ .../admin-category-list.ts | 111 +++ .../admin/category-form/category-form.html | 179 +++++ .../admin/category-form/category-form.scss | 243 ++++++ .../admin/category-form/category-form.ts | 230 ++++++ frontend/src/app/features/auth/login/login.ts | 63 +- .../app/features/auth/register/register.ts | 31 +- .../category-detail/category-detail.html | 216 ++++++ .../category-detail/category-detail.scss | 425 +++++++++++ .../category-detail/category-detail.ts | 98 +++ .../category-list/category-list.html | 169 +++++ .../category-list/category-list.scss | 381 ++++++++++ .../categories/category-list/category-list.ts | 130 ++++ .../dashboard/dashboard.component.html | 212 ++++++ .../dashboard/dashboard.component.scss | 711 ++++++++++++++++++ .../features/dashboard/dashboard.component.ts | 252 +++++++ .../history/quiz-history.component.html | 214 ++++++ .../history/quiz-history.component.scss | 485 ++++++++++++ .../history/quiz-history.component.ts | 312 ++++++++ .../quiz/quiz-question/quiz-question.html | 205 +++++ .../quiz/quiz-question/quiz-question.scss | 515 +++++++++++++ .../quiz/quiz-question/quiz-question.ts | 378 ++++++++++ .../quiz/quiz-results/quiz-results.html | 337 +++++++++ .../quiz/quiz-results/quiz-results.scss | 674 +++++++++++++++++ .../quiz/quiz-results/quiz-results.ts | 279 +++++++ .../quiz/quiz-review/quiz-review.html | 243 ++++++ .../quiz/quiz-review/quiz-review.scss | 533 +++++++++++++ .../features/quiz/quiz-review/quiz-review.ts | 245 ++++++ .../features/quiz/quiz-setup/quiz-setup.html | 201 +++++ .../features/quiz/quiz-setup/quiz-setup.scss | 433 +++++++++++ .../features/quiz/quiz-setup/quiz-setup.ts | 238 ++++++ .../app/shared/components/header/header.ts | 74 +- .../resume-quiz-dialog.html | 107 +++ .../resume-quiz-dialog.scss | 259 +++++++ .../resume-quiz-dialog/resume-quiz-dialog.ts | 116 +++ .../app/shared/components/sidebar/sidebar.ts | 6 + frontend/src/environments/environment.ts | 2 +- 48 files changed, 10759 insertions(+), 213 deletions(-) create mode 100644 frontend/src/app/core/services/category.service.ts create mode 100644 frontend/src/app/core/services/quiz.service.ts create mode 100644 frontend/src/app/core/services/user.service.ts create mode 100644 frontend/src/app/features/admin/admin-category-list/admin-category-list.html create mode 100644 frontend/src/app/features/admin/admin-category-list/admin-category-list.scss create mode 100644 frontend/src/app/features/admin/admin-category-list/admin-category-list.ts create mode 100644 frontend/src/app/features/admin/category-form/category-form.html create mode 100644 frontend/src/app/features/admin/category-form/category-form.scss create mode 100644 frontend/src/app/features/admin/category-form/category-form.ts create mode 100644 frontend/src/app/features/categories/category-detail/category-detail.html create mode 100644 frontend/src/app/features/categories/category-detail/category-detail.scss create mode 100644 frontend/src/app/features/categories/category-detail/category-detail.ts create mode 100644 frontend/src/app/features/categories/category-list/category-list.html create mode 100644 frontend/src/app/features/categories/category-list/category-list.scss create mode 100644 frontend/src/app/features/categories/category-list/category-list.ts create mode 100644 frontend/src/app/features/dashboard/dashboard.component.html create mode 100644 frontend/src/app/features/dashboard/dashboard.component.scss create mode 100644 frontend/src/app/features/dashboard/dashboard.component.ts create mode 100644 frontend/src/app/features/history/quiz-history.component.html create mode 100644 frontend/src/app/features/history/quiz-history.component.scss create mode 100644 frontend/src/app/features/history/quiz-history.component.ts create mode 100644 frontend/src/app/features/quiz/quiz-question/quiz-question.html create mode 100644 frontend/src/app/features/quiz/quiz-question/quiz-question.scss create mode 100644 frontend/src/app/features/quiz/quiz-question/quiz-question.ts create mode 100644 frontend/src/app/features/quiz/quiz-results/quiz-results.html create mode 100644 frontend/src/app/features/quiz/quiz-results/quiz-results.scss create mode 100644 frontend/src/app/features/quiz/quiz-results/quiz-results.ts create mode 100644 frontend/src/app/features/quiz/quiz-review/quiz-review.html create mode 100644 frontend/src/app/features/quiz/quiz-review/quiz-review.scss create mode 100644 frontend/src/app/features/quiz/quiz-review/quiz-review.ts create mode 100644 frontend/src/app/features/quiz/quiz-setup/quiz-setup.html create mode 100644 frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss create mode 100644 frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts create mode 100644 frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.html create mode 100644 frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.scss create mode 100644 frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.ts diff --git a/frontend/FRONTEND_UI_TASKS.md b/frontend/FRONTEND_UI_TASKS.md index 4022d4e..555869a 100644 --- a/frontend/FRONTEND_UI_TASKS.md +++ b/frontend/FRONTEND_UI_TASKS.md @@ -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) --- diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 71501c2..bd28665 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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 { diff --git a/frontend/src/app/core/models/category.model.ts b/frontend/src/app/core/models/category.model.ts index a09c949..e7cfd0f 100644 --- a/frontend/src/app/core/models/category.model.ts +++ b/frontend/src/app/core/models/category.model.ts @@ -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; } /** diff --git a/frontend/src/app/core/models/user.model.ts b/frontend/src/app/core/models/user.model.ts index 3ee956c..ffa9083 100644 --- a/frontend/src/app/core/models/user.model.ts +++ b/frontend/src/app/core/models/user.model.ts @@ -41,8 +41,11 @@ export interface UserLogin { */ export interface AuthResponse { success: boolean; - token: string; - user: User; + data: { + user: User; + token: string; + }; + message?: string; migratedStats?: { quizzesTaken: number; diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index ce05b4a..c858b38 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -57,8 +57,8 @@ export class AuthService { return this.http.post(`${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 { + login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/categories'): Observable { this.setLoading(true); const loginData: UserLogin = { email, password }; @@ -95,17 +95,19 @@ export class AuthService { return this.http.post(`${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]); diff --git a/frontend/src/app/core/services/category.service.ts b/frontend/src/app/core/services/category.service.ts new file mode 100644 index 0000000..d838604 --- /dev/null +++ b/frontend/src/app/core/services/category.service.ts @@ -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 { + 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([]); + private selectedCategoryState = signal(null); + private loadingState = signal(false); + private errorState = signal(null); + + // Cache storage + private categoriesCache: CacheEntry | null = null; + private categoryDetailsCache = new Map>(); + + // 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 { + // 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 { + // 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 { + this.loadingState.set(true); + this.errorState.set(null); + + return this.http.post(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 { + this.loadingState.set(true); + this.errorState.set(null); + + return this.http.put(`${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 { + this.loadingState.set(true); + this.errorState.set(null); + + return this.http.delete(`${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 { + 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); + } +} diff --git a/frontend/src/app/core/services/index.ts b/frontend/src/app/core/services/index.ts index e5c3715..427a8bd 100644 --- a/frontend/src/app/core/services/index.ts +++ b/frontend/src/app/core/services/index.ts @@ -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'; diff --git a/frontend/src/app/core/services/quiz.service.ts b/frontend/src/app/core/services/quiz.service.ts new file mode 100644 index 0000000..7037b19 --- /dev/null +++ b/frontend/src/app/core/services/quiz.service.ts @@ -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(null); + readonly activeSession = this._activeSession.asReadonly(); + + // Quiz questions state + private readonly _questions = signal([]); + readonly questions = this._questions.asReadonly(); + + // Quiz results state + private readonly _quizResults = signal(null); + readonly quizResults = this._quizResults.asReadonly(); + + // Loading states + private readonly _isStartingQuiz = signal(false); + readonly isStartingQuiz = this._isStartingQuiz.asReadonly(); + + private readonly _isSubmittingAnswer = signal(false); + readonly isSubmittingAnswer = this._isSubmittingAnswer.asReadonly(); + + private readonly _isCompletingQuiz = signal(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 { + // 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(`${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 { + this._isSubmittingAnswer.set(true); + + return this.http.post(`${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 { + this._isCompletingQuiz.set(true); + + return this.http.post(`${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 { + 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 { + return this.http.get(`${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 { + return this.http.post(`${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; + } +} diff --git a/frontend/src/app/core/services/storage.service.ts b/frontend/src/app/core/services/storage.service.ts index d487ee9..bf52fec 100644 --- a/frontend/src/app/core/services/storage.service.ts +++ b/frontend/src/app/core/services/storage.service.ts @@ -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 { diff --git a/frontend/src/app/core/services/user.service.ts b/frontend/src/app/core/services/user.service.ts new file mode 100644 index 0000000..a8a3a30 --- /dev/null +++ b/frontend/src/app/core/services/user.service.ts @@ -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 { + 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(null); + historyState = signal(null); + isLoading = signal(false); + error = signal(null); + + // Cache + private dashboardCache = new Map>(); + + // 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 { + // 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(`${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 { + this.isLoading.set(true); + this.error.set(null); + + let params: any = { page, limit, sortBy }; + if (category) { + params.category = category; + } + + return this.http.get(`${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 { + 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; + } +} diff --git a/frontend/src/app/features/admin/admin-category-list/admin-category-list.html b/frontend/src/app/features/admin/admin-category-list/admin-category-list.html new file mode 100644 index 0000000..f33a942 --- /dev/null +++ b/frontend/src/app/features/admin/admin-category-list/admin-category-list.html @@ -0,0 +1,154 @@ +
+ + + +
+

Manage Categories

+ +
+
+
+ + + + @if (isLoading()) { +
+ +

Loading categories...

+
+ } + + + @if (error() && !isLoading()) { +
+ error_outline +

Failed to load categories

+

{{ error() }}

+ +
+ } + + + @if (!isLoading() && !error()) { + @if (categories().length === 0) { +
+ folder_open +

No Categories Yet

+

Create your first category to get started.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Icon +
+ {{ category.icon || 'category' }} +
+
Name +
+ {{ category.name }} + {{ category.description }} +
+
Slug + {{ category.slug }} + Questions + {{ category.questionCount || 0 }} + Access + @if (category.guestAccessible) { + + public + Guest + + } @else { + + lock + Auth + + } + Order + {{ category.displayOrder ?? '-' }} + Actions +
+ + +
+
+
+ } + } +
+
+
diff --git a/frontend/src/app/features/admin/admin-category-list/admin-category-list.scss b/frontend/src/app/features/admin/admin-category-list/admin-category-list.scss new file mode 100644 index 0000000..119023a --- /dev/null +++ b/frontend/src/app/features/admin/admin-category-list/admin-category-list.scss @@ -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); + } + } + } +} diff --git a/frontend/src/app/features/admin/admin-category-list/admin-category-list.ts b/frontend/src/app/features/admin/admin-category-list/admin-category-list.ts new file mode 100644 index 0000000..c401fb0 --- /dev/null +++ b/frontend/src/app/features/admin/admin-category-list/admin-category-list.ts @@ -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(); + + 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(); + } +} diff --git a/frontend/src/app/features/admin/category-form/category-form.html b/frontend/src/app/features/admin/category-form/category-form.html new file mode 100644 index 0000000..050a005 --- /dev/null +++ b/frontend/src/app/features/admin/category-form/category-form.html @@ -0,0 +1,179 @@ +
+ + + +
+ +

{{ pageTitle() }}

+
+
+
+ + +
+ + + Category Name + + label + {{ getErrorMessage('name') }} + + + + + Slug (URL-friendly) + + link + Preview: /categories/{{ slugPreview() }} + {{ getErrorMessage('slug') }} + + + + + Description + + description + + {{ categoryForm.get('description')?.value?.length || 0 }} / 500 + + {{ getErrorMessage('description') }} + + + +
+ + + Icon + + @for (icon of iconOptions; track icon.value) { + + {{ icon.value }} + {{ icon.label }} + + } + + {{ categoryForm.get('icon')?.value }} + {{ getErrorMessage('icon') }} + + + + + Color + + @for (color of colorOptions; track color.value) { + + + + + {{ color.label }} + + + } + + + + {{ getErrorMessage('color') }} + +
+ + + + Display Order + + sort + Lower numbers appear first in the category list + {{ getErrorMessage('displayOrder') }} + + + +
+ + Guest Accessible + +

+ Allow guest users to access this category without authentication +

+
+ + +
+

Preview

+
+
+ {{ categoryForm.get('icon')?.value }} +
+
+

{{ categoryForm.get('name')?.value || 'Category Name' }}

+

{{ categoryForm.get('description')?.value || 'Category description will appear here...' }}

+ @if (categoryForm.get('guestAccessible')?.value) { + + public + Guest Accessible + + } @else { + + lock + Login Required + + } +
+
+
+ + +
+ + + +
+
+
+
+
diff --git a/frontend/src/app/features/admin/category-form/category-form.scss b/frontend/src/app/features/admin/category-form/category-form.scss new file mode 100644 index 0000000..fa86790 --- /dev/null +++ b/frontend/src/app/features/admin/category-form/category-form.scss @@ -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); + } + } +} diff --git a/frontend/src/app/features/admin/category-form/category-form.ts b/frontend/src/app/features/admin/category-form/category-form.ts new file mode 100644 index 0000000..65b8f7f --- /dev/null +++ b/frontend/src/app/features/admin/category-form/category-form.ts @@ -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(); + + categoryForm!: FormGroup; + isEditMode = signal(false); + categoryId = signal(null); + isSubmitting = signal(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(); + } +} diff --git a/frontend/src/app/features/auth/login/login.ts b/frontend/src/app/features/auth/login/login.ts index 1037722..238ebe7 100644 --- a/frontend/src/app/features/auth/login/login.ts +++ b/frontend/src/app/features/auth/login/login.ts @@ -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(); // Signals isSubmitting = signal(false); hidePassword = signal(true); - returnUrl = signal('/dashboard'); + returnUrl = signal('/categories'); isStartingGuestSession = signal(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,15 +90,17 @@ export class LoginComponent { const { email, password, rememberMe } = this.loginForm.value; - this.authService.login(email, password, rememberMe, this.returnUrl()).subscribe({ - next: () => { - this.isSubmitting.set(false); - // Navigation is handled by AuthService - }, - error: () => { - this.isSubmitting.set(false); - } - }); + this.authService.login(email, password, rememberMe, this.returnUrl()) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isSubmitting.set(false); + // Navigation is handled by AuthService + }, + error: () => { + this.isSubmitting.set(false); + } + }); } /** @@ -139,14 +145,21 @@ export class LoginComponent { */ continueAsGuest(): void { this.isStartingGuestSession.set(true); - this.guestService.startSession().subscribe({ - next: () => { - this.isStartingGuestSession.set(false); - this.router.navigate(['/guest-welcome']); - }, - error: () => { - this.isStartingGuestSession.set(false); - } - }); + this.guestService.startSession() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isStartingGuestSession.set(false); + this.router.navigate(['/guest-welcome']); + }, + error: () => { + this.isStartingGuestSession.set(false); + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/frontend/src/app/features/auth/register/register.ts b/frontend/src/app/features/auth/register/register.ts index 17c485f..2758ce4 100644 --- a/frontend/src/app/features/auth/register/register.ts +++ b/frontend/src/app/features/auth/register/register.ts @@ -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(); // Signals isSubmitting = signal(false); @@ -181,15 +183,17 @@ export class RegisterComponent { const { username, email, password } = this.registerForm.value; const guestSessionId = this.storageService.getGuestToken() || undefined; - this.authService.register(username, email, password, guestSessionId).subscribe({ - next: () => { - this.isSubmitting.set(false); - // Navigation handled by service - }, - error: () => { - this.isSubmitting.set(false); - } - }); + this.authService.register(username, email, password, guestSessionId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isSubmitting.set(false); + // Navigation handled by service + }, + error: () => { + this.isSubmitting.set(false); + } + }); } /** @@ -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(); + } } diff --git a/frontend/src/app/features/categories/category-detail/category-detail.html b/frontend/src/app/features/categories/category-detail/category-detail.html new file mode 100644 index 0000000..332fc34 --- /dev/null +++ b/frontend/src/app/features/categories/category-detail/category-detail.html @@ -0,0 +1,216 @@ +
+ + @if (isLoading()) { +
+ +

Loading category details...

+
+ } + + + @if (error() && !isLoading()) { +
+ + +
+ error_outline +

Oops! Something went wrong

+

{{ error() }}

+
+ + +
+
+
+
+
+ } + + + @if (category() && !isLoading() && !error()) { +
+ + + + + + +
+
+ {{ category()?.icon || 'category' }} +
+
+

{{ category()?.name }}

+

{{ category()?.description }}

+ +
+
+
+
+ + + @if (category()?.stats) { +
+

Statistics

+
+ + +
+ quiz +
+

{{ category()?.stats?.totalQuestions || 0 }}

+

Total Questions

+
+
+ + + +
+ trending_up +
+

{{ category()?.stats?.averageAccuracy || 0 }}%

+

Average Accuracy

+
+
+ + + +
+ people +
+

{{ category()?.stats?.totalAttempts || 0 }}

+

Total Attempts

+
+
+ + + +
+ speed +
+

{{ category()?.stats?.averageScore || 0 }}%

+

Average Score

+
+
+
+
+ } + + + @if (category()?.difficultyBreakdown) { +
+

Difficulty Breakdown

+
+ + + sentiment_satisfied +

{{ category()?.difficultyBreakdown?.easy || 0 }}

+

Easy

+
+
+ + + + sentiment_neutral +

{{ category()?.difficultyBreakdown?.medium || 0 }}

+

Medium

+
+
+ + + + sentiment_very_dissatisfied +

{{ category()?.difficultyBreakdown?.hard || 0 }}

+

Hard

+
+
+
+
+ } + + + @if (category()?.questionPreview?.length) { +
+

Sample Questions

+
+ @for (question of category()?.questionPreview; track question.id; let i = $index) { + + +
+ #{{ i + 1 }} + + + {{ question.difficulty }} + + {{ question.questionType }} + +
+

{{ question.questionText }}

+
+
+ } +
+
+ } + + +
+

Ready to test your knowledge?

+

Choose a difficulty level to start your quiz

+
+ + + + +
+ +
+
+ } +
diff --git a/frontend/src/app/features/categories/category-detail/category-detail.scss b/frontend/src/app/features/categories/category-detail/category-detail.scss new file mode 100644 index 0000000..e5082f6 --- /dev/null +++ b/frontend/src/app/features/categories/category-detail/category-detail.scss @@ -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; + } + } +} diff --git a/frontend/src/app/features/categories/category-detail/category-detail.ts b/frontend/src/app/features/categories/category-detail/category-detail.ts new file mode 100644 index 0000000..ecb1ed0 --- /dev/null +++ b/frontend/src/app/features/categories/category-detail/category-detail.ts @@ -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(); + + category = signal(null); + isLoading = signal(true); + error = signal(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(); + } +} diff --git a/frontend/src/app/features/categories/category-list/category-list.html b/frontend/src/app/features/categories/category-list/category-list.html new file mode 100644 index 0000000..ada2373 --- /dev/null +++ b/frontend/src/app/features/categories/category-list/category-list.html @@ -0,0 +1,169 @@ +
+ +
+

Quiz Categories

+

Choose a category to start your quiz journey

+
+ + +
+ + Search categories + + search + @if (searchControl.value) { + + } + +
+ + + @if (isLoading()) { +
+
+ @for (item of [1,2,3,4,5,6]; track item) { + + +
+
+
+
+
+
+ } +
+
+ } + + + @if (error() && !isLoading()) { +
+ error_outline +

Oops! Something went wrong

+

{{ error() }}

+ +
+ } + + + @if (!isLoading() && !error()) { + @if (isEmpty()) { + +
+ folder_open +

No Categories Found

+

+ @if (searchControl.value) { + No categories match your search. Try a different keyword. + } @else { + No categories are available at the moment. + } +

+ @if (searchControl.value) { + + } +
+ } @else { + +
+ @for (category of filteredCategories(); track category.id) { + + + + @if (isCategoryLocked(category)) { +
+ lock + Sign up to access +
+ } + + + +
+ {{ getCategoryIcon(category) }} +
+ + +

{{ category.name }}

+ + +

{{ category.description }}

+ + +
+
+ quiz + {{ category.questionCount }} questions +
+ + @if (!category.guestAccessible) { + + person + Members Only + + } +
+ + +
+ @if (!isCategoryLocked(category)) { + + } @else { + + } +
+
+
+ } +
+ } + } + + + @if (isGuestMode()) { +
+ info +

+ You're browsing as a guest. Some categories require registration. + Sign up to access all content! +

+
+ } +
diff --git a/frontend/src/app/features/categories/category-list/category-list.scss b/frontend/src/app/features/categories/category-list/category-list.scss new file mode 100644 index 0000000..ad0285a --- /dev/null +++ b/frontend/src/app/features/categories/category-list/category-list.scss @@ -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; + } + } + } +} diff --git a/frontend/src/app/features/categories/category-list/category-list.ts b/frontend/src/app/features/categories/category-list/category-list.ts new file mode 100644 index 0000000..9be9385 --- /dev/null +++ b/frontend/src/app/features/categories/category-list/category-list.ts @@ -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(); + + // Signals + searchControl = new FormControl(''); + filteredCategories = signal([]); + + // 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()); + } + }); + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.html b/frontend/src/app/features/dashboard/dashboard.component.html new file mode 100644 index 0000000..dba7808 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.html @@ -0,0 +1,212 @@ + +
+ +

Loading your dashboard...

+
+ + +
+ error_outline +

Failed to Load Dashboard

+

{{ error() }}

+ +
+ + +
+ + +
+
+

Welcome back, {{ username() }}! 👋

+

Ready to test your knowledge today?

+
+ +
+ + +
+ quiz +

Start Your Journey!

+

You haven't taken any quizzes yet. Start your first quiz to see your progress here.

+ +
+ + +
+ + +
+ + +
+ {{ stat.icon }} + {{ stat.badge }} +
+
{{ stat.value }}
+
{{ stat.title }}
+
{{ stat.description }}
+
+
+
+ + + + + + bar_chart + Top Categories Performance + + + +
+
+
+ {{ category.categoryName }} + + {{ category.quizzesTaken }} {{ category.quizzesTaken === 1 ? 'quiz' : 'quizzes' }} + +
+
+
+
+ {{ category.accuracy.toFixed(1) }}% +
+
+ + +
+

No category data available yet

+
+
+
+ + + + + + history + Recent Quiz Sessions + + + + +
+
+
+ quiz +
+
+
{{ session.categoryName || 'Quiz' }}
+
+ {{ formatDate(session.completedAt) }} + + {{ formatDuration(session.timeSpent) }} + + + {{ session.difficulty }} + +
+
+
+ + {{ session.score }}/{{ session.totalQuestions }} + + {{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}% +
+ chevron_right +
+
+ + +
+

No recent quiz sessions

+
+
+
+ + + + + + emoji_events + Achievements & Badges + + + +
+
+
+ {{ achievement.icon }} +
+
{{ achievement.name }}
+
+ {{ formatDate(achievement.earnedAt) }} +
+
+
+ + +
+

No achievements earned yet. Keep taking quizzes to unlock badges!

+
+
+
+ + +
+ + + + +
+
+
diff --git a/frontend/src/app/features/dashboard/dashboard.component.scss b/frontend/src/app/features/dashboard/dashboard.component.scss new file mode 100644 index 0000000..dd8d9ea --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.scss @@ -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%); + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.ts b/frontend/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..2017e56 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.ts @@ -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(true); + dashboard = signal(null); + error = signal(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); + } + }); + } + } +} diff --git a/frontend/src/app/features/history/quiz-history.component.html b/frontend/src/app/features/history/quiz-history.component.html new file mode 100644 index 0000000..1dd9c91 --- /dev/null +++ b/frontend/src/app/features/history/quiz-history.component.html @@ -0,0 +1,214 @@ + +
+ +

Loading quiz history...

+
+ + +
+ error_outline +

Failed to Load History

+

{{ error() }}

+ +
+ + +
+ + +
+
+

+ history + Quiz History +

+

View all your completed quizzes

+
+ +
+ + + + +
+ + Filter by Category + + All Categories + + {{ category.name }} + + + + + + Sort By + + Date (Newest First) + Score (Highest First) + + + + +
+
+
+ + +
+ quiz +

No Quiz History

+

You haven't completed any quizzes yet. Start your first quiz to see it here!

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date + {{ formatDate(session.completedAt || session.startedAt) }} + Category + {{ session.categoryName || 'Unknown' }} + Score + + {{ session.score }}/{{ session.totalQuestions }} + ({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%) + + Time Spent + {{ formatDuration(session.timeSpent) }} + Status + + {{ session.status === 'in_progress' ? 'In Progress' : + session.status === 'completed' ? 'Completed' : + 'Abandoned' }} + + Actions + + +
+
+ + +
+ + +
+
+ quiz + {{ session.categoryName || 'Unknown' }} +
+ + {{ session.status === 'in_progress' ? 'In Progress' : + session.status === 'completed' ? 'Completed' : + 'Abandoned' }} + +
+ +
+
+ calendar_today + {{ formatDate(session.completedAt || session.startedAt) }} +
+
+ timer + {{ formatDuration(session.timeSpent) }} +
+
+ Score: + + {{ session.score }}/{{ session.totalQuestions }} + ({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%) + +
+
+ +
+ + +
+
+
+
+ + + + +
diff --git a/frontend/src/app/features/history/quiz-history.component.scss b/frontend/src/app/features/history/quiz-history.component.scss new file mode 100644 index 0000000..c196d4d --- /dev/null +++ b/frontend/src/app/features/history/quiz-history.component.scss @@ -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); + } + } + } +} diff --git a/frontend/src/app/features/history/quiz-history.component.ts b/frontend/src/app/features/history/quiz-history.component.ts new file mode 100644 index 0000000..d1e2420 --- /dev/null +++ b/frontend/src/app/features/history/quiz-history.component.ts @@ -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(true); + history = signal([]); + pagination = signal(null); + categories = signal([]); + error = signal(null); + + // Filter and sort state + currentPage = signal(1); + pageSize = signal(10); + selectedCategory = signal(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(); + } +} diff --git a/frontend/src/app/features/quiz/quiz-question/quiz-question.html b/frontend/src/app/features/quiz/quiz-question/quiz-question.html new file mode 100644 index 0000000..8024b74 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-question/quiz-question.html @@ -0,0 +1,205 @@ +
+ +
+
+ + Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions() }} + + @if (activeSession()?.quizType === 'timed') { +
+ timer + {{ formatTime(timeRemaining()) }} +
+ } +
+ stars + Score: {{ currentScore() }} +
+
+ + +
+ + + + @if (currentQuestion(); as question) { + + +
+
+ {{ questionTypeLabel() }} + + {{ question.difficulty | titlecase }} + + {{ question.points }} points +
+
+
+ + + + + +
+

{{ question.questionText }}

+
+ + +
+ + + @if (isMultipleChoice() && question.options) { + + @for (option of question.options; track option) { + + {{ option }} + + } + + } + + + @if (isTrueFalse()) { +
+ + +
+ } + + + @if (isWritten()) { + + Your Answer + + Be as detailed as possible + + } + + + @if (answerSubmitted() && answerResult()) { +
+ + + @if (!answerResult()?.isCorrect) { +
+ Correct Answer: +

{{ answerResult()?.correctAnswer }}

+
+ } + + @if (answerResult()?.explanation) { +
+ Explanation: +

{{ answerResult()?.explanation }}

+
+ } + +
+ stars + Points earned: {{ answerResult()?.points }} +
+
+ } + + +
+ @if (!answerSubmitted()) { + + } @else { + + } +
+
+
+ } @else { + + + +

Loading question...

+
+ } +
+ + + +

Quiz Progress

+
+
+ check_circle + {{ correctAnswers() }} Correct +
+
+ cancel + {{ activeSession()?.incorrectAnswers || 0 }} Incorrect +
+
+ stars + {{ currentScore() }} Points +
+
+
+
diff --git a/frontend/src/app/features/quiz/quiz-question/quiz-question.scss b/frontend/src/app/features/quiz/quiz-question/quiz-question.scss new file mode 100644 index 0000000..a5851a8 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-question/quiz-question.scss @@ -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); + } + } + } +} diff --git a/frontend/src/app/features/quiz/quiz-question/quiz-question.ts b/frontend/src/app/features/quiz/quiz-question/quiz-question.ts new file mode 100644 index 0000000..985bc86 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-question/quiz-question.ts @@ -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(); + + // 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(null); + + // Answer feedback state + readonly answerSubmitted = signal(false); + readonly answerResult = signal(null); + readonly showExplanation = signal(false); + + // Timer state (for timed quizzes) + readonly timeRemaining = signal(0); // in seconds + readonly timerRunning = signal(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'; + } +} diff --git a/frontend/src/app/features/quiz/quiz-results/quiz-results.html b/frontend/src/app/features/quiz/quiz-results/quiz-results.html new file mode 100644 index 0000000..eb6bb3a --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-results/quiz-results.html @@ -0,0 +1,337 @@ +
+ + @if (isLoading()) { +
+ +

Loading results...

+
+ } + + + @if (!isLoading() && results()) { + + @if (showConfetti()) { +
+ @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) { +
+ } +
+ } + +
+ +
+
+ @if (performanceLevel() === 'excellent') { + emoji_events + } @else if (performanceLevel() === 'good') { + thumb_up + } @else if (performanceLevel() === 'average') { + trending_up + } @else { + school + } +
+

Quiz Completed!

+

+ {{ performanceMessage() }} +

+
+ + + + +
+
+ + + + +
+ {{ scorePercentage() }}% + Score +
+
+
+
+ check_circle +
+
{{ results()!.correctAnswers }}
+
Correct
+
+
+
+ cancel +
+
{{ results()!.incorrectAnswers }}
+
Incorrect
+
+
+ @if (results()!.skippedAnswers > 0) { +
+ remove_circle +
+
{{ results()!.skippedAnswers }}
+
Skipped
+
+
+ } +
+
+ + + + +
+
+ + + + + Performance Breakdown + + +
+
+ + + + + + + + + @if (chartPercentages().skipped > 0) { + + } + +
+ {{ results()!.totalQuestions }} + Questions +
+
+ +
+
+ + Correct ({{ chartData().correct }}) +
+
+ + Incorrect ({{ chartData().incorrect }}) +
+ @if (chartData().skipped > 0) { +
+ + Skipped ({{ chartData().skipped }}) +
+ } +
+
+
+
+ + + + + Question Review + Review all questions and answers + + +
+ @for (question of results()!.questions; track question.questionId; let i = $index) { +
+
+
+ {{ i + 1 }} + @if (question.isCorrect) { + check_circle + } @else { + cancel + } +
+
+ {{ getQuestionTypeText(question.questionType) }} + {{ question.points }} pts +
+
+ +
{{ question.questionText }}
+ +
+
+ Your Answer: + + {{ question.userAnswer || 'Not answered' }} + +
+ @if (!question.isCorrect) { +
+ Correct Answer: + {{ question.correctAnswer }} +
+ } +
+ + @if (question.explanation) { +
+ info +

{{ question.explanation }}

+
+ } +
+ } +
+
+
+ + +
+ + + @if (hasIncorrectAnswers()) { + + } + + +
+ + + +
+ } +
diff --git a/frontend/src/app/features/quiz/quiz-results/quiz-results.scss b/frontend/src/app/features/quiz/quiz-results/quiz-results.scss new file mode 100644 index 0000000..63f3a34 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-results/quiz-results.scss @@ -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); + } +} diff --git a/frontend/src/app/features/quiz/quiz-results/quiz-results.ts b/frontend/src/app/features/quiz/quiz-results/quiz-results.ts new file mode 100644 index 0000000..b5a706d --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-results/quiz-results.ts @@ -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(); + + readonly sessionId = signal(''); + readonly results = this.quizService.quizResults; + readonly isLoading = signal(true); + readonly showConfetti = signal(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'); + }); + } +} diff --git a/frontend/src/app/features/quiz/quiz-review/quiz-review.html b/frontend/src/app/features/quiz/quiz-review/quiz-review.html new file mode 100644 index 0000000..ca92985 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-review/quiz-review.html @@ -0,0 +1,243 @@ +
+ + @if (isLoading()) { +
+ +

Loading review...

+
+ } + + + @if (!isLoading() && results()) { +
+ +
+ +
+

Quiz Review

+

Review your answers and learn from mistakes

+
+
+ + +
+ + +
+ quiz +
+
+
{{ allQuestions().length }}
+
Total Questions
+
+
+
+ + + +
+ check_circle +
+
+
{{ correctCount() }}
+
Correct
+
+
+
+ + + +
+ cancel +
+
+
{{ incorrectCount() }}
+
Incorrect
+
+
+
+ + + +
+ emoji_events +
+
+
{{ results()!.percentage }}%
+
Score
+
+
+
+
+ + +
+ + + +
+ + +
+ @for (question of paginatedQuestions(); track question.questionId; let i = $index) { + + +
+
+ {{ (pageIndex() * pageSize()) + i + 1 }} + + {{ question.isCorrect ? 'check_circle' : 'cancel' }} + +
+ +
+ + {{ getQuestionTypeText(question.questionType) }} + + {{ question.points }} pts +
+ + @if (isAuthenticated()) { + + } +
+
+ + +
{{ question.questionText }}
+ + + +
+
+
Your Answer:
+
+ {{ formatAnswer(question.userAnswer) || 'Not answered' }} + @if (!question.isCorrect) { + close + } @else { + check + } +
+
+ + @if (!question.isCorrect) { +
+
Correct Answer:
+
+ {{ formatAnswer(question.correctAnswer) }} + check +
+
+ } +
+ + @if (question.explanation) { +
+
+ lightbulb + Explanation +
+

{{ question.explanation }}

+
+ } + + @if (question.timeSpent) { +
+ schedule + Time spent: {{ question.timeSpent }}s +
+ } +
+
+ } + + @if (paginatedQuestions().length === 0) { +
+ info +

No questions match the selected filter

+
+ } +
+ + + @if (totalQuestions() > pageSize()) { + + + } + + +
+ + + + + +
+
+ } +
diff --git a/frontend/src/app/features/quiz/quiz-review/quiz-review.scss b/frontend/src/app/features/quiz/quiz-review/quiz-review.scss new file mode 100644 index 0000000..22ea971 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-review/quiz-review.scss @@ -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)); + } +} diff --git a/frontend/src/app/features/quiz/quiz-review/quiz-review.ts b/frontend/src/app/features/quiz/quiz-review/quiz-review.ts new file mode 100644 index 0000000..e7527e2 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-review/quiz-review.ts @@ -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(); + + readonly sessionId = signal(''); + readonly results = this.quizService.quizResults; + readonly isLoading = signal(true); + + // Pagination + readonly pageSize = signal(10); + readonly pageIndex = signal(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>(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']); + } +} diff --git a/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html new file mode 100644 index 0000000..ae29c1f --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html @@ -0,0 +1,201 @@ +
+ + +
+
+ play_circle +

Start New Quiz

+
+

Configure your quiz settings and challenge yourself!

+
+
+ + + + @if (showGuestWarning()) { +
+ warning +
+

Limited Quizzes Remaining

+

You have {{ remainingQuizzes() }} quiz(es) left as a guest.

+ +
+
+ } + + + @if (isLoadingCategories()) { +
+ +

Loading categories...

+
+ } + + + @if (!isLoadingCategories()) { +
+ + +
+

+ category + Select Category +

+ + Choose a category + + @for (category of getAvailableCategories(); track category.id) { + +
+ @if (category.icon) { + {{ category.icon }} + } + {{ category.name }} + ({{ category.questionCount }} questions) +
+
+ } +
+ @if (setupForm.get('categoryId')?.hasError('required') && setupForm.get('categoryId')?.touched) { + Please select a category + } +
+ + @if (selectedCategory()) { +
+ {{ selectedCategory()?.icon }} +
+

{{ selectedCategory()?.name }}

+

{{ selectedCategory()?.description }}

+
+
+ } +
+ + +
+

+ format_list_numbered + Number of Questions +

+
+ @for (count of questionCountOptions; track count) { + + } +
+

Selected: {{ setupForm.get('questionCount')?.value }} questions

+
+ + +
+

+ tune + Difficulty Level +

+
+ @for (difficulty of difficultyOptions; track difficulty.value) { + + } +
+
+ + +
+

+ mode + Quiz Mode +

+
+ @for (type of quizTypeOptions; track type.value) { + + {{ type.icon }} +

{{ type.label }}

+

{{ type.description }}

+
+ } +
+
+ + +
+ +

+ info + Quiz Summary +

+
+
+ Category: + {{ selectedCategory()?.name || 'Not selected' }} +
+
+ Questions: + {{ setupForm.get('questionCount')?.value }} +
+
+ Difficulty: + + {{ getSelectedDifficultyLabel() }} + +
+
+ Mode: + + {{ getSelectedQuizTypeLabel() }} + +
+
+ Estimated Time: + ~{{ estimatedTime() }} minutes +
+
+
+
+ + +
+ + +
+
+ } +
+
+
diff --git a/frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss new file mode 100644 index 0000000..69ac810 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.scss @@ -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); + } + } + } + } +} diff --git a/frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts new file mode 100644 index 0000000..a020082 --- /dev/null +++ b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.ts @@ -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(); + + // 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'; + } +} diff --git a/frontend/src/app/shared/components/header/header.ts b/frontend/src/app/shared/components/header/header.ts index 6f91805..1895c66 100644 --- a/frontend/src/app/shared/components/header/header.ts +++ b/frontend/src/app/shared/components/header/header.ts @@ -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(); @@ -67,6 +73,68 @@ export class HeaderComponent { get isGuest() { 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 diff --git a/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.html b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.html new file mode 100644 index 0000000..f381dbe --- /dev/null +++ b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.html @@ -0,0 +1,107 @@ +
+
+
+ history +
+

Resume Quiz?

+ +
+ + +
+

+ You have an incomplete quiz session. Would you like to continue where you left off? +

+ +
+
+ quiz + Progress: + + Question {{ session().currentQuestionIndex + 1 }} of {{ session().totalQuestions }} + +
+ +
+ + {{ progress() }}% Complete +
+ +
+ category + Category: + {{ session().categoryName || 'Quiz' }} +
+ +
+ tune + Difficulty: + {{ formatDifficulty(session().difficulty) }} +
+ +
+ timer + Quiz Type: + {{ getQuizTypeText(session().quizType) }} +
+ +
+ schedule + Started: + {{ formatTimeElapsed() }} +
+ +
+
+ check_circle + {{ session().correctAnswers }} + Correct +
+
+ cancel + {{ session().incorrectAnswers }} + Incorrect +
+
+ help_outline + {{ questionsRemaining() }} + Remaining +
+
+ + @if (session().score > 0) { +
+ emoji_events + Current Score: {{ session().score }} points +
+ } +
+
+
+ + + + + +
diff --git a/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.scss b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.scss new file mode 100644 index 0000000..de27fed --- /dev/null +++ b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.scss @@ -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)); + } +} diff --git a/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.ts b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.ts new file mode 100644 index 0000000..5364738 --- /dev/null +++ b/frontend/src/app/shared/components/resume-quiz-dialog/resume-quiz-dialog.ts @@ -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); + readonly data = inject(MAT_DIALOG_DATA); + private readonly router = inject(Router); + + readonly session = signal(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`; + } +} diff --git a/frontend/src/app/shared/components/sidebar/sidebar.ts b/frontend/src/app/shared/components/sidebar/sidebar.ts index e342b32..ba6c1a5 100644 --- a/frontend/src/app/shared/components/sidebar/sidebar.ts +++ b/frontend/src/app/shared/components/sidebar/sidebar.ts @@ -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', diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index be58b8b..10913ec 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -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,