From 37b4d565b1c121609f498cfd31fb96c5206fd892 Mon Sep 17 00:00:00 2001 From: AD2025 Date: Fri, 14 Nov 2025 21:48:47 +0200 Subject: [PATCH] add changes --- frontend/FRONTEND_UI_TASKS.md | 447 +++++----- frontend/src/app/app.config.ts | 16 +- frontend/src/app/app.html | 10 + frontend/src/app/app.routes.ts | 105 ++- frontend/src/app/app.scss | 18 + frontend/src/app/app.ts | 26 +- frontend/src/app/core/guards/admin.guard.ts | 47 + frontend/src/app/core/interceptors/index.ts | 1 + .../core/interceptors/loading.interceptor.ts | 27 + frontend/src/app/core/models/admin.model.ts | 280 ++++++ .../src/app/core/models/bookmark.model.ts | 57 ++ .../src/app/core/models/dashboard.model.ts | 11 + .../src/app/core/services/admin.service.ts | 815 ++++++++++++++++++ .../src/app/core/services/bookmark.service.ts | 270 ++++++ .../services/global-error-handler.service.ts | 107 +++ frontend/src/app/core/services/index.ts | 2 + .../app/core/services/pagination.service.ts | 240 ++++++ .../src/app/core/services/search.service.ts | 296 +++++++ .../src/app/core/services/user.service.ts | 21 +- .../admin-dashboard.component.html | 275 ++++++ .../admin-dashboard.component.scss | 511 +++++++++++ .../admin-dashboard.component.ts | 285 ++++++ .../admin-question-form.component.html | 430 +++++++++ .../admin-question-form.component.scss | 535 ++++++++++++ .../admin-question-form.component.ts | 480 +++++++++++ .../admin-questions.component.html | 226 +++++ .../admin-questions.component.scss | 341 ++++++++ .../admin-questions.component.ts | 304 +++++++ .../admin-user-detail.component.html | 329 +++++++ .../admin-user-detail.component.scss | 752 ++++++++++++++++ .../admin-user-detail.component.ts | 362 ++++++++ .../admin-users/admin-users.component.html | 283 ++++++ .../admin-users/admin-users.component.scss | 466 ++++++++++ .../admin-users/admin-users.component.ts | 400 +++++++++ .../admin/category-form/category-form.html | 14 +- .../delete-confirm-dialog.component.ts | 216 +++++ .../guest-analytics.component.html | 251 ++++++ .../guest-analytics.component.scss | 474 ++++++++++ .../guest-analytics.component.ts | 260 ++++++ .../guest-settings-edit.component.html | 255 ++++++ .../guest-settings-edit.component.scss | 468 ++++++++++ .../guest-settings-edit.component.ts | 276 ++++++ .../guest-settings.component.html | 230 +++++ .../guest-settings.component.scss | 449 ++++++++++ .../guest-settings.component.ts | 127 +++ .../role-update-dialog.component.html | 174 ++++ .../role-update-dialog.component.scss | 415 +++++++++ .../role-update-dialog.component.ts | 132 +++ .../status-update-dialog.component.html | 91 ++ .../status-update-dialog.component.scss | 387 +++++++++ .../status-update-dialog.component.ts | 109 +++ .../bookmarks/bookmarks.component.html | 263 ++++++ .../bookmarks/bookmarks.component.scss | 561 ++++++++++++ .../features/bookmarks/bookmarks.component.ts | 275 ++++++ .../dashboard/dashboard.component.html | 14 +- .../dashboard/dashboard.component.scss | 41 +- .../profile/profile-settings.component.html | 277 ++++++ .../profile/profile-settings.component.scss | 448 ++++++++++ .../profile/profile-settings.component.ts | 347 ++++++++ .../quiz/quiz-question/quiz-question.html | 18 +- .../features/quiz/quiz-setup/quiz-setup.html | 6 +- .../back-button/back-button.component.ts | 126 +++ .../breadcrumb/breadcrumb.component.ts | 298 +++++++ .../components/error/error.component.ts | 379 ++++++++ .../app/shared/components/header/header.html | 5 + .../app/shared/components/header/header.scss | 11 + .../app/shared/components/header/header.ts | 4 +- .../loading-spinner/loading-spinner.html | 9 +- .../pagination/pagination.component.ts | 334 +++++++ .../components/search/search.component.html | 166 ++++ .../components/search/search.component.scss | 352 ++++++++ .../components/search/search.component.ts | 313 +++++++ 72 files changed, 17104 insertions(+), 246 deletions(-) create mode 100644 frontend/src/app/core/guards/admin.guard.ts create mode 100644 frontend/src/app/core/interceptors/loading.interceptor.ts create mode 100644 frontend/src/app/core/models/admin.model.ts create mode 100644 frontend/src/app/core/models/bookmark.model.ts create mode 100644 frontend/src/app/core/services/admin.service.ts create mode 100644 frontend/src/app/core/services/bookmark.service.ts create mode 100644 frontend/src/app/core/services/global-error-handler.service.ts create mode 100644 frontend/src/app/core/services/pagination.service.ts create mode 100644 frontend/src/app/core/services/search.service.ts create mode 100644 frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.html create mode 100644 frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.scss create mode 100644 frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.ts create mode 100644 frontend/src/app/features/admin/admin-question-form/admin-question-form.component.html create mode 100644 frontend/src/app/features/admin/admin-question-form/admin-question-form.component.scss create mode 100644 frontend/src/app/features/admin/admin-question-form/admin-question-form.component.ts create mode 100644 frontend/src/app/features/admin/admin-questions/admin-questions.component.html create mode 100644 frontend/src/app/features/admin/admin-questions/admin-questions.component.scss create mode 100644 frontend/src/app/features/admin/admin-questions/admin-questions.component.ts create mode 100644 frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.html create mode 100644 frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.scss create mode 100644 frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.ts create mode 100644 frontend/src/app/features/admin/admin-users/admin-users.component.html create mode 100644 frontend/src/app/features/admin/admin-users/admin-users.component.scss create mode 100644 frontend/src/app/features/admin/admin-users/admin-users.component.ts create mode 100644 frontend/src/app/features/admin/delete-confirm-dialog/delete-confirm-dialog.component.ts create mode 100644 frontend/src/app/features/admin/guest-analytics/guest-analytics.component.html create mode 100644 frontend/src/app/features/admin/guest-analytics/guest-analytics.component.scss create mode 100644 frontend/src/app/features/admin/guest-analytics/guest-analytics.component.ts create mode 100644 frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.html create mode 100644 frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.scss create mode 100644 frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.ts create mode 100644 frontend/src/app/features/admin/guest-settings/guest-settings.component.html create mode 100644 frontend/src/app/features/admin/guest-settings/guest-settings.component.scss create mode 100644 frontend/src/app/features/admin/guest-settings/guest-settings.component.ts create mode 100644 frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.html create mode 100644 frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.scss create mode 100644 frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.ts create mode 100644 frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.html create mode 100644 frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.scss create mode 100644 frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.ts create mode 100644 frontend/src/app/features/bookmarks/bookmarks.component.html create mode 100644 frontend/src/app/features/bookmarks/bookmarks.component.scss create mode 100644 frontend/src/app/features/bookmarks/bookmarks.component.ts create mode 100644 frontend/src/app/features/profile/profile-settings.component.html create mode 100644 frontend/src/app/features/profile/profile-settings.component.scss create mode 100644 frontend/src/app/features/profile/profile-settings.component.ts create mode 100644 frontend/src/app/shared/components/back-button/back-button.component.ts create mode 100644 frontend/src/app/shared/components/breadcrumb/breadcrumb.component.ts create mode 100644 frontend/src/app/shared/components/error/error.component.ts create mode 100644 frontend/src/app/shared/components/pagination/pagination.component.ts create mode 100644 frontend/src/app/shared/components/search/search.component.html create mode 100644 frontend/src/app/shared/components/search/search.component.scss create mode 100644 frontend/src/app/shared/components/search/search.component.ts diff --git a/frontend/FRONTEND_UI_TASKS.md b/frontend/FRONTEND_UI_TASKS.md index 555869a..d9dd35a 100644 --- a/frontend/FRONTEND_UI_TASKS.md +++ b/frontend/FRONTEND_UI_TASKS.md @@ -499,21 +499,21 @@ **Purpose:** Update user profile **Frontend Tasks:** -- [ ] Add `UserService.updateProfile(userId, data)` method -- [ ] Update `authState` signal with new user data -- [ ] Validate form data (email, username) -- [ ] Handle 409 Conflict for duplicate email/username -- [ ] Handle password change separately (if supported) +- [x] Add `UserService.updateProfile(userId, data)` method +- [x] Update `authState` signal with new user data +- [x] Validate form data (email, username) +- [x] Handle 409 Conflict for duplicate email/username +- [x] Handle password change separately (if supported) **UI Tasks:** -- [ ] Build `ProfileSettingsComponent` for editing profile -- [ ] Create form with username, email fields -- [ ] Add password change section (current, new, confirm) -- [ ] Show validation errors inline -- [ ] Add "Save Changes" and "Cancel" buttons -- [ ] Display success toast after update -- [ ] Show loading spinner on submit -- [ ] Pre-fill form with current user data +- [x] Build `ProfileSettingsComponent` for editing profile +- [x] Create form with username, email fields +- [x] Add password change section (current, new, confirm) +- [x] Show validation errors inline +- [x] Add "Save Changes" and "Cancel" buttons +- [x] Display success toast after update +- [x] Show loading spinner on submit +- [x] Pre-fill form with current user data --- @@ -523,19 +523,19 @@ **Purpose:** Get user's bookmarked questions **Frontend Tasks:** -- [ ] Create `BookmarkService` with `getBookmarks(userId)` method -- [ ] Store bookmarks in `bookmarksState` signal -- [ ] Implement caching (5 min TTL) -- [ ] Handle 401 if not authenticated +- [x] Create `BookmarkService` with `getBookmarks(userId)` method +- [x] Store bookmarks in `bookmarksState` signal +- [x] Implement caching (5 min TTL) +- [x] Handle 401 if not authenticated **UI Tasks:** -- [ ] Build `BookmarksComponent` displaying all bookmarked questions -- [ ] Show question cards with text, category, difficulty -- [ ] Add "Remove Bookmark" button for each question -- [ ] Add "Practice Bookmarked Questions" button to start quiz -- [ ] Show empty state if no bookmarks -- [ ] Implement grid layout (responsive) -- [ ] Add search/filter for bookmarks +- [x] Build `BookmarksComponent` displaying all bookmarked questions +- [x] Show question cards with text, category, difficulty +- [x] Add "Remove Bookmark" button for each question +- [x] Add "Practice Bookmarked Questions" button to start quiz +- [x] Show empty state if no bookmarks +- [x] Implement grid layout (responsive) +- [x] Add search/filter for bookmarks --- @@ -543,17 +543,17 @@ **Purpose:** Add question to bookmarks **Frontend Tasks:** -- [ ] Add `BookmarkService.addBookmark(userId, questionId)` method -- [ ] Update `bookmarksState` signal optimistically -- [ ] Handle 409 if already bookmarked -- [ ] Show success/error toast +- [x] Add `BookmarkService.addBookmark(userId, questionId)` method +- [x] Update `bookmarksState` signal optimistically +- [x] Handle 409 if already bookmarked +- [x] Show success/error toast **UI Tasks:** -- [ ] Add bookmark icon button on question cards -- [ ] Show filled/unfilled icon based on bookmark status -- [ ] Animate icon on toggle -- [ ] Display success toast "Question bookmarked" -- [ ] Ensure button is accessible with ARIA label +- [x] Add bookmark icon button on question cards +- [x] Show filled/unfilled icon based on bookmark status +- [x] Animate icon on toggle +- [x] Display success toast "Question bookmarked" +- [x] Ensure button is accessible with ARIA label --- @@ -561,14 +561,14 @@ **Purpose:** Remove bookmark **Frontend Tasks:** -- [ ] Add `BookmarkService.removeBookmark(userId, questionId)` method -- [ ] Update `bookmarksState` signal optimistically -- [ ] Handle 404 if bookmark not found +- [x] Add `BookmarkService.removeBookmark(userId, questionId)` method +- [x] Update `bookmarksState` signal optimistically +- [x] Handle 404 if bookmark not found **UI Tasks:** -- [ ] Toggle bookmark icon to unfilled state -- [ ] Remove question from bookmarks list -- [ ] Display success toast "Bookmark removed" +- [x] Toggle bookmark icon to unfilled state +- [x] Remove question from bookmarks list +- [x] Display success toast "Bookmark removed" - [ ] Add undo option in toast (optional) --- @@ -579,25 +579,25 @@ **Purpose:** Get system-wide statistics **Frontend Tasks:** -- [ ] Create `AdminService` with `getStatistics()` method -- [ ] Store stats in `adminStatsState` signal -- [ ] Implement caching (5 min TTL) -- [ ] Handle 401/403 authorization errors -- [ ] Create admin auth guard for routes +- [x] Create `AdminService` with `getStatistics()` method +- [x] Store stats in `adminStatsState` signal +- [x] Implement caching (5 min TTL) +- [x] Handle 401/403 authorization errors +- [x] Create admin auth guard for routes **UI Tasks:** -- [ ] Build `AdminDashboardComponent` as admin landing page -- [ ] Display statistics cards: +- [x] Build `AdminDashboardComponent` as admin landing page +- [x] Display statistics cards: - Total users - Active users (last 7 days) - Total quiz sessions - Total questions -- [ ] Show user growth chart (line chart) -- [ ] Display most popular categories (bar chart) -- [ ] Show average quiz scores -- [ ] Add date range picker for filtering stats -- [ ] Implement responsive layout -- [ ] Show loading skeletons for charts +- [x] Show user growth chart (line chart) +- [x] Display most popular categories (bar chart) +- [x] Show average quiz scores +- [x] Add date range picker for filtering stats +- [x] Implement responsive layout +- [x] Show loading skeletons for charts --- @@ -605,20 +605,20 @@ **Purpose:** Get guest user analytics **Frontend Tasks:** -- [ ] Add `AdminService.getGuestAnalytics()` method -- [ ] Store analytics in `guestAnalyticsState` signal -- [ ] Implement caching (10 min TTL) +- [x] Add `AdminService.getGuestAnalytics()` method +- [x] Store analytics in `guestAnalyticsState` signal +- [x] Implement caching (10 min TTL) **UI Tasks:** -- [ ] Build `GuestAnalyticsComponent` (admin) -- [ ] Display guest statistics: +- [x] Build `GuestAnalyticsComponent` (admin) +- [x] Display guest statistics: - Total guest sessions - Active guest sessions - Guest-to-user conversion rate - Average quizzes per guest -- [ ] Show conversion funnel chart -- [ ] Display guest session timeline chart -- [ ] Add export functionality +- [x] Show conversion funnel chart +- [x] Display guest session timeline chart +- [x] Add export functionality --- @@ -626,13 +626,13 @@ **Purpose:** Get guest access settings **Frontend Tasks:** -- [ ] Add `AdminService.getGuestSettings()` method -- [ ] Store settings in `guestSettingsState` signal +- [x] Add `AdminService.getGuestSettings()` method +- [x] Store settings in `guestSettingsState` signal **UI Tasks:** -- [ ] Build `GuestSettingsComponent` (admin) for viewing settings -- [ ] Display current settings in read-only cards -- [ ] Add "Edit Settings" button +- [x] Build `GuestSettingsComponent` (admin) for viewing settings +- [x] Display current settings in read-only cards +- [x] Add "Edit Settings" button --- @@ -640,22 +640,22 @@ **Purpose:** Update guest access settings **Frontend Tasks:** -- [ ] Add `AdminService.updateGuestSettings(data)` method -- [ ] Update `guestSettingsState` signal -- [ ] Validate form data -- [ ] Handle success/error responses +- [x] Add `AdminService.updateGuestSettings(data)` method +- [x] Update `guestSettingsState` signal +- [x] Validate form data +- [x] Handle success/error responses **UI Tasks:** -- [ ] Build settings form with fields: +- [x] Build settings form with fields: - Guest access enabled toggle - Max quizzes per day (number input) - Max questions per quiz (number input) - Session expiry hours (number input) - Upgrade prompt message (textarea) -- [ ] Add "Save Changes" and "Cancel" buttons -- [ ] Show validation errors inline -- [ ] Display success toast after update -- [ ] Show preview of settings changes +- [x] Add "Save Changes" and "Cancel" buttons +- [x] Show validation errors inline +- [x] Display success toast after update +- [x] Show preview of settings changes --- @@ -663,21 +663,21 @@ **Purpose:** Get all users with pagination **Frontend Tasks:** -- [ ] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method -- [ ] Store users in `adminUsersState` signal -- [ ] Implement pagination, filtering, and sorting -- [ ] Handle query parameters in URL +- [x] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method +- [x] Store users in `adminUsersState` signal +- [x] Implement pagination, filtering, and sorting +- [x] Handle query parameters in URL **UI Tasks:** -- [ ] Build `AdminUsersComponent` displaying user list -- [ ] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions -- [ ] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive) -- [ ] Add sort dropdown (Username, Email, Date) -- [ ] Add search input for username/email -- [ ] Implement pagination controls -- [ ] Add action buttons (Edit Role, View Details, Deactivate/Activate) -- [ ] Show loading spinner during fetch -- [ ] Make table responsive (stack on mobile) +- [x] Build `AdminUsersComponent` displaying user list +- [x] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions +- [x] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive) +- [x] Add sort dropdown (Username, Email, Date) +- [x] Add search input for username/email +- [x] Implement pagination controls +- [x] Add action buttons (Edit Role, View Details, Deactivate/Activate) +- [x] Show loading spinner during fetch +- [x] Make table responsive (stack on mobile) --- @@ -685,16 +685,16 @@ **Purpose:** Get user details **Frontend Tasks:** -- [ ] Add `AdminService.getUserDetails(userId)` method -- [ ] Store user details in signal -- [ ] Handle 404 if user not found +- [x] Add `AdminService.getUserDetails(userId)` method +- [x] Store user details in signal +- [x] Handle 404 if user not found **UI Tasks:** -- [ ] Build `AdminUserDetailComponent` showing full user profile -- [ ] Display user info, statistics, quiz history -- [ ] Add "Edit Role" and "Deactivate" buttons -- [ ] Show user activity timeline -- [ ] Add breadcrumb navigation +- [x] Build `AdminUserDetailComponent` showing full user profile +- [x] Display user info, statistics, quiz history +- [x] Add "Edit Role" and "Deactivate" buttons +- [x] Show user activity timeline +- [x] Add breadcrumb navigation --- @@ -702,16 +702,16 @@ **Purpose:** Update user role **Frontend Tasks:** -- [ ] Add `AdminService.updateUserRole(userId, role)` method -- [ ] Update user in `adminUsersState` signal -- [ ] Handle validation errors +- [x] Add `AdminService.updateUserRole(userId, role)` method +- [x] Update user in `adminUsersState` signal +- [x] Handle validation errors **UI Tasks:** -- [ ] Build role update modal/dialog -- [ ] Add role selector (User, Admin) -- [ ] Show confirmation dialog -- [ ] Display success toast after update -- [ ] Show warning if demoting admin +- [x] Build role update modal/dialog +- [x] Add role selector (User, Admin) +- [x] Show confirmation dialog +- [x] Display success toast after update +- [x] Show warning if demoting admin --- @@ -719,13 +719,13 @@ **Purpose:** Reactivate user **Frontend Tasks:** -- [ ] Add `AdminService.activateUser(userId)` method -- [ ] Update user status in signal +- [x] Add `AdminService.activateUser(userId)` method +- [x] Update user status in signal **UI Tasks:** -- [ ] Add "Activate" button for inactive users -- [ ] Show confirmation dialog -- [ ] Display success toast after activation +- [x] Add "Activate" button for inactive users +- [x] Show confirmation dialog +- [x] Display success toast after activation --- @@ -733,15 +733,15 @@ **Purpose:** Deactivate user **Frontend Tasks:** -- [ ] Add `AdminService.deactivateUser(userId)` method -- [ ] Update user status in signal -- [ ] Handle soft delete +- [x] Add `AdminService.deactivateUser(userId)` method +- [x] Update user status in signal +- [x] Handle soft delete **UI Tasks:** -- [ ] Add "Deactivate" button for active users -- [ ] Show confirmation dialog with warning message -- [ ] Display success toast after deactivation -- [ ] Show "Reactivate" button for deactivated users +- [x] Add "Deactivate" button for active users +- [x] Show confirmation dialog with warning message +- [x] Display success toast after deactivation +- [x] Show "Reactivate" button for deactivated users --- @@ -749,13 +749,13 @@ **Purpose:** Create new question **Frontend Tasks:** -- [ ] Add `AdminService.createQuestion(data)` method -- [ ] Validate question data (type, options, correct answer) -- [ ] Handle 401/403 authorization errors +- [x] Add `AdminService.createQuestion(data)` method +- [x] Validate question data (type, options, correct answer) +- [x] Handle 401/403 authorization errors **UI Tasks:** -- [ ] Build `AdminQuestionFormComponent` for creating questions -- [ ] Create form with fields: +- [x] Build `AdminQuestionFormComponent` for creating questions +- [x] Create form with fields: - Question text (textarea) - Question type selector (Multiple Choice, True/False, Written) - Category selector @@ -766,13 +766,13 @@ - Points (number) - Tags (chip input) - Guest accessible checkbox -- [ ] Show/hide options based on question type -- [ ] Add dynamic option inputs for MCQ (Add/Remove buttons) -- [ ] Validate correct answer matches options -- [ ] Show question preview panel -- [ ] Display validation errors inline -- [ ] Add "Save Question" and "Cancel" buttons -- [ ] Show success toast after creation +- [x] Show/hide options based on question type +- [x] Add dynamic option inputs for MCQ (Add/Remove buttons) +- [x] Validate correct answer matches options +- [x] Show question preview panel +- [x] Display validation errors inline +- [x] Add "Save Question" and "Cancel" buttons +- [x] Show success toast after creation --- @@ -780,16 +780,16 @@ **Purpose:** Update question **Frontend Tasks:** -- [ ] Add `AdminService.updateQuestion(id, data)` method -- [ ] Pre-fill form with existing question data -- [ ] Handle 404 if question not found +- [x] Add `AdminService.updateQuestion(id, data)` method +- [x] Pre-fill form with existing question data +- [x] Handle 404 if question not found **UI Tasks:** -- [ ] Reuse `AdminQuestionFormComponent` in edit mode -- [ ] Pre-populate all form fields -- [ ] Show "Editing: Question ID" header +- [x] Reuse `AdminQuestionFormComponent` in edit mode +- [x] Pre-populate all form fields +- [x] Show "Editing: Question ID" header - [ ] Add version history section (optional) -- [ ] Display success toast after update +- [x] Display success toast after update --- @@ -797,14 +797,14 @@ **Purpose:** Delete question **Frontend Tasks:** -- [ ] Add `AdminService.deleteQuestion(id)` method -- [ ] Handle soft delete -- [ ] Update question list after deletion +- [x] Add `AdminService.deleteQuestion(id)` method +- [x] Handle soft delete +- [x] Update question list after deletion **UI Tasks:** -- [ ] Add delete button in admin question list -- [ ] Show confirmation dialog with warning -- [ ] Display success toast after deletion +- [x] Add delete button in admin question list +- [x] Show confirmation dialog with warning +- [x] Display success toast after deletion - [ ] Add "Restore" option for soft-deleted questions --- @@ -814,21 +814,21 @@ ### Search Functionality **Frontend Tasks:** -- [ ] Create `SearchService` for global search -- [ ] Implement debounced search input -- [ ] Search across questions, categories, quizzes -- [ ] Store search results in signal -- [ ] Handle empty search results +- [x] Create `SearchService` for global search +- [x] Implement debounced search input +- [x] Search across questions, categories, quizzes +- [x] Store search results in signal +- [x] Handle empty search results **UI Tasks:** -- [ ] Build `SearchComponent` in header/navbar -- [ ] Create search input with icon -- [ ] Display search results dropdown -- [ ] Highlight matching text in results -- [ ] Add "See All Results" link -- [ ] Implement keyboard navigation (arrow keys, enter) -- [ ] Show loading indicator during search -- [ ] Display empty state for no results +- [x] Build `SearchComponent` in header/navbar +- [x] Create search input with icon +- [x] Display search results dropdown +- [x] Highlight matching text in results +- [x] Add "See All Results" link +- [x] Implement keyboard navigation (arrow keys, enter) +- [x] Show loading indicator during search +- [x] Display empty state for no results --- @@ -850,7 +850,7 @@ --- -### Social Share + ### Pagination Component **Frontend Tasks:** -- [ ] Create reusable `PaginationService` -- [ ] Calculate page numbers and ranges -- [ ] Handle page change events -- [ ] Update URL query parameters +- [x] Create reusable `PaginationService` +- [x] Calculate page numbers and ranges +- [x] Handle page change events +- [x] Update URL query parameters **UI Tasks:** -- [ ] Build reusable `PaginationComponent` -- [ ] Show Previous, Next, and page numbers -- [ ] Highlight current page -- [ ] Disable Previous on first page, Next on last page -- [ ] Display "Showing X-Y of Z results" -- [ ] Implement responsive design (fewer page numbers on mobile) +- [x] Build reusable `PaginationComponent` +- [x] Show Previous, Next, and page numbers +- [x] Highlight current page +- [x] Disable Previous on first page, Next on last page +- [x] Display "Showing X-Y of Z results" +- [x] Implement responsive design (fewer page numbers on mobile) --- ### Error Handling **Frontend Tasks:** -- [ ] Create global error handler service -- [ ] Log errors to console and external service (optional) -- [ ] Display user-friendly error messages -- [ ] Handle network errors gracefully -- [ ] Implement retry logic for failed requests +- [x] Create global error handler service +- [x] Log errors to console and external service (optional) +- [x] Display user-friendly error messages +- [x] Handle network errors gracefully +- [x] Implement retry logic for failed requests **UI Tasks:** -- [ ] Build `ErrorComponent` for displaying errors -- [ ] Create error toast component -- [ ] Show "Something went wrong" page for critical errors -- [ ] Add "Retry" button for recoverable errors -- [ ] Display specific error messages (401, 403, 404, 500) +- [x] Build `ErrorComponent` for displaying errors +- [x] Create error toast component +- [x] Show "Something went wrong" page for critical errors +- [x] Add "Retry" button for recoverable errors +- [x] Display specific error messages (401, 403, 404, 500) --- ### Loading States **Frontend Tasks:** -- [ ] Create `LoadingService` for global loading state -- [ ] Use signals for loading indicators -- [ ] Show loading during HTTP requests -- [ ] Handle concurrent loading states +- [x] Create `LoadingService` for global loading state +- [x] Use signals for loading indicators +- [x] Show loading during HTTP requests +- [x] Handle concurrent loading states **UI Tasks:** -- [ ] Build reusable `LoadingSpinnerComponent` -- [ ] Create skeleton loaders for lists and cards -- [ ] Show inline loading spinners on buttons -- [ ] Add progress bar at top of page for navigation -- [ ] Ensure loading states are accessible (ARIA live regions) +- [x] Build reusable `LoadingSpinnerComponent` +- [x] Create skeleton loaders for lists and cards +- [x] Show inline loading spinners on buttons +- [x] Add progress bar at top of page for navigation +- [x] Ensure loading states are accessible (ARIA live regions) + +**Completed Implementation:** +- ✅ **LoadingService integrated with HTTP interceptors** + - Created `loading.interceptor.ts` that automatically shows/hides loading during HTTP requests + - Registered in app.config.ts as first interceptor in the chain + - Supports `X-Skip-Loading` header to skip loading for specific requests (e.g., polling) + - Uses LoadingService counter to handle concurrent requests properly +- ✅ **Navigation progress bar added** + - Mat-progress-bar displayed at top of page during route transitions + - Listens to Router events (NavigationStart/End/Cancel/Error) + - Fixed position with high z-index above all content + - Custom styling with primary color theme +- ✅ **Accessibility features implemented** + - LoadingSpinnerComponent has `role="status"` and `aria-live="polite"` + - Dynamic `aria-label` with loading message + - Navigation progress bar has `role="progressbar"` and `aria-label="Page loading"` + - Visual loading message marked with `aria-hidden="true"` to avoid duplication --- -### Offline Support + ## Routing & Navigation **Frontend Tasks:** -- [ ] Configure app routes with lazy loading: - - `/` - Landing page (guest welcome or dashboard) - - `/login` - Login page - - `/register` - Register page - - `/dashboard` - User dashboard (auth guard) - - `/categories` - Category list - - `/categories/:id` - Category detail - - `/quiz/setup` - Quiz setup - - `/quiz/:sessionId` - Active quiz - - `/quiz/:sessionId/results` - Quiz results - - `/quiz/:sessionId/review` - Quiz review - - `/bookmarks` - Bookmarked questions (auth guard) - - `/history` - Quiz history (auth guard) - - `/profile` - User profile (auth guard) - - `/admin` - Admin dashboard (admin guard) - - `/admin/users` - User management (admin guard) - - `/admin/questions` - Question management (admin guard) - - `/admin/categories` - Category management (admin guard) - - `/admin/settings` - Guest settings (admin guard) - - `/admin/analytics` - Analytics (admin guard) -- [ ] Create auth guard for protected routes -- [ ] Create admin guard for admin-only routes -- [ ] Create guest guard to prevent access to auth-only content -- [ ] Implement route preloading strategy -- [ ] Handle 404 redirect +- [x] Configure app routes with lazy loading: + - [x] `/` - Landing page (guest welcome or dashboard) + - [x] `/login` - Login page + - [x] `/register` - Register page + - [x] `/dashboard` - User dashboard (auth guard) + - [x] `/categories` - Category list + - [x] `/categories/:id` - Category detail + - [x] `/quiz/setup` - Quiz setup + - [x] `/quiz/:sessionId` - Active quiz + - [x] `/quiz/:sessionId/results` - Quiz results + - [x] `/quiz/:sessionId/review` - Quiz review + - [x] `/bookmarks` - Bookmarked questions (auth guard) + - [x] `/history` - Quiz history (auth guard) + - [x] `/profile` - User profile (auth guard) + - [x] `/admin` - Admin dashboard (admin guard) + - [x] `/admin/users` - User management (admin guard) + - [x] `/admin/questions` - Question management (admin guard) + - [x] `/admin/categories` - Category management (admin guard) + - [x] `/admin/settings` - Guest settings (admin guard) - **Exists as /admin/guest-settings** + - [x] `/admin/analytics` - Analytics (admin guard) +- [x] Create auth guard for protected routes +- [x] Create admin guard for admin-only routes +- [x] Create guest guard to prevent access to auth-only content +- [x] Implement route preloading strategy +- [x] Handle 404 redirect **UI Tasks:** -- [ ] Create navigation menu with links -- [ ] Highlight active route in navigation -- [ ] Implement breadcrumb component -- [ ] Add back button where appropriate +- [x] Create navigation menu with links +- [x] Highlight active route in navigation +- [x] Implement breadcrumb component +- [x] Add back button where appropriate - [ ] Ensure smooth transitions between routes --- diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index f0c9817..bd79a4f 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,23 +1,29 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; +import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { routes } from './app.routes'; -import { authInterceptor, guestInterceptor, errorInterceptor } from './core/interceptors'; +import { authInterceptor, guestInterceptor, errorInterceptor, loadingInterceptor } from './core/interceptors'; +import { GlobalErrorHandlerService } from './core/services/global-error-handler.service'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideRouter(routes), + provideRouter( + routes, + withPreloading(PreloadAllModules) + ), provideAnimationsAsync(), provideHttpClient( withInterceptors([ + loadingInterceptor, authInterceptor, guestInterceptor, errorInterceptor ]) - ) + ), + { provide: ErrorHandler, useClass: GlobalErrorHandlerService } ] }; diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 5d225ce..cad0354 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -5,6 +5,16 @@ } + +@if (isNavigating()) { + + +} + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index bd28665..3d1b5e0 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,7 +1,30 @@ import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; import { authGuard, guestGuard } from './core/guards'; +import { adminGuard } from './core/guards/admin.guard'; +import { AuthService } from './core/services/auth.service'; export const routes: Routes = [ + // Root route - redirect based on authentication status + { + path: '', + pathMatch: 'full', + canActivate: [() => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated()) { + router.navigate(['/dashboard']); + return false; + } else { + router.navigate(['/categories']); + return false; + } + }], + children: [] + }, + // Authentication routes (guest only - redirect to dashboard if already logged in) { path: 'login', @@ -51,6 +74,22 @@ export const routes: Routes = [ title: 'Quiz History - Quiz Platform' }, + // Profile Settings route (protected) + { + path: 'profile', + loadComponent: () => import('./features/profile/profile-settings.component').then(m => m.ProfileSettingsComponent), + canActivate: [authGuard], + title: 'Profile Settings - Quiz Platform' + }, + + // Bookmarks route (protected) + { + path: 'bookmarks', + loadComponent: () => import('./features/bookmarks/bookmarks.component').then(m => m.BookmarksComponent), + canActivate: [authGuard], + title: 'My Bookmarks - Quiz Platform' + }, + // Quiz routes { path: 'quiz/setup', @@ -73,23 +112,87 @@ export const routes: Routes = [ title: 'Review Quiz - Quiz Platform' }, - // Admin routes (TODO: Add adminGuard) + // Admin routes (protected with adminGuard) + { + path: 'admin', + loadComponent: () => import('./features/admin/admin-dashboard/admin-dashboard.component').then(m => m.AdminDashboardComponent), + canActivate: [adminGuard], + title: 'Admin Dashboard - Quiz Platform' + }, + { + path: 'admin/analytics', + loadComponent: () => import('./features/admin/guest-analytics/guest-analytics.component').then(m => m.GuestAnalyticsComponent), + canActivate: [adminGuard], + title: 'Guest Analytics - Admin' + }, + { + path: 'admin/guest-settings', + loadComponent: () => import('./features/admin/guest-settings/guest-settings.component').then(m => m.GuestSettingsComponent), + canActivate: [adminGuard], + title: 'Guest Settings - Admin' + }, + { + path: 'admin/guest-settings/edit', + loadComponent: () => import('./features/admin/guest-settings-edit/guest-settings-edit.component').then(m => m.GuestSettingsEditComponent), + canActivate: [adminGuard], + title: 'Edit Guest Settings - Admin' + }, + { + path: 'admin/users', + loadComponent: () => import('./features/admin/admin-users/admin-users.component').then(m => m.AdminUsersComponent), + canActivate: [adminGuard], + title: 'User Management - Admin' + }, + { + path: 'admin/users/:id', + loadComponent: () => import('./features/admin/admin-user-detail/admin-user-detail.component').then(m => m.AdminUserDetailComponent), + canActivate: [adminGuard], + title: 'User Details - Admin' + }, + { + path: 'admin/questions', + loadComponent: () => import('./features/admin/admin-questions/admin-questions.component').then(m => m.AdminQuestionsComponent), + canActivate: [adminGuard], + title: 'Manage Questions - Admin' + }, + { + path: 'admin/questions/new', + loadComponent: () => import('./features/admin/admin-question-form/admin-question-form.component').then(m => m.AdminQuestionFormComponent), + canActivate: [adminGuard], + title: 'Create Question - Admin' + }, + { + path: 'admin/questions/:id/edit', + loadComponent: () => import('./features/admin/admin-question-form/admin-question-form.component').then(m => m.AdminQuestionFormComponent), + canActivate: [adminGuard], + title: 'Edit Question - Admin' + }, { path: 'admin/categories', loadComponent: () => import('./features/admin/admin-category-list/admin-category-list').then(m => m.AdminCategoryListComponent), + canActivate: [adminGuard], title: 'Manage Categories - Admin' }, { path: 'admin/categories/new', loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent), + canActivate: [adminGuard], title: 'Create Category - Admin' }, { path: 'admin/categories/edit/:id', loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent), + canActivate: [adminGuard], title: 'Edit Category - Admin' }, + // Error page + { + path: 'error', + loadComponent: () => import('./shared/components/error/error.component').then(m => m.ErrorComponent), + title: 'Error - Quiz Platform' + }, + // TODO: Add more routes as components are created // - Home page (public) // - Quiz history (protected with authGuard) diff --git a/frontend/src/app/app.scss b/frontend/src/app/app.scss index 172e313..91f9958 100644 --- a/frontend/src/app/app.scss +++ b/frontend/src/app/app.scss @@ -1,3 +1,21 @@ +// Navigation Progress Bar +.navigation-progress-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: calc(var(--z-modal) + 1); + height: 3px; + + ::ng-deep .mat-mdc-progress-bar-fill::after { + background-color: var(--color-primary); + } + + ::ng-deep .mat-mdc-progress-bar-buffer { + background-color: rgba(var(--color-primary-rgb), 0.3); + } +} + // App Shell Layout .app-shell { display: flex; diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index c9821b0..f47c83d 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,6 +1,8 @@ import { Component, signal, inject, OnInit, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router, RouterOutlet } from '@angular/router'; +import { Router, RouterOutlet, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { filter } from 'rxjs/operators'; import { ToastContainerComponent } from './shared/components/toast-container/toast-container'; import { HeaderComponent } from './shared/components/header/header'; import { SidebarComponent } from './shared/components/sidebar/sidebar'; @@ -16,6 +18,7 @@ import { ToastService } from './core/services/toast.service'; imports: [ CommonModule, RouterOutlet, + MatProgressBarModule, ToastContainerComponent, HeaderComponent, SidebarComponent, @@ -39,6 +42,9 @@ export class App implements OnInit { // Signal for app initialization state isInitializing = signal(true); + // Signal for navigation loading state + isNavigating = signal(false); + // Computed signal to check if user is guest isGuest = computed(() => { return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated; @@ -46,6 +52,24 @@ export class App implements OnInit { ngOnInit(): void { this.initializeApp(); + this.setupNavigationListener(); + } + + /** + * Setup navigation event listener for progress bar + */ + private setupNavigationListener(): void { + this.router.events.subscribe(event => { + if (event instanceof NavigationStart) { + this.isNavigating.set(true); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + this.isNavigating.set(false); + } + }); } /** diff --git a/frontend/src/app/core/guards/admin.guard.ts b/frontend/src/app/core/guards/admin.guard.ts new file mode 100644 index 0000000..131cf9c --- /dev/null +++ b/frontend/src/app/core/guards/admin.guard.ts @@ -0,0 +1,47 @@ +import { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; +import { AuthService } from '../services/auth.service'; +import { ToastService } from '../services/toast.service'; + +/** + * Admin Guard + * + * Protects admin-only routes by verifying: + * 1. User is authenticated + * 2. User has 'admin' role + * + * Redirects to dashboard if not admin + * Redirects to login if not authenticated + * + * @example + * { + * path: 'admin', + * canActivate: [adminGuard], + * loadComponent: () => import('./features/admin/admin-dashboard/admin-dashboard.component') + * } + */ +export const adminGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + const toastService = inject(ToastService); + + const user = authService.getCurrentUser(); + + // Check if user is authenticated + if (!authService.isAuthenticated()) { + toastService.error('Please login to access admin area'); + router.navigate(['/login'], { + queryParams: { returnUrl: state.url } + }); + return false; + } + + // Check if user has admin role + if (user?.role !== 'admin') { + toastService.error('Access denied. Admin privileges required.'); + router.navigate(['/dashboard']); + return false; + } + + return true; +}; diff --git a/frontend/src/app/core/interceptors/index.ts b/frontend/src/app/core/interceptors/index.ts index d8ad915..62e0ea0 100644 --- a/frontend/src/app/core/interceptors/index.ts +++ b/frontend/src/app/core/interceptors/index.ts @@ -1,3 +1,4 @@ export * from './auth.interceptor'; export * from './guest.interceptor'; export * from './error.interceptor'; +export * from './loading.interceptor'; diff --git a/frontend/src/app/core/interceptors/loading.interceptor.ts b/frontend/src/app/core/interceptors/loading.interceptor.ts new file mode 100644 index 0000000..c1d955d --- /dev/null +++ b/frontend/src/app/core/interceptors/loading.interceptor.ts @@ -0,0 +1,27 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { finalize } from 'rxjs/operators'; +import { LoadingService } from '../services/loading.service'; + +/** + * Loading Interceptor + * Automatically shows/hides loading indicator during HTTP requests + */ +export const loadingInterceptor: HttpInterceptorFn = (req, next) => { + const loadingService = inject(LoadingService); + + // Skip loading for specific URLs if needed (e.g., polling endpoints) + const skipLoading = req.headers.has('X-Skip-Loading'); + + if (!skipLoading) { + loadingService.start('Loading...'); + } + + return next(req).pipe( + finalize(() => { + if (!skipLoading) { + loadingService.stop(); + } + }) + ); +}; diff --git a/frontend/src/app/core/models/admin.model.ts b/frontend/src/app/core/models/admin.model.ts new file mode 100644 index 0000000..2a3d9b8 --- /dev/null +++ b/frontend/src/app/core/models/admin.model.ts @@ -0,0 +1,280 @@ +/** + * Admin Statistics Models + * Type definitions for admin statistics and analytics + */ + +/** + * User growth data point for chart + */ +export interface UserGrowthData { + date: string; + count: number; +} + +/** + * Category popularity data for chart + */ +export interface CategoryPopularity { + categoryId: string; + categoryName: string; + quizCount: number; + percentage: number; +} + +/** + * System-wide statistics response + */ +export interface AdminStatistics { + totalUsers: number; + activeUsers: number; // Last 7 days + totalQuizSessions: number; + totalQuestions: number; + averageQuizScore: number; + userGrowth: UserGrowthData[]; + popularCategories: CategoryPopularity[]; + stats: { + newUsersToday: number; + newUsersThisWeek: number; + newUsersThisMonth: number; + quizzesToday: number; + quizzesThisWeek: number; + quizzesThisMonth: number; + }; +} + +/** + * API response wrapper for statistics + */ +export interface AdminStatisticsResponse { + success: boolean; + data: AdminStatistics; + message?: string; +} + +/** + * Date range filter for statistics + */ +export interface DateRangeFilter { + startDate: Date | null; + endDate: Date | null; +} + +/** + * Cache entry for admin data + */ +export interface AdminCacheEntry { + data: T; + timestamp: number; + expiresAt: number; +} + +/** + * Guest session timeline data point + */ +export interface GuestSessionTimelineData { + date: string; + activeSessions: number; + newSessions: number; + convertedSessions: number; +} + +/** + * Conversion funnel stage + */ +export interface ConversionFunnelStage { + stage: string; + count: number; + percentage: number; + dropoff?: number; +} + +/** + * Guest analytics data + */ +export interface GuestAnalytics { + totalGuestSessions: number; + activeGuestSessions: number; + conversionRate: number; // Percentage + averageQuizzesPerGuest: number; + totalConversions: number; + timeline: GuestSessionTimelineData[]; + conversionFunnel: ConversionFunnelStage[]; + stats: { + sessionsToday: number; + sessionsThisWeek: number; + sessionsThisMonth: number; + conversionsToday: number; + conversionsThisWeek: number; + conversionsThisMonth: number; + }; +} + +/** + * API response wrapper for guest analytics + */ +export interface GuestAnalyticsResponse { + success: boolean; + data: GuestAnalytics; + message?: string; +} + +/** + * Guest access settings + */ +export interface GuestSettings { + guestAccessEnabled: boolean; + maxQuizzesPerDay: number; + maxQuestionsPerQuiz: number; + sessionExpiryHours: number; + upgradePromptMessage: string; + allowedCategories?: string[]; + features?: { + canBookmark: boolean; + canViewHistory: boolean; + canExportResults: boolean; + }; +} + +/** + * API response wrapper for guest settings + */ +export interface GuestSettingsResponse { + success: boolean; + data: GuestSettings; + message?: string; +} + +/** + * Admin user data + */ +export interface AdminUser { + id: string; + username: string; + email: string; + role: 'user' | 'admin'; + isActive: boolean; + createdAt: string; + lastLoginAt?: string; + profilePicture?: string | null; + quizzesTaken?: number; + averageScore?: number; +} + +/** + * User list query parameters + */ +export interface UserListParams { + page?: number; + limit?: number; + role?: 'all' | 'user' | 'admin'; + isActive?: 'all' | 'active' | 'inactive'; + sortBy?: 'username' | 'email' | 'createdAt' | 'lastLoginAt'; + sortOrder?: 'asc' | 'desc'; + search?: string; +} + +/** + * Paginated user list response + */ +export interface AdminUserListResponse { + success: boolean; + data: { + users: AdminUser[]; + pagination: { + currentPage: number; + totalPages: number; + totalUsers: number; + limit: number; + hasNext: boolean; + hasPrev: boolean; + }; + }; + message?: string; +} + +/** + * User activity entry + */ +export interface UserActivity { + id: string; + type: 'login' | 'quiz_start' | 'quiz_complete' | 'bookmark' | 'profile_update' | 'role_change'; + description: string; + timestamp: string; + metadata?: { + categoryName?: string; + score?: number; + questionCount?: number; + oldRole?: string; + newRole?: string; + }; +} + +/** + * Quiz history entry for user detail + */ +export interface UserQuizHistoryEntry { + id: string; + categoryId: string; + categoryName: string; + score: number; + totalQuestions: number; + percentage: number; + timeTaken: number; // seconds + completedAt: string; +} + +/** + * User statistics for detail view + */ +export interface UserStatistics { + totalQuizzes: number; + averageScore: number; + totalQuestionsAnswered: number; + correctAnswers: number; + accuracy: number; + currentStreak: number; + longestStreak: number; + totalTimeSpent: number; // seconds + favoriteCategory?: { + id: string; + name: string; + quizCount: number; + }; + recentActivity: { + lastQuizDate?: string; + lastLoginDate?: string; + quizzesThisWeek: number; + quizzesThisMonth: number; + }; +} + +/** + * Detailed user profile + */ +export interface AdminUserDetail { + id: string; + username: string; + email: string; + role: 'user' | 'admin'; + isActive: boolean; + createdAt: string; + lastLoginAt?: string; + statistics: UserStatistics; + quizHistory: UserQuizHistoryEntry[]; + activityTimeline: UserActivity[]; + metadata?: { + ipAddress?: string; + userAgent?: string; + registrationMethod?: 'direct' | 'guest_conversion'; + guestSessionId?: string; + }; +} + +/** + * API response wrapper for user detail + */ +export interface AdminUserDetailResponse { + success: boolean; + data: AdminUserDetail; + message?: string; +} diff --git a/frontend/src/app/core/models/bookmark.model.ts b/frontend/src/app/core/models/bookmark.model.ts new file mode 100644 index 0000000..7866020 --- /dev/null +++ b/frontend/src/app/core/models/bookmark.model.ts @@ -0,0 +1,57 @@ +/** + * Bookmark Interface + * Represents a bookmarked question + */ +export interface Bookmark { + id: string; + userId: string; + questionId: string; + question: BookmarkedQuestion; + createdAt: string; +} + +/** + * Bookmarked Question Details + */ +export interface BookmarkedQuestion { + id: string; + questionText: string; + questionType: 'multiple-choice' | 'true-false' | 'written'; + difficulty: 'easy' | 'medium' | 'hard'; + categoryId: string; + categoryName: string; + options?: string[]; + correctAnswer: string; + explanation?: string; + points: number; + tags?: string[]; +} + +/** + * Bookmarks Response + */ +export interface BookmarksResponse { + success: boolean; + data: { + bookmarks: Bookmark[]; + total: number; + }; +} + +/** + * Add Bookmark Request + */ +export interface AddBookmarkRequest { + questionId: string; +} + +/** + * Add Bookmark Response + */ +export interface AddBookmarkResponse { + success: boolean; + data: { + bookmark: Bookmark; + }; + message: string; +} diff --git a/frontend/src/app/core/models/dashboard.model.ts b/frontend/src/app/core/models/dashboard.model.ts index e3637f3..2e2f55c 100644 --- a/frontend/src/app/core/models/dashboard.model.ts +++ b/frontend/src/app/core/models/dashboard.model.ts @@ -70,6 +70,17 @@ export interface UserProfileUpdate { newPassword?: string; } +/** + * User Profile Update Response + */ +export interface UserProfileUpdateResponse { + success: boolean; + data: { + user: User; + }; + message: string; +} + /** * Bookmark */ diff --git a/frontend/src/app/core/services/admin.service.ts b/frontend/src/app/core/services/admin.service.ts new file mode 100644 index 0000000..ada46af --- /dev/null +++ b/frontend/src/app/core/services/admin.service.ts @@ -0,0 +1,815 @@ +import { Injectable, signal, computed, inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap, map } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { environment } from '../../../environments/environment'; +import { + AdminStatistics, + AdminStatisticsResponse, + AdminCacheEntry, + DateRangeFilter, + GuestAnalytics, + GuestAnalyticsResponse, + GuestSettings, + GuestSettingsResponse, + AdminUser, + AdminUserListResponse, + UserListParams, + AdminUserDetail, + AdminUserDetailResponse +} from '../models/admin.model'; +import { Question, QuestionFormData } from '../models/question.model'; +import { ToastService } from './toast.service'; + +/** + * AdminService + * + * Handles all admin-related API operations including: + * - System-wide statistics + * - User analytics + * - Guest analytics + * - User management + * - Question management + * - Settings management + * + * Features: + * - Signal-based state management + * - 5-minute caching for statistics + * - Automatic authorization error handling + * - Admin role verification + */ +@Injectable({ + providedIn: 'root' +}) +export class AdminService { + private readonly http = inject(HttpClient); + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + private readonly apiUrl = `${environment.apiUrl}/admin`; + + // Cache storage for admin data + private readonly cache = new Map>(); + private readonly STATS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private readonly ANALYTICS_CACHE_TTL = 10 * 60 * 1000; // 10 minutes + + // State signals - Statistics + readonly adminStatsState = signal(null); + readonly isLoadingStats = signal(false); + readonly statsError = signal(null); + + // State signals - Guest Analytics + readonly guestAnalyticsState = signal(null); + readonly isLoadingAnalytics = signal(false); + readonly analyticsError = signal(null); + + // State signals - Guest Settings + readonly guestSettingsState = signal(null); + readonly isLoadingSettings = signal(false); + readonly settingsError = signal(null); + + // State signals - User Management + readonly adminUsersState = signal([]); + readonly isLoadingUsers = signal(false); + readonly usersError = signal(null); + readonly usersPagination = signal<{ + currentPage: number; + totalPages: number; + totalUsers: number; + limit: number; + hasNext: boolean; + hasPrev: boolean; + } | null>(null); + readonly currentUserFilters = signal({}); + + // State signals - User Detail + readonly selectedUserDetail = signal(null); + readonly isLoadingUserDetail = signal(false); + readonly userDetailError = signal(null); + + // Date range filter + readonly dateRangeFilter = signal({ + startDate: null, + endDate: null + }); + + // Computed signals - Statistics + readonly hasStats = computed(() => this.adminStatsState() !== null); + readonly totalUsers = computed(() => this.adminStatsState()?.totalUsers ?? 0); + readonly activeUsers = computed(() => this.adminStatsState()?.activeUsers ?? 0); + readonly totalQuizSessions = computed(() => this.adminStatsState()?.totalQuizSessions ?? 0); + readonly totalQuestions = computed(() => this.adminStatsState()?.totalQuestions ?? 0); + readonly averageScore = computed(() => this.adminStatsState()?.averageQuizScore ?? 0); + + // Computed signals - Guest Analytics + readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null); + readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.totalGuestSessions ?? 0); + readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.activeGuestSessions ?? 0); + readonly conversionRate = computed(() => this.guestAnalyticsState()?.conversionRate ?? 0); + readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.averageQuizzesPerGuest ?? 0); + + // Computed signals - Guest Settings + readonly hasSettings = computed(() => this.guestSettingsState() !== null); + readonly isGuestAccessEnabled = computed(() => this.guestSettingsState()?.guestAccessEnabled ?? false); + readonly maxQuizzesPerDay = computed(() => this.guestSettingsState()?.maxQuizzesPerDay ?? 0); + readonly maxQuestionsPerQuiz = computed(() => this.guestSettingsState()?.maxQuestionsPerQuiz ?? 0); + + // Computed signals - User Management + readonly hasUsers = computed(() => this.adminUsersState().length > 0); + readonly totalUsersCount = computed(() => this.usersPagination()?.totalUsers ?? 0); + readonly currentPage = computed(() => this.usersPagination()?.currentPage ?? 1); + readonly totalPages = computed(() => this.usersPagination()?.totalPages ?? 1); + + // Computed signals - User Detail + readonly hasUserDetail = computed(() => this.selectedUserDetail() !== null); + readonly userFullName = computed(() => { + const user = this.selectedUserDetail(); + return user ? user.username : ''; + }); + readonly userTotalQuizzes = computed(() => this.selectedUserDetail()?.statistics.totalQuizzes ?? 0); + readonly userAverageScore = computed(() => this.selectedUserDetail()?.statistics.averageScore ?? 0); + readonly userAccuracy = computed(() => this.selectedUserDetail()?.statistics.accuracy ?? 0); + + /** + * Get system-wide statistics + * Implements 5-minute caching + */ + getStatistics(forceRefresh: boolean = false): Observable { + const cacheKey = 'admin-statistics'; + + // Check cache first + if (!forceRefresh) { + const cached = this.getFromCache(cacheKey); + if (cached) { + this.adminStatsState.set(cached); + return new Observable(observer => { + observer.next(cached); + observer.complete(); + }); + } + } + + this.isLoadingStats.set(true); + this.statsError.set(null); + + return this.http.get(`${this.apiUrl}/statistics`).pipe( + map(response => response.data), + tap(data => { + this.adminStatsState.set(data); + this.setCache(cacheKey, data); + this.isLoadingStats.set(false); + }), + catchError(error => { + this.isLoadingStats.set(false); + return this.handleError(error, 'Failed to load statistics'); + }) + ); + } + + /** + * Get statistics with date range filter + */ + getStatisticsWithDateRange(startDate: Date, endDate: Date): Observable { + this.isLoadingStats.set(true); + this.statsError.set(null); + + const params = { + startDate: startDate.toISOString(), + endDate: endDate.toISOString() + }; + + return this.http.get(`${this.apiUrl}/statistics`, { params }).pipe( + map(response => response.data), + tap(data => { + this.adminStatsState.set(data); + this.isLoadingStats.set(false); + this.dateRangeFilter.set({ startDate, endDate }); + }), + catchError(error => { + this.isLoadingStats.set(false); + return this.handleError(error, 'Failed to load filtered statistics'); + }) + ); + } + + /** + * Clear date range filter and reload all-time statistics + */ + clearDateFilter(): void { + this.dateRangeFilter.set({ startDate: null, endDate: null }); + this.getStatistics(true).subscribe(); + } + + /** + * Refresh statistics (force cache invalidation) + */ + refreshStatistics(): Observable { + this.invalidateCache('admin-statistics'); + return this.getStatistics(true); + } + + /** + * Get guest user analytics + * Implements 10-minute caching + */ + getGuestAnalytics(forceRefresh: boolean = false): Observable { + const cacheKey = 'guest-analytics'; + + // Check cache first + if (!forceRefresh) { + const cached = this.getFromCache(cacheKey); + if (cached) { + this.guestAnalyticsState.set(cached); + return new Observable(observer => { + observer.next(cached); + observer.complete(); + }); + } + } + + this.isLoadingAnalytics.set(true); + this.analyticsError.set(null); + + return this.http.get(`${this.apiUrl}/guest-analytics`).pipe( + map(response => response.data), + tap(data => { + this.guestAnalyticsState.set(data); + this.setCache(cacheKey, data, this.ANALYTICS_CACHE_TTL); + this.isLoadingAnalytics.set(false); + }), + catchError(error => { + this.isLoadingAnalytics.set(false); + return this.handleError(error, 'Failed to load guest analytics'); + }) + ); + } + + /** + * Refresh guest analytics (force cache invalidation) + */ + refreshGuestAnalytics(): Observable { + this.invalidateCache('guest-analytics'); + return this.getGuestAnalytics(true); + } + + /** + * Get data from cache if not expired + */ + private getFromCache(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + + const now = Date.now(); + if (now > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.data as T; + } + + /** + * Store data in cache with TTL + */ + private setCache(key: string, data: T, ttl: number = this.STATS_CACHE_TTL): void { + const now = Date.now(); + const entry: AdminCacheEntry = { + data, + timestamp: now, + expiresAt: now + ttl + }; + this.cache.set(key, entry); + } + + /** + * Invalidate specific cache entry + */ + private invalidateCache(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all cache entries + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Handle HTTP errors with proper messaging + */ + private handleError(error: HttpErrorResponse, defaultMessage: string): Observable { + let errorMessage = defaultMessage; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + this.router.navigate(['/dashboard']); + } else if (error.status === 404) { + errorMessage = 'Resource not found.'; + this.toastService.error(errorMessage); + } else if (error.status === 500) { + errorMessage = 'Server error. Please try again later.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + this.statsError.set(errorMessage); + return throwError(() => new Error(errorMessage)); + } + + /** + * Get guest access settings + * Implements 10-minute caching + */ + getGuestSettings(forceRefresh: boolean = false): Observable { + const cacheKey = 'guest-settings'; + + // Check cache first + if (!forceRefresh) { + const cached = this.getFromCache(cacheKey); + if (cached) { + this.guestSettingsState.set(cached); + return new Observable(observer => { + observer.next(cached); + observer.complete(); + }); + } + } + + this.isLoadingSettings.set(true); + this.settingsError.set(null); + + return this.http.get(`${this.apiUrl}/guest-settings`).pipe( + map(response => response.data), + tap(data => { + this.guestSettingsState.set(data); + this.setCache(cacheKey, data, this.ANALYTICS_CACHE_TTL); + this.isLoadingSettings.set(false); + }), + catchError(error => { + this.isLoadingSettings.set(false); + return this.handleSettingsError(error, 'Failed to load guest settings'); + }) + ); + } + + /** + * Refresh guest settings (force reload) + */ + refreshGuestSettings(): Observable { + this.invalidateCache('guest-settings'); + return this.getGuestSettings(true); + } + + /** + * Update guest access settings + * Invalidates cache and updates state + */ + updateGuestSettings(data: Partial): Observable { + this.isLoadingSettings.set(true); + this.settingsError.set(null); + + return this.http.put(`${this.apiUrl}/guest-settings`, data).pipe( + map(response => response.data), + tap(updatedSettings => { + this.guestSettingsState.set(updatedSettings); + this.invalidateCache('guest-settings'); + this.isLoadingSettings.set(false); + this.toastService.success('Guest settings updated successfully'); + }), + catchError(error => { + this.isLoadingSettings.set(false); + return this.handleSettingsError(error, 'Failed to update guest settings'); + }) + ); + } + + /** + * Handle HTTP errors for guest settings + */ + private handleSettingsError(error: HttpErrorResponse, defaultMessage: string): Observable { + let errorMessage = defaultMessage; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + this.router.navigate(['/dashboard']); + } else if (error.status === 404) { + errorMessage = 'Settings not found.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + this.settingsError.set(errorMessage); + return throwError(() => new Error(errorMessage)); + } + + /** + * Get users with pagination, filtering, and sorting + */ + getUsers(params: UserListParams = {}): Observable { + this.isLoadingUsers.set(true); + this.usersError.set(null); + + // Build query parameters + const queryParams: any = { + page: params.page ?? 1, + limit: params.limit ?? 10 + }; + + if (params.role && params.role !== 'all') { + queryParams.role = params.role; + } + + if (params.isActive && params.isActive !== 'all') { + queryParams.isActive = params.isActive === 'active'; + } + + if (params.sortBy) { + queryParams.sortBy = params.sortBy; + queryParams.sortOrder = params.sortOrder ?? 'asc'; + } + + if (params.search) { + queryParams.search = params.search; + } + + return this.http.get(`${this.apiUrl}/users`, { params: queryParams }).pipe( + tap(response => { + this.adminUsersState.set(response.data.users); + this.usersPagination.set(response.data.pagination); + this.currentUserFilters.set(params); + this.isLoadingUsers.set(false); + }), + catchError(error => { + this.isLoadingUsers.set(false); + return this.handleUsersError(error, 'Failed to load users'); + }) + ); + } + + /** + * Refresh users list with current filters + */ + refreshUsers(): Observable { + const currentFilters = this.currentUserFilters(); + return this.getUsers(currentFilters); + } + + /** + * Get detailed user profile by ID + * Fetches comprehensive user data including statistics, quiz history, and activity timeline + */ + getUserDetails(userId: string): Observable { + this.isLoadingUserDetail.set(true); + this.userDetailError.set(null); + + return this.http.get(`${this.apiUrl}/users/${userId}`).pipe( + map(response => response.data), + tap(data => { + this.selectedUserDetail.set(data); + this.isLoadingUserDetail.set(false); + }), + catchError(error => { + this.isLoadingUserDetail.set(false); + return this.handleUserDetailError(error, 'Failed to load user details'); + }) + ); + } + + /** + * Clear selected user detail + */ + clearUserDetail(): void { + this.selectedUserDetail.set(null); + this.userDetailError.set(null); + } + + /** + * Update user role (User <-> Admin) + * Updates the role in both the users list and detail view if loaded + */ + updateUserRole(userId: string, role: 'user' | 'admin'): Observable<{ success: boolean; message: string; data: AdminUser }> { + return this.http.put<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}/role`, { role }).pipe( + tap(response => { + // Update user in the users list if present + const currentUsers = this.adminUsersState(); + const updatedUsers = currentUsers.map(user => + user.id === userId ? { ...user, role } : user + ); + this.adminUsersState.set(updatedUsers); + + // Update user detail if currently viewing this user + const currentDetail = this.selectedUserDetail(); + if (currentDetail && currentDetail.id === userId) { + this.selectedUserDetail.set({ ...currentDetail, role }); + } + + this.toastService.success(response.message || 'User role updated successfully'); + }), + catchError(error => { + let errorMessage = 'Failed to update user role'; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + } else if (error.status === 404) { + errorMessage = 'User not found.'; + this.toastService.error(errorMessage); + } else if (error.status === 400 && error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + /** + * Activate user account + * Updates the user status in both the users list and detail view if loaded + */ + activateUser(userId: string): Observable<{ success: boolean; message: string; data: AdminUser }> { + return this.http.put<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}/activate`, {}).pipe( + tap(response => { + // Update user in the users list if present + const currentUsers = this.adminUsersState(); + const updatedUsers = currentUsers.map(user => + user.id === userId ? { ...user, isActive: true } : user + ); + this.adminUsersState.set(updatedUsers); + + // Update user detail if currently viewing this user + const currentDetail = this.selectedUserDetail(); + if (currentDetail && currentDetail.id === userId) { + this.selectedUserDetail.set({ ...currentDetail, isActive: true }); + } + + this.toastService.success(response.message || 'User activated successfully'); + }), + catchError(error => { + let errorMessage = 'Failed to activate user'; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + } else if (error.status === 404) { + errorMessage = 'User not found.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + /** + * Deactivate user account (soft delete) + * Updates the user status in both the users list and detail view if loaded + */ + deactivateUser(userId: string): Observable<{ success: boolean; message: string; data: AdminUser }> { + return this.http.delete<{ success: boolean; message: string; data: AdminUser }>(`${this.apiUrl}/users/${userId}`).pipe( + tap(response => { + // Update user in the users list if present + const currentUsers = this.adminUsersState(); + const updatedUsers = currentUsers.map(user => + user.id === userId ? { ...user, isActive: false } : user + ); + this.adminUsersState.set(updatedUsers); + + // Update user detail if currently viewing this user + const currentDetail = this.selectedUserDetail(); + if (currentDetail && currentDetail.id === userId) { + this.selectedUserDetail.set({ ...currentDetail, isActive: false }); + } + + this.toastService.success(response.message || 'User deactivated successfully'); + }), + catchError(error => { + let errorMessage = 'Failed to deactivate user'; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + } else if (error.status === 404) { + errorMessage = 'User not found.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + /** + * Handle HTTP errors for user detail + */ + private handleUserDetailError(error: HttpErrorResponse, defaultMessage: string): Observable { + let errorMessage = defaultMessage; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + this.router.navigate(['/dashboard']); + } else if (error.status === 404) { + errorMessage = 'User not found.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + this.userDetailError.set(errorMessage); + return throwError(() => new Error(errorMessage)); + } + + /** + * Handle HTTP errors for user management + */ + private handleUsersError(error: HttpErrorResponse, defaultMessage: string): Observable { + let errorMessage = defaultMessage; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + this.router.navigate(['/dashboard']); + } else if (error.status === 404) { + errorMessage = 'Users not found.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + this.usersError.set(errorMessage); + return throwError(() => new Error(errorMessage)); + } + + // =========================== + // Question Management Methods + // =========================== + + /** + * Get question by ID + */ + getQuestion(id: string): Observable<{ success: boolean; data: Question; message?: string }> { + return this.http.get<{ success: boolean; data: Question; message?: string }>( + `${this.apiUrl}/questions/${id}` + ).pipe( + catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to load question')) + ); + } + + /** + * Create new question + */ + createQuestion(data: QuestionFormData): Observable<{ success: boolean; data: Question; message?: string }> { + return this.http.post<{ success: boolean; data: Question; message?: string }>( + `${this.apiUrl}/questions`, + data + ).pipe( + tap((response) => { + this.toastService.success('Question created successfully'); + }), + catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to create question')) + ); + } + + /** + * Update existing question + */ + updateQuestion(id: string, data: QuestionFormData): Observable<{ success: boolean; data: Question; message?: string }> { + return this.http.put<{ success: boolean; data: Question; message?: string }>( + `${this.apiUrl}/questions/${id}`, + data + ).pipe( + tap((response) => { + this.toastService.success('Question updated successfully'); + }), + catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to update question')) + ); + } + + /** + * Delete question (soft delete) + */ + deleteQuestion(id: string): Observable<{ success: boolean; message?: string }> { + return this.http.delete<{ success: boolean; message?: string }>( + `${this.apiUrl}/questions/${id}` + ).pipe( + tap((response) => { + this.toastService.success('Question deleted successfully'); + }), + catchError((error: HttpErrorResponse) => this.handleQuestionError(error, 'Failed to delete question')) + ); + } + + /** + * Handle question-related errors + */ + private handleQuestionError(error: HttpErrorResponse, defaultMessage: string): Observable { + let errorMessage = defaultMessage; + + if (error.status === 401) { + errorMessage = 'Unauthorized. Please login again.'; + this.toastService.error(errorMessage); + this.router.navigate(['/login']); + } else if (error.status === 403) { + errorMessage = 'Access denied. Admin privileges required.'; + this.toastService.error(errorMessage); + this.router.navigate(['/dashboard']); + } else if (error.status === 400) { + errorMessage = error.error?.message || 'Invalid question data. Please check all fields.'; + this.toastService.error(errorMessage); + } else if (error.error?.message) { + errorMessage = error.error.message; + this.toastService.error(errorMessage); + } else { + this.toastService.error(errorMessage); + } + + return throwError(() => new Error(errorMessage)); + } + + /** + * Reset all admin state + */ + resetState(): void { + this.adminStatsState.set(null); + this.isLoadingStats.set(false); + this.statsError.set(null); + this.guestAnalyticsState.set(null); + this.isLoadingAnalytics.set(false); + this.analyticsError.set(null); + this.guestSettingsState.set(null); + this.isLoadingSettings.set(false); + this.settingsError.set(null); + this.adminUsersState.set([]); + this.isLoadingUsers.set(false); + this.usersError.set(null); + this.usersPagination.set(null); + this.currentUserFilters.set({}); + this.selectedUserDetail.set(null); + this.isLoadingUserDetail.set(false); + this.userDetailError.set(null); + this.dateRangeFilter.set({ startDate: null, endDate: null }); + this.clearCache(); + } +} diff --git a/frontend/src/app/core/services/bookmark.service.ts b/frontend/src/app/core/services/bookmark.service.ts new file mode 100644 index 0000000..9df14c8 --- /dev/null +++ b/frontend/src/app/core/services/bookmark.service.ts @@ -0,0 +1,270 @@ +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 { Observable, throwError } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { + Bookmark, + BookmarksResponse, + AddBookmarkRequest, + AddBookmarkResponse +} from '../models/bookmark.model'; +import { ToastService } from './toast.service'; +import { AuthService } from './auth.service'; + +interface CacheEntry { + data: T; + timestamp: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class BookmarkService { + private http = inject(HttpClient); + private router = inject(Router); + private toastService = inject(ToastService); + private authService = inject(AuthService); + + private readonly API_URL = `${environment.apiUrl}/users`; + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds + + // Signals + bookmarksState = signal([]); + isLoading = signal(false); + error = signal(null); + + // Cache + private bookmarksCache = new Map>(); + + // Computed values + totalBookmarks = computed(() => this.bookmarksState().length); + hasBookmarks = computed(() => this.bookmarksState().length > 0); + bookmarksByCategory = computed(() => { + const bookmarks = this.bookmarksState(); + const grouped = new Map(); + + bookmarks.forEach(bookmark => { + const category = bookmark.question.categoryName; + if (!grouped.has(category)) { + grouped.set(category, []); + } + grouped.get(category)!.push(bookmark); + }); + + return grouped; + }); + + /** + * Get user's bookmarked questions + */ + getBookmarks(userId: string, forceRefresh = false): Observable { + // Check cache if not forcing refresh + if (!forceRefresh) { + const cached = this.bookmarksCache.get(userId); + if (cached && (Date.now() - cached.timestamp < this.CACHE_TTL)) { + this.bookmarksState.set(cached.data); + return new Observable(observer => { + observer.next(cached.data); + observer.complete(); + }); + } + } + + this.isLoading.set(true); + this.error.set(null); + + return this.http.get(`${this.API_URL}/${userId}/bookmarks`).pipe( + tap(response => { + const bookmarks = response.data.bookmarks; + this.bookmarksState.set(bookmarks); + + // Cache the response + this.bookmarksCache.set(userId, { + data: bookmarks, + timestamp: Date.now() + }); + + this.isLoading.set(false); + }), + catchError(error => { + console.error('Error fetching bookmarks:', error); + this.error.set(error.error?.message || 'Failed to load bookmarks'); + this.isLoading.set(false); + + if (error.status === 401) { + this.toastService.error('Please log in to view your bookmarks'); + this.router.navigate(['/login']); + } else { + this.toastService.error('Failed to load bookmarks'); + } + + return throwError(() => error); + }), + map(response => response.data.bookmarks) + ); + } + + /** + * Add question to bookmarks + */ + addBookmark(userId: string, questionId: string): Observable { + const request: AddBookmarkRequest = { questionId }; + + return this.http.post( + `${this.API_URL}/${userId}/bookmarks`, + request + ).pipe( + tap(response => { + // Optimistically update state + const currentBookmarks = this.bookmarksState(); + this.bookmarksState.set([...currentBookmarks, response.data.bookmark]); + + // Invalidate cache + this.bookmarksCache.delete(userId); + + this.toastService.success('Question bookmarked successfully'); + }), + catchError(error => { + console.error('Error adding bookmark:', error); + + if (error.status === 401) { + this.toastService.error('Please log in to bookmark questions'); + this.router.navigate(['/login']); + } else if (error.status === 409) { + this.toastService.info('Question is already bookmarked'); + } else { + this.toastService.error('Failed to bookmark question'); + } + + return throwError(() => error); + }), + map(response => response.data.bookmark) + ); + } + + /** + * Remove bookmark + */ + removeBookmark(userId: string, questionId: string): Observable { + return this.http.delete( + `${this.API_URL}/${userId}/bookmarks/${questionId}` + ).pipe( + tap(() => { + // Optimistically update state + const currentBookmarks = this.bookmarksState(); + const updatedBookmarks = currentBookmarks.filter( + b => b.questionId !== questionId + ); + this.bookmarksState.set(updatedBookmarks); + + // Invalidate cache + this.bookmarksCache.delete(userId); + + this.toastService.success('Bookmark removed'); + }), + catchError(error => { + console.error('Error removing bookmark:', error); + + if (error.status === 401) { + this.toastService.error('Please log in to manage bookmarks'); + this.router.navigate(['/login']); + } else if (error.status === 404) { + this.toastService.warning('Bookmark not found'); + // Still update state to remove it + const currentBookmarks = this.bookmarksState(); + const updatedBookmarks = currentBookmarks.filter( + b => b.questionId !== questionId + ); + this.bookmarksState.set(updatedBookmarks); + } else { + this.toastService.error('Failed to remove bookmark'); + } + + return throwError(() => error); + }) + ); + } + + /** + * Check if question is bookmarked + */ + isBookmarked(questionId: string): boolean { + return this.bookmarksState().some(b => b.questionId === questionId); + } + + /** + * Get bookmark for specific question + */ + getBookmarkByQuestionId(questionId: string): Bookmark | undefined { + return this.bookmarksState().find(b => b.questionId === questionId); + } + + /** + * Clear cache (useful after logout or data updates) + */ + clearCache(): void { + this.bookmarksCache.clear(); + this.bookmarksState.set([]); + this.error.set(null); + } + + /** + * Filter bookmarks by search query + */ + searchBookmarks(query: string): Bookmark[] { + if (!query.trim()) { + return this.bookmarksState(); + } + + const lowerQuery = query.toLowerCase(); + return this.bookmarksState().filter(bookmark => + bookmark.question.questionText.toLowerCase().includes(lowerQuery) || + bookmark.question.categoryName.toLowerCase().includes(lowerQuery) || + bookmark.question.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)) + ); + } + + /** + * Filter bookmarks by category + */ + filterByCategory(categoryId: string | null): Bookmark[] { + if (!categoryId) { + return this.bookmarksState(); + } + + return this.bookmarksState().filter( + bookmark => bookmark.question.categoryId === categoryId + ); + } + + /** + * Filter bookmarks by difficulty + */ + filterByDifficulty(difficulty: string | null): Bookmark[] { + if (!difficulty) { + return this.bookmarksState(); + } + + return this.bookmarksState().filter( + bookmark => bookmark.question.difficulty === difficulty + ); + } + + /** + * Get unique categories from bookmarks + */ + getCategories(): Array<{ id: string; name: string }> { + const categoriesMap = new Map(); + + this.bookmarksState().forEach(bookmark => { + categoriesMap.set( + bookmark.question.categoryId, + bookmark.question.categoryName + ); + }); + + return Array.from(categoriesMap.entries()).map(([id, name]) => ({ id, name })); + } +} diff --git a/frontend/src/app/core/services/global-error-handler.service.ts b/frontend/src/app/core/services/global-error-handler.service.ts new file mode 100644 index 0000000..56a32ac --- /dev/null +++ b/frontend/src/app/core/services/global-error-handler.service.ts @@ -0,0 +1,107 @@ +import { ErrorHandler, Injectable, inject } from '@angular/core'; +import { ToastService } from './toast.service'; +import { Router } from '@angular/router'; + +/** + * Global Error Handler Service + * Catches all unhandled errors in the application + */ +@Injectable({ + providedIn: 'root' +}) +export class GlobalErrorHandlerService implements ErrorHandler { + private toastService = inject(ToastService); + private router = inject(Router); + + /** + * Handle uncaught errors + */ + handleError(error: Error | any): void { + // Log error to console + console.error('Global error caught:', error); + + // Log error to external service (optional) + this.logErrorToExternalService(error); + + // Determine user-friendly error message + let userMessage = 'An unexpected error occurred. Please try again.'; + let shouldRedirectToErrorPage = false; + + if (error instanceof Error) { + // Handle known error types + if (error.message.includes('ChunkLoadError') || error.message.includes('Loading chunk')) { + userMessage = 'Failed to load application resources. Please refresh the page.'; + } else if (error.message.includes('Network')) { + userMessage = 'Network error. Please check your internet connection.'; + } else if (error.name === 'TypeError') { + userMessage = 'A technical error occurred. Our team has been notified.'; + shouldRedirectToErrorPage = true; + } + } + + // Handle HTTP errors (already handled by errorInterceptor, but catch any that slip through) + if (error?.status) { + switch (error.status) { + case 0: + userMessage = 'Cannot connect to server. Please check your internet connection.'; + break; + case 401: + userMessage = 'Session expired. Please login again.'; + this.router.navigate(['/login']); + return; + case 403: + userMessage = 'You do not have permission to perform this action.'; + break; + case 404: + userMessage = 'The requested resource was not found.'; + break; + case 500: + case 502: + case 503: + userMessage = 'Server error. Please try again later.'; + shouldRedirectToErrorPage = true; + break; + default: + userMessage = `An error occurred (${error.status}). Please try again.`; + } + } + + // Show toast notification + this.toastService.error(userMessage, 8000); + + // Redirect to error page for critical errors + if (shouldRedirectToErrorPage && !this.router.url.includes('/error')) { + this.router.navigate(['/error'], { + queryParams: { + message: userMessage, + timestamp: Date.now() + } + }); + } + } + + /** + * Log error to external monitoring service + * TODO: Integrate with Sentry, LogRocket, or similar service + */ + private logErrorToExternalService(error: Error | any): void { + // Example implementation: + // if (environment.production) { + // Sentry.captureException(error); + // } + + // For now, just log to console with additional context + const errorLog = { + message: error?.message || 'Unknown error', + stack: error?.stack, + timestamp: new Date().toISOString(), + url: window.location.href, + userAgent: navigator.userAgent + }; + + console.log('Error logged:', errorLog); + + // TODO: Send to external service + // this.http.post('/api/logs/errors', errorLog).subscribe(); + } +} diff --git a/frontend/src/app/core/services/index.ts b/frontend/src/app/core/services/index.ts index 427a8bd..eb6d005 100644 --- a/frontend/src/app/core/services/index.ts +++ b/frontend/src/app/core/services/index.ts @@ -6,3 +6,5 @@ export * from './theme.service'; export * from './auth.service'; export * from './category.service'; export * from './guest.service'; +export * from './global-error-handler.service'; +export * from './pagination.service'; diff --git a/frontend/src/app/core/services/pagination.service.ts b/frontend/src/app/core/services/pagination.service.ts new file mode 100644 index 0000000..e4cfb71 --- /dev/null +++ b/frontend/src/app/core/services/pagination.service.ts @@ -0,0 +1,240 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; + +/** + * Pagination Configuration Interface + */ +export interface PaginationConfig { + currentPage: number; + pageSize: number; + totalItems: number; + pageSizeOptions?: number[]; +} + +/** + * Pagination State Interface + */ +export interface PaginationState { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + startIndex: number; + endIndex: number; + hasPrev: boolean; + hasNext: boolean; +} + +/** + * Pagination Service + * Provides reusable pagination logic with signal-based state management + */ +@Injectable({ + providedIn: 'root' +}) +export class PaginationService { + + constructor( + private router: Router, + private route: ActivatedRoute + ) {} + + /** + * Calculate pagination state from configuration + */ + calculatePaginationState(config: PaginationConfig): PaginationState { + const { currentPage, pageSize, totalItems } = config; + + // Calculate total pages + const totalPages = Math.ceil(totalItems / pageSize) || 1; + + // Ensure current page is within valid range + const validCurrentPage = Math.max(1, Math.min(currentPage, totalPages)); + + // Calculate start and end indices + const startIndex = (validCurrentPage - 1) * pageSize + 1; + const endIndex = Math.min(validCurrentPage * pageSize, totalItems); + + // Determine if previous and next pages exist + const hasPrev = validCurrentPage > 1; + const hasNext = validCurrentPage < totalPages; + + return { + currentPage: validCurrentPage, + pageSize, + totalItems, + totalPages, + startIndex, + endIndex, + hasPrev, + hasNext + }; + } + + /** + * Calculate page numbers to display (with ellipsis logic) + * Shows a maximum number of page buttons with smart ellipsis + */ + calculatePageNumbers( + currentPage: number, + totalPages: number, + maxVisiblePages: number = 5 + ): (number | string)[] { + if (totalPages <= maxVisiblePages) { + // Show all pages if total is less than max + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const pages: (number | string)[] = []; + const halfVisible = Math.floor(maxVisiblePages / 2); + + // Always show first page + pages.push(1); + + // Calculate start and end of visible page range + let startPage = Math.max(2, currentPage - halfVisible); + let endPage = Math.min(totalPages - 1, currentPage + halfVisible); + + // Adjust range if near start or end + if (currentPage <= halfVisible + 1) { + endPage = Math.min(totalPages - 1, maxVisiblePages - 1); + } else if (currentPage >= totalPages - halfVisible) { + startPage = Math.max(2, totalPages - maxVisiblePages + 2); + } + + // Add ellipsis after first page if needed + if (startPage > 2) { + pages.push('...'); + } + + // Add visible page numbers + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + // Add ellipsis before last page if needed + if (endPage < totalPages - 1) { + pages.push('...'); + } + + // Always show last page + if (totalPages > 1) { + pages.push(totalPages); + } + + return pages; + } + + /** + * Update URL query parameters with pagination state + */ + updateUrlQueryParams( + page: number, + pageSize?: number, + preserveParams: boolean = true + ): void { + const queryParams: any = preserveParams + ? { ...this.route.snapshot.queryParams } + : {}; + + queryParams.page = page; + + if (pageSize) { + queryParams.pageSize = pageSize; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: preserveParams ? 'merge' : 'replace' + }); + } + + /** + * Get pagination state from URL query parameters + */ + getPaginationFromUrl(defaultPageSize: number = 10): { page: number; pageSize: number } { + const params = this.route.snapshot.queryParams; + + const page = parseInt(params['page']) || 1; + const pageSize = parseInt(params['pageSize']) || defaultPageSize; + + return { + page: Math.max(1, page), + pageSize: Math.max(1, pageSize) + }; + } + + /** + * Create a signal-based pagination state manager + * Returns signals and methods for managing pagination + */ + createPaginationManager(initialConfig: PaginationConfig) { + const config = signal(initialConfig); + + const state = computed(() => + this.calculatePaginationState(config()) + ); + + const pageNumbers = computed(() => + this.calculatePageNumbers( + state().currentPage, + state().totalPages, + 5 + ) + ); + + return { + // Signals + config, + state, + pageNumbers, + + // Methods + setPage: (page: number) => { + config.update(c => ({ ...c, currentPage: page })); + }, + + setPageSize: (pageSize: number) => { + config.update(c => ({ + ...c, + pageSize, + currentPage: 1 // Reset to first page when page size changes + })); + }, + + setTotalItems: (totalItems: number) => { + config.update(c => ({ ...c, totalItems })); + }, + + nextPage: () => { + if (state().hasNext) { + config.update(c => ({ ...c, currentPage: c.currentPage + 1 })); + } + }, + + prevPage: () => { + if (state().hasPrev) { + config.update(c => ({ ...c, currentPage: c.currentPage - 1 })); + } + }, + + firstPage: () => { + config.update(c => ({ ...c, currentPage: 1 })); + }, + + lastPage: () => { + config.update(c => ({ ...c, currentPage: state().totalPages })); + } + }; + } + + /** + * Calculate items to display for current page (for client-side pagination) + */ + getPaginatedItems(items: T[], currentPage: number, pageSize: number): T[] { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return items.slice(startIndex, endIndex); + } +} diff --git a/frontend/src/app/core/services/search.service.ts b/frontend/src/app/core/services/search.service.ts new file mode 100644 index 0000000..92f4bdf --- /dev/null +++ b/frontend/src/app/core/services/search.service.ts @@ -0,0 +1,296 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, catchError, tap } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; + +/** + * Search result types + */ +export type SearchResultType = 'question' | 'category' | 'quiz'; + +/** + * Individual search result item + */ +export interface SearchResultItem { + id: string | number; + type: SearchResultType; + title: string; + description?: string; + highlight?: string; + category?: string; + difficulty?: string; + icon?: string; + url?: string; +} + +/** + * Search results grouped by type + */ +export interface SearchResults { + questions: SearchResultItem[]; + categories: SearchResultItem[]; + quizzes: SearchResultItem[]; + totalResults: number; +} + +/** + * Search response from API + */ +export interface SearchResponse { + success: boolean; + data: { + questions: any[]; + categories: any[]; + quizzes: any[]; + }; + total: number; +} + +/** + * SearchService + * + * Global search service for searching across questions, categories, and quizzes. + * + * Features: + * - Debounced search input (500ms) + * - Search across multiple entity types + * - Signal-based state management + * - Result caching + * - Empty state handling + * - Loading states + * - Error handling + */ +@Injectable({ + providedIn: 'root' +}) +export class SearchService { + private readonly http = inject(HttpClient); + private readonly apiUrl = `${environment.apiUrl}/search`; + + // State signals + readonly searchResults = signal({ + questions: [], + categories: [], + quizzes: [], + totalResults: 0 + }); + readonly isSearching = signal(false); + readonly searchQuery = signal(''); + readonly hasSearched = signal(false); + + // Cache for recent searches (optional optimization) + private searchCache = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + /** + * Perform global search across all entities + */ + search(query: string): Observable { + // Update query state + this.searchQuery.set(query); + + // Handle empty query + if (!query || query.trim().length < 2) { + this.clearResults(); + return of(this.searchResults()); + } + + const trimmedQuery = query.trim(); + + // Check cache first + const cached = this.searchCache.get(trimmedQuery); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + this.searchResults.set(cached.results); + this.hasSearched.set(true); + return of(cached.results); + } + + // Set loading state + this.isSearching.set(true); + + const params = new HttpParams().set('q', trimmedQuery).set('limit', '5'); + + return this.http.get(`${this.apiUrl}`, { params }).pipe( + tap((response) => { + const results = this.transformSearchResults(response); + this.searchResults.set(results); + this.hasSearched.set(true); + + // Cache the results + this.searchCache.set(trimmedQuery, { + results, + timestamp: Date.now() + }); + }), + switchMap(() => of(this.searchResults())), + catchError((error) => { + console.error('Search error:', error); + this.clearResults(); + return of(this.searchResults()); + }), + tap(() => this.isSearching.set(false)) + ); + } + + /** + * Search only questions + */ + searchQuestions(query: string, limit: number = 10): Observable { + if (!query || query.trim().length < 2) { + return of([]); + } + + const params = new HttpParams() + .set('q', query.trim()) + .set('type', 'questions') + .set('limit', limit.toString()); + + return this.http.get(`${this.apiUrl}`, { params }).pipe( + switchMap((response) => of(this.transformQuestions(response.data.questions))), + catchError(() => of([])) + ); + } + + /** + * Search only categories + */ + searchCategories(query: string, limit: number = 10): Observable { + if (!query || query.trim().length < 2) { + return of([]); + } + + const params = new HttpParams() + .set('q', query.trim()) + .set('type', 'categories') + .set('limit', limit.toString()); + + return this.http.get(`${this.apiUrl}`, { params }).pipe( + switchMap((response) => of(this.transformCategories(response.data.categories))), + catchError(() => of([])) + ); + } + + /** + * Clear search results + */ + clearResults(): void { + this.searchResults.set({ + questions: [], + categories: [], + quizzes: [], + totalResults: 0 + }); + this.searchQuery.set(''); + this.hasSearched.set(false); + this.isSearching.set(false); + } + + /** + * Clear search cache + */ + clearCache(): void { + this.searchCache.clear(); + } + + /** + * Transform API response to SearchResults + */ + private transformSearchResults(response: SearchResponse): SearchResults { + return { + questions: this.transformQuestions(response.data.questions), + categories: this.transformCategories(response.data.categories), + quizzes: this.transformQuizzes(response.data.quizzes), + totalResults: response.total + }; + } + + /** + * Transform question results + */ + private transformQuestions(questions: any[]): SearchResultItem[] { + return questions.map(q => ({ + id: q.id, + type: 'question' as SearchResultType, + title: q.questionText, + description: q.explanation?.substring(0, 100), + highlight: this.highlightMatch(q.questionText, this.searchQuery()), + category: q.category?.name, + difficulty: q.difficulty, + icon: 'quiz', + url: `/quiz/question/${q.id}` + })); + } + + /** + * Transform category results + */ + private transformCategories(categories: any[]): SearchResultItem[] { + return categories.map(c => ({ + id: c.id, + type: 'category' as SearchResultType, + title: c.name, + description: c.description?.substring(0, 100), + highlight: this.highlightMatch(c.name, this.searchQuery()), + icon: c.icon || 'category', + url: `/categories/${c.id}` + })); + } + + /** + * Transform quiz results + */ + private transformQuizzes(quizzes: any[]): SearchResultItem[] { + return quizzes.map(q => ({ + id: q.id, + type: 'quiz' as SearchResultType, + title: `Quiz: ${q.category?.name || 'Unknown'}`, + description: `${q.totalQuestions} questions - Score: ${q.score}%`, + category: q.category?.name, + icon: 'assessment', + url: `/quiz/review/${q.id}` + })); + } + + /** + * Highlight matching text in search results + */ + private highlightMatch(text: string, query: string): string { + if (!query || !text) return text; + + const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi'); + return text.replace(regex, '$1'); + } + + /** + * Escape special regex characters + */ + private escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + /** + * Check if results are empty + */ + hasResults(): boolean { + const results = this.searchResults(); + return results.totalResults > 0; + } + + /** + * Get results by type + */ + getResultsByType(type: SearchResultType): SearchResultItem[] { + const results = this.searchResults(); + switch (type) { + case 'question': + return results.questions; + case 'category': + return results.categories; + case 'quiz': + return results.quizzes; + default: + return []; + } + } +} diff --git a/frontend/src/app/core/services/user.service.ts b/frontend/src/app/core/services/user.service.ts index a8a3a30..5ae79ae 100644 --- a/frontend/src/app/core/services/user.service.ts +++ b/frontend/src/app/core/services/user.service.ts @@ -4,8 +4,10 @@ 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 { UserDashboard, QuizHistoryResponse, UserProfileUpdate, UserProfileUpdateResponse } from '../models/dashboard.model'; import { ToastService } from './toast.service'; +import { AuthService } from './auth.service'; +import { StorageService } from './storage.service'; interface CacheEntry { data: T; @@ -19,6 +21,8 @@ export class UserService { private http = inject(HttpClient); private router = inject(Router); private toastService = inject(ToastService); + private authService = inject(AuthService); + private storageService = inject(StorageService); private readonly API_URL = `${environment.apiUrl}/users`; private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds @@ -123,12 +127,23 @@ export class UserService { /** * Update user profile */ - updateProfile(userId: string, data: UserProfileUpdate): Observable { + updateProfile(userId: string, data: UserProfileUpdate): Observable { this.isLoading.set(true); this.error.set(null); - return this.http.put(`${this.API_URL}/${userId}`, data).pipe( + return this.http.put(`${this.API_URL}/${userId}`, data).pipe( tap(response => { + // Update auth state with new user data + const currentUser = this.authService.getCurrentUser(); + if (currentUser && response.data?.user) { + const updatedUser = { ...currentUser, ...response.data.user }; + this.storageService.setUserData(updatedUser); + + // Update auth state by calling a private method reflection + // Since updateAuthState is private, we update storage directly + // The auth state will sync on next navigation/refresh + } + this.isLoading.set(false); this.toastService.success('Profile updated successfully'); // Invalidate dashboard cache diff --git a/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.html b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.html new file mode 100644 index 0000000..ddee1d2 --- /dev/null +++ b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.html @@ -0,0 +1,275 @@ +
+ +
+
+

+ admin_panel_settings + Admin Dashboard +

+

System-wide statistics and analytics

+
+
+ +
+
+ + + + +
+

+ date_range + Filter by Date Range +

+
+ + Start Date + + + + + + + End Date + + + + + + + + @if (hasDateFilter()) { + + } +
+
+
+
+ + + @if (isLoading()) { +
+ +

Loading statistics...

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

Failed to Load Statistics

+

{{ error() }}

+ +
+
+
+ } + + + @if (stats() && !isLoading()) { + +
+ + +
+ people +
+
+

Total Users

+

{{ formatNumber(totalUsers()) }}

+ @if (stats() && stats()!.stats.newUsersThisWeek) { +

+{{ stats()!.stats.newUsersThisWeek }} this week

+ } +
+
+
+ + + +
+ trending_up +
+
+

Active Users

+

{{ formatNumber(activeUsers()) }}

+

Last 7 days

+
+
+
+ + + +
+ quiz +
+
+

Total Quizzes

+

{{ formatNumber(totalQuizSessions()) }}

+ @if (stats() && stats()!.stats.quizzesThisWeek) { +

+{{ stats()!.stats.quizzesThisWeek }} this week

+ } +
+
+
+ + + +
+ help_outline +
+
+

Total Questions

+

{{ formatNumber(totalQuestions()) }}

+

In database

+
+
+
+
+ + + + + + bar_chart + Average Quiz Score + + + +
+
+ {{ formatPercentage(averageScore()) }} +
+

+ @if (averageScore() >= 80) { + Excellent performance across all quizzes + } @else if (averageScore() >= 60) { + Good performance overall + } @else { + Room for improvement + } +

+
+
+
+ + + @if (userGrowthData().length > 0) { + + + + show_chart + User Growth Over Time + + + +
+ + + + + + + + + + + + + + + + @for (point of userGrowthData(); track point.date; let i = $index) { + + } + +
+
+
+ } + + + @if (popularCategories().length > 0) { + + + + category + Most Popular Categories + + + +
+ + + + + + + + + + + + @for (bar of getCategoryBars(); track bar.label) { + + {{ bar.value }} + {{ bar.label }} + } + +
+
+
+ } + + +
+

Quick Actions

+
+ + + + +
+
+ } + + + @if (!stats() && !isLoading() && !error()) { + + + analytics +

No Statistics Available

+

Statistics will appear here once users start taking quizzes

+
+
+ } +
diff --git a/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.scss b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.scss new file mode 100644 index 0000000..1724fd3 --- /dev/null +++ b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.scss @@ -0,0 +1,511 @@ +.admin-dashboard { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + + // Header + .dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + + .header-content { + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 2rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #1a237e; + + mat-icon { + font-size: 2.5rem; + width: 2.5rem; + height: 2.5rem; + color: #3f51b5; + } + } + + .subtitle { + margin: 0; + color: #666; + font-size: 1rem; + } + } + + .header-actions { + display: flex; + gap: 0.5rem; + + button { + mat-icon { + transition: transform 0.3s ease; + } + + &:hover:not([disabled]) mat-icon { + transform: rotate(180deg); + } + } + } + } + + // Date Filter Card + .filter-card { + margin-bottom: 2rem; + + mat-card-content { + padding: 1.5rem; + } + + .date-filter { + h3 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 1rem 0; + font-size: 1.1rem; + color: #333; + + mat-icon { + color: #3f51b5; + } + } + + .date-inputs { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + + mat-form-field { + flex: 1; + min-width: 200px; + } + + button { + height: 56px; + } + } + } + } + + // Loading State + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + font-size: 1.1rem; + color: #666; + } + } + + // Error State + .error-card { + margin-bottom: 2rem; + border-left: 4px solid #f44336; + + .error-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem; + gap: 1rem; + + mat-icon { + font-size: 4rem; + width: 4rem; + height: 4rem; + } + + h3 { + margin: 0; + font-size: 1.5rem; + color: #333; + } + + p { + margin: 0; + color: #666; + } + + button { + margin-top: 1rem; + } + } + } + + // Statistics Grid + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; + + .stat-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + } + + mat-card-content { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.5rem; + + .stat-icon { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + border-radius: 12px; + + mat-icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + color: white; + } + } + + .stat-info { + flex: 1; + + h3 { + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + font-weight: 500; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-value { + margin: 0 0 0.25rem 0; + font-size: 2rem; + font-weight: 700; + color: #333; + } + + .stat-detail { + margin: 0; + font-size: 0.85rem; + color: #4caf50; + font-weight: 500; + } + } + } + + &.users-card .stat-icon { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + &.active-card .stat-icon { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + } + + &.quizzes-card .stat-icon { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + } + + &.questions-card .stat-icon { + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); + } + } + } + + // Average Score Card + .score-card { + margin-bottom: 2rem; + + mat-card-header { + padding: 1.5rem 1.5rem 0; + + mat-card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.3rem; + color: #333; + + mat-icon { + color: #3f51b5; + } + } + } + + mat-card-content { + padding: 2rem; + + .score-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + + .score-circle { + width: 150px; + height: 150px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); + + .score-value { + font-size: 2.5rem; + font-weight: 700; + color: white; + } + } + + .score-description { + margin: 0; + font-size: 1.1rem; + text-align: center; + + .excellent { + color: #4caf50; + font-weight: 600; + } + + .good { + color: #ff9800; + font-weight: 600; + } + + .needs-improvement { + color: #f44336; + font-weight: 600; + } + } + } + } + } + + // Chart Cards + .chart-card { + margin-bottom: 2rem; + + mat-card-header { + padding: 1.5rem 1.5rem 0; + + mat-card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.3rem; + color: #333; + + mat-icon { + color: #3f51b5; + } + } + } + + mat-card-content { + padding: 1.5rem; + + .chart-container { + overflow-x: auto; + + svg { + display: block; + margin: 0 auto; + + &.line-chart path { + transition: stroke-dashoffset 1s ease; + stroke-dasharray: 2000; + stroke-dashoffset: 2000; + animation: drawLine 2s ease forwards; + } + + &.bar-chart rect { + transition: opacity 0.3s ease; + + &:hover { + opacity: 1 !important; + } + } + + text { + font-family: 'Roboto', sans-serif; + } + } + } + } + } + + @keyframes drawLine { + to { + stroke-dashoffset: 0; + } + } + + // Quick Actions + .quick-actions { + margin-top: 3rem; + + h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: #333; + } + + .actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + + button { + height: 60px; + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + + mat-icon { + font-size: 1.5rem; + width: 1.5rem; + height: 1.5rem; + } + } + } + } + + // Empty State + .empty-state { + margin-top: 2rem; + + mat-card-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + mat-icon { + font-size: 5rem; + width: 5rem; + height: 5rem; + color: #bdbdbd; + margin-bottom: 1rem; + } + + h3 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + color: #333; + } + + p { + margin: 0; + color: #666; + font-size: 1rem; + } + } + } + + // Responsive Design + @media (max-width: 768px) { + padding: 1rem; + + .dashboard-header { + flex-direction: column; + gap: 1rem; + + .header-content h1 { + font-size: 1.5rem; + + mat-icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + } + } + + .header-actions { + align-self: flex-end; + } + } + + .filter-card .date-filter .date-inputs { + flex-direction: column; + align-items: stretch; + + mat-form-field { + width: 100%; + } + + button { + width: 100%; + } + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .chart-card mat-card-content .chart-container { + svg { + width: 100%; + height: auto; + } + } + + .quick-actions .actions-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .admin-dashboard { + .dashboard-header .header-content h1 { + color: #e3f2fd; + } + + .filter-card .date-filter h3, + .chart-card mat-card-title, + .score-card mat-card-title, + .quick-actions h2 { + color: #e0e0e0; + } + + .stats-grid .stat-card { + mat-card-content .stat-info { + h3 { + color: #bdbdbd; + } + + .stat-value { + color: #e0e0e0; + } + } + } + + .empty-state mat-card-content h3 { + color: #e0e0e0; + } + } +} diff --git a/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.ts b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.ts new file mode 100644 index 0000000..8f06c0b --- /dev/null +++ b/frontend/src/app/features/admin/admin-dashboard/admin-dashboard.component.ts @@ -0,0 +1,285 @@ +import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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 { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatNativeDateModule } from '@angular/material/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; +import { AdminService } from '../../../core/services/admin.service'; +import { AdminStatistics } from '../../../core/models/admin.model'; + +/** + * AdminDashboardComponent + * + * Main landing page for administrators featuring: + * - System-wide statistics cards (users, quizzes, questions) + * - User growth line chart + * - Popular categories bar chart + * - Average quiz scores display + * - Date range filtering + * - Responsive layout with loading skeletons + * + * Features: + * - Real-time statistics with 5-min caching + * - Interactive charts (using SVG for simplicity) + * - Date range picker for filtering + * - Auto-refresh capability + * - Mobile-responsive grid layout + */ +@Component({ + selector: 'app-admin-dashboard', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + MatNativeDateModule, + ReactiveFormsModule + ], + templateUrl: './admin-dashboard.component.html', + styleUrls: ['./admin-dashboard.component.scss'] +}) +export class AdminDashboardComponent implements OnInit, OnDestroy { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly destroy$ = new Subject(); + + // State from service + readonly stats = this.adminService.adminStatsState; + readonly isLoading = this.adminService.isLoadingStats; + readonly error = this.adminService.statsError; + readonly dateFilter = this.adminService.dateRangeFilter; + + // Date range form + readonly dateRangeForm = new FormGroup({ + startDate: new FormControl(null), + endDate: new FormControl(null) + }); + + // Computed values for cards + readonly totalUsers = this.adminService.totalUsers; + readonly activeUsers = this.adminService.activeUsers; + readonly totalQuizSessions = this.adminService.totalQuizSessions; + readonly totalQuestions = this.adminService.totalQuestions; + readonly averageScore = this.adminService.averageScore; + + // Chart data computed signals + readonly userGrowthData = computed(() => this.stats()?.userGrowth ?? []); + readonly popularCategories = computed(() => this.stats()?.popularCategories ?? []); + readonly hasDateFilter = computed(() => { + const filter = this.dateFilter(); + return filter.startDate !== null && filter.endDate !== null; + }); + + // Chart dimensions + readonly chartWidth = 800; + readonly chartHeight = 300; + + ngOnInit(): void { + this.loadStatistics(); + this.setupDateRangeListener(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Load statistics from service + */ + private loadStatistics(): void { + this.adminService.getStatistics() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + error: (error) => { + console.error('Failed to load admin statistics:', error); + } + }); + } + + /** + * Setup date range form listener + */ + private setupDateRangeListener(): void { + this.dateRangeForm.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + if (value.startDate && value.endDate) { + this.applyDateFilter(); + } + }); + } + + /** + * Apply date range filter + */ + applyDateFilter(): void { + const startDate = this.dateRangeForm.value.startDate; + const endDate = this.dateRangeForm.value.endDate; + + if (!startDate || !endDate) { + return; + } + + if (startDate > endDate) { + alert('Start date must be before end date'); + return; + } + + this.adminService.getStatisticsWithDateRange(startDate, endDate) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Clear date filter and reload all-time stats + */ + clearDateFilter(): void { + this.dateRangeForm.reset(); + this.adminService.clearDateFilter(); + } + + /** + * Refresh statistics (force reload) + */ + refreshStats(): void { + this.adminService.refreshStatistics() + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Get max count from user growth data + */ + getMaxUserCount(): number { + const data = this.userGrowthData(); + if (data.length === 0) return 1; + return Math.max(...data.map(d => d.count), 1); + } + + /** + * Calculate Y coordinate for a data point + */ + calculateChartY(count: number, index: number): number { + const maxCount = this.getMaxUserCount(); + const height = this.chartHeight; + const padding = 40; + const plotHeight = height - 2 * padding; + return height - padding - (count / maxCount) * plotHeight; + } + + /** + * Calculate X coordinate for a data point + */ + calculateChartX(index: number, totalPoints: number): number { + const width = this.chartWidth; + const padding = 40; + const plotWidth = width - 2 * padding; + return padding + (index / (totalPoints - 1)) * plotWidth; + } + + /** + * Generate SVG path for user growth line chart + */ + getUserGrowthPath(): string { + const data = this.userGrowthData(); + if (data.length === 0) return ''; + + const maxCount = Math.max(...data.map(d => d.count), 1); + const width = this.chartWidth; + const height = this.chartHeight; + const padding = 40; + const plotWidth = width - 2 * padding; + const plotHeight = height - 2 * padding; + + const points = data.map((d, i) => { + const x = padding + (i / (data.length - 1)) * plotWidth; + const y = height - padding - (d.count / maxCount) * plotHeight; + return `${x},${y}`; + }); + + return `M ${points.join(' L ')}`; + } + + /** + * Get bar chart data for popular categories + */ + getCategoryBars(): Array<{ x: number; y: number; width: number; height: number; label: string; value: number }> { + const categories = this.popularCategories(); + if (categories.length === 0) return []; + + const maxCount = Math.max(...categories.map(c => c.quizCount), 1); + const width = this.chartWidth; + const height = this.chartHeight; + const padding = 40; + const plotWidth = width - 2 * padding; + const plotHeight = height - 2 * padding; + const barWidth = plotWidth / categories.length - 10; + + return categories.map((cat, i) => { + const barHeight = (cat.quizCount / maxCount) * plotHeight; + return { + x: padding + i * (plotWidth / categories.length) + 5, + y: height - padding - barHeight, + width: barWidth, + height: barHeight, + label: cat.categoryName, + value: cat.quizCount + }; + }); + } + + /** + * Format number with commas + */ + formatNumber(num: number): string { + return num.toLocaleString(); + } + + /** + * Format percentage + */ + formatPercentage(num: number): string { + return `${num.toFixed(1)}%`; + } + + /** + * Navigate to user management + */ + goToUsers(): void { + this.router.navigate(['/admin/users']); + } + + /** + * Navigate to question management + */ + goToQuestions(): void { + this.router.navigate(['/admin/questions']); + } + + /** + * Navigate to analytics + */ + goToAnalytics(): void { + this.router.navigate(['/admin/analytics']); + } + + /** + * Navigate to settings + */ + goToSettings(): void { + this.router.navigate(['/admin/settings']); + } +} diff --git a/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.html b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.html new file mode 100644 index 0000000..82d2f42 --- /dev/null +++ b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.html @@ -0,0 +1,430 @@ +
+ +
+ @if (isEditMode()) { +

+ edit + Edit Question +

+

Update the details below to modify the quiz question

+ @if (questionId()) { +

Question ID: {{ questionId() }}

+ } + } @else { +

+ add_circle + Create New Question +

+

Fill in the details below to create a new quiz question

+ } +
+ +
+ + @if (isLoadingQuestion()) { + + +
+ hourglass_empty +

Loading question data...

+
+
+
+ } @else { + + + +
+ + @if (getFormError()) { +
+ error + {{ getFormError() }} +
+ } + + + + Question Text + + Minimum 10 characters + @if (getErrorMessage('questionText')) { + {{ getErrorMessage('questionText') }} + } + + + +
+ + Question Type + + @for (type of questionTypes; track type.value) { + + {{ type.label }} + + } + + @if (getErrorMessage('questionType')) { + {{ getErrorMessage('questionType') }} + } + + + + Category + + @if (isLoadingCategories()) { + Loading categories... + } @else { + @for (category of categories(); track category.id) { + + {{ category.name }} + + } + } + + @if (getErrorMessage('categoryId')) { + {{ getErrorMessage('categoryId') }} + } + +
+ + +
+ + Difficulty + + @for (level of difficultyLevels; track level.value) { + + {{ level.label }} + + } + + @if (getErrorMessage('difficulty')) { + {{ getErrorMessage('difficulty') }} + } + + + + Points + + Between 1 and 100 + @if (getErrorMessage('points')) { + {{ getErrorMessage('points') }} + } + +
+ + + + + @if (showOptions()) { +
+

+ list + Answer Options +

+ +
+ @for (option of optionsArray.controls; track $index) { +
+ Option {{ $index + 1 }} + + + + @if (optionsArray.length > 2) { + + } +
+ } +
+ + @if (optionsArray.length < 10) { + + } +
+ + + + +
+

+ check_circle + Correct Answer +

+ + Select Correct Answer + + @for (optionText of getOptionTexts(); track $index) { + + {{ optionText }} + + } + + @if (getErrorMessage('correctAnswer')) { + {{ getErrorMessage('correctAnswer') }} + } + +
+ } + + + @if (showTrueFalse()) { +
+

+ check_circle + Correct Answer +

+ + True + False + +
+ } + + + @if (selectedQuestionType() === 'written') { +
+

+ edit + Sample Correct Answer +

+ + Expected Answer + + This is a reference answer for grading + @if (getErrorMessage('correctAnswer')) { + {{ getErrorMessage('correctAnswer') }} + } + +
+ } + + + + + + Explanation + + Minimum 10 characters + @if (getErrorMessage('explanation')) { + {{ getErrorMessage('explanation') }} + } + + + +
+

+ label + Tags (Optional) +

+ + Add Tags + + @for (tag of tagsArray; track tag) { + + {{ tag }} + + + } + + + Press Enter or comma to add tags + +
+ + +
+ + Make question public + + + Allow guest access + +
+ + +
+ + + +
+
+
+
+ } + + + + + + visibility + Preview + + + +
+ +
+
Question:
+
+ {{ questionForm.get('questionText')?.value || 'Your question will appear here...' }} +
+
+ + +
+ + {{ questionForm.get('questionType')?.value | titlecase }} + + + {{ questionForm.get('difficulty')?.value | titlecase }} + + + {{ questionForm.get('points')?.value || 10 }} Points + +
+ + + @if (showOptions() && getOptionTexts().length > 0) { +
+
Options:
+
+ @for (optionText of getOptionTexts(); track $index) { +
+ {{ questionForm.get('correctAnswer')?.value === optionText ? 'check_circle' : 'radio_button_unchecked' }} + {{ optionText }} +
+ } +
+
+ } + + + @if (showTrueFalse()) { +
+
Options:
+
+
+ {{ questionForm.get('correctAnswer')?.value === 'true' ? 'check_circle' : 'radio_button_unchecked' }} + True +
+
+ {{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' : 'radio_button_unchecked' }} + False +
+
+
+ } + + + @if (questionForm.get('explanation')?.value) { +
+
Explanation:
+
+ {{ questionForm.get('explanation')?.value }} +
+
+ } + + + @if (tagsArray.length > 0) { +
+
Tags:
+
+ @for (tag of tagsArray; track tag) { + {{ tag }} + } +
+
+ } + + +
+
Access:
+
+ @if (questionForm.get('isPublic')?.value) { + Public + } @else { + Private + } + @if (questionForm.get('isGuestAccessible')?.value) { + Guest Accessible + } +
+
+
+
+
+
+
diff --git a/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.scss b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.scss new file mode 100644 index 0000000..6621032 --- /dev/null +++ b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.scss @@ -0,0 +1,535 @@ +.question-form-container { + padding: 24px; + max-width: 1400px; + margin: 0 auto; + + @media (max-width: 768px) { + padding: 16px; + } +} + +// =========================== +// Header +// =========================== +.form-header { + margin-bottom: 24px; + + h1 { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 8px 0; + font-size: 32px; + font-weight: 600; + color: var(--mat-app-on-surface, #212121); + + mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: var(--mat-app-primary, #1976d2); + } + } + + .subtitle { + margin: 0; + font-size: 16px; + color: var(--mat-app-on-surface-variant, #757575); + } + + .question-id { + margin: 8px 0 0 0; + padding: 6px 12px; + background-color: rgba(33, 150, 243, 0.1); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + color: var(--mat-app-primary, #1976d2); + width: fit-content; + } + + @media (max-width: 768px) { + h1 { + font-size: 24px; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + + .subtitle { + font-size: 14px; + } + } +} + +// =========================== +// Layout +// =========================== +.form-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 24px; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } +} + +.form-card, +.preview-card { + height: fit-content; + + mat-card-content { + padding: 24px !important; + } +} + +.preview-card { + position: sticky; + top: 24px; + + @media (max-width: 1024px) { + position: static; + order: -1; + } + + mat-card-header { + padding: 16px 24px 0; + + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 600; + + mat-icon { + color: var(--mat-app-primary, #1976d2); + } + } + } +} + +// =========================== +// Form Elements +// =========================== +.full-width { + width: 100%; +} + +.half-width { + width: calc(50% - 8px); + + @media (max-width: 768px) { + width: 100%; + } +} + +.form-row { + display: flex; + gap: 16px; + margin-bottom: 16px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 0; + } +} + +mat-form-field { + margin-bottom: 16px; +} + +mat-divider { + margin: 24px 0; +} + +// =========================== +// Form Sections +// =========================== +.options-section, +.correct-answer-section, +.tags-section { + margin-bottom: 24px; + + h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 600; + color: var(--mat-app-on-surface, #212121); + + mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: var(--mat-app-primary, #1976d2); + } + } +} + +// =========================== +// Options List +// =========================== +.options-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.option-row { + display: flex; + align-items: center; + gap: 12px; + + .option-label { + min-width: 70px; + font-weight: 500; + color: var(--mat-app-on-surface-variant, #757575); + } + + .option-input { + flex: 1; + margin-bottom: 0; + } + + @media (max-width: 768px) { + flex-wrap: wrap; + + .option-label { + width: 100%; + margin-bottom: 4px; + } + + .option-input { + width: calc(100% - 48px); + } + } +} + +.add-option-btn { + width: 100%; + border-style: dashed !important; +} + +// =========================== +// Radio Group +// =========================== +.radio-group { + display: flex; + flex-direction: column; + gap: 12px; + + mat-radio-button { + margin: 0; + } +} + +// =========================== +// Checkbox Group +// =========================== +.checkbox-group { + display: flex; + flex-direction: column; + gap: 12px; + margin: 24px 0; +} + +// =========================== +// Form Actions +// =========================== +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--mat-app-outline-variant, #e0e0e0); + + button { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + @media (max-width: 768px) { + flex-direction: column-reverse; + + button { + width: 100%; + justify-content: center; + } + } +} + +// =========================== +// Form Error +// =========================== +.form-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + margin-bottom: 16px; + background-color: rgba(244, 67, 54, 0.1); + border-left: 4px solid var(--mat-warn-main, #f44336); + border-radius: 4px; + color: var(--mat-warn-dark, #d32f2f); + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + font-size: 14px; + font-weight: 500; + } +} + +// =========================== +// Preview Panel +// =========================== +.preview-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.preview-section { + .preview-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--mat-app-on-surface-variant, #757575); + margin-bottom: 8px; + letter-spacing: 0.5px; + } + + .preview-text { + font-size: 16px; + line-height: 1.6; + color: var(--mat-app-on-surface, #212121); + white-space: pre-wrap; + } + + .preview-explanation { + padding: 12px; + background-color: rgba(33, 150, 243, 0.1); + border-left: 3px solid var(--mat-app-primary, #1976d2); + border-radius: 4px; + font-size: 14px; + line-height: 1.5; + color: var(--mat-app-on-surface, #212121); + } +} + +.preview-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.preview-badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + + &.type-badge { + background-color: rgba(33, 150, 243, 0.1); + color: #2196f3; + } + + &.difficulty-badge { + &.difficulty-easy { + background-color: rgba(76, 175, 80, 0.1); + color: #4caf50; + } + + &.difficulty-medium { + background-color: rgba(255, 152, 0, 0.1); + color: #ff9800; + } + + &.difficulty-hard { + background-color: rgba(244, 67, 54, 0.1); + color: #f44336; + } + } + + &.points-badge { + background-color: rgba(156, 39, 176, 0.1); + color: #9c27b0; + } +} + +.preview-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.preview-option { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: var(--mat-app-surface-variant, #f5f5f5); + border-radius: 8px; + transition: all 0.2s ease; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--mat-app-on-surface-variant, #757575); + } + + span { + flex: 1; + font-size: 14px; + color: var(--mat-app-on-surface, #212121); + } + + &.correct { + background-color: rgba(76, 175, 80, 0.1); + border: 1px solid rgba(76, 175, 80, 0.3); + + mat-icon { + color: #4caf50; + } + + span { + font-weight: 500; + } + } +} + +.preview-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.preview-tag { + display: inline-block; + padding: 4px 12px; + background-color: var(--mat-app-surface-variant, #f5f5f5); + border-radius: 12px; + font-size: 12px; + color: var(--mat-app-on-surface, #212121); +} + +.preview-access { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.access-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + + &.public { + background-color: rgba(76, 175, 80, 0.1); + color: #4caf50; + border: 1px solid rgba(76, 175, 80, 0.3); + } + + &.private { + background-color: rgba(158, 158, 158, 0.1); + color: #9e9e9e; + border: 1px solid rgba(158, 158, 158, 0.3); + } + + &.guest { + background-color: rgba(33, 150, 243, 0.1); + color: #2196f3; + border: 1px solid rgba(33, 150, 243, 0.3); + } +} + +// =========================== +// Loading State +// =========================== +.loading-card { + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + color: var(--mat-app-on-surface-variant, #757575); + + .loading-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: var(--mat-app-primary, #1976d2); + animation: spin 2s linear infinite; + } + + p { + margin: 0; + font-size: 16px; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +// =========================== +// Dark Mode Support +// =========================== +@media (prefers-color-scheme: dark) { + .preview-option { + background-color: rgba(255, 255, 255, 0.05); + + &.correct { + background-color: rgba(76, 175, 80, 0.15); + } + } + + .preview-explanation { + background-color: rgba(33, 150, 243, 0.15); + } + + .preview-tag, + .access-badge.private { + background-color: rgba(255, 255, 255, 0.05); + } + + .form-error { + background-color: rgba(244, 67, 54, 0.15); + } +} diff --git a/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.ts b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.ts new file mode 100644 index 0000000..16a8f85 --- /dev/null +++ b/frontend/src/app/features/admin/admin-question-form/admin-question-form.component.ts @@ -0,0 +1,480 @@ +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdminService } from '../../../core/services/admin.service'; +import { CategoryService } from '../../../core/services/category.service'; +import { Question, QuestionFormData } from '../../../core/models/question.model'; +import { QuestionType, Difficulty } from '../../../core/models/category.model'; + +/** + * AdminQuestionFormComponent + * + * Comprehensive form for creating new quiz questions. + * + * Features: + * - Dynamic form based on question type + * - Real-time validation + * - Question preview panel + * - Tag input with chips + * - Dynamic options for MCQ + * - Correct answer validation + * - Category selection + * - Difficulty levels + * - Guest accessibility toggle + * + * Question Types: + * - Multiple Choice: Radio options with dynamic add/remove + * - True/False: Pre-defined boolean options + * - Written: Text-based answer + */ +@Component({ + selector: 'app-admin-question-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MatCheckboxModule, + MatRadioModule, + MatDividerModule, + MatTooltipModule + ], + templateUrl: './admin-question-form.component.html', + styleUrl: './admin-question-form.component.scss' +}) +export class AdminQuestionFormComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly adminService = inject(AdminService); + private readonly categoryService = inject(CategoryService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + // Form state + questionForm!: FormGroup; + isSubmitting = signal(false); + isEditMode = signal(false); + questionId = signal(null); + isLoadingQuestion = signal(false); + + // Categories from service + readonly categories = this.categoryService.categories; + readonly isLoadingCategories = this.categoryService.isLoading; + + // Chip input config + readonly separatorKeysCodes: number[] = [ENTER, COMMA]; + + // Available options + readonly questionTypes = [ + { value: 'multiple_choice', label: 'Multiple Choice' }, + { value: 'true_false', label: 'True/False' }, + { value: 'written', label: 'Written Answer' } + ]; + + readonly difficultyLevels = [ + { value: 'easy', label: 'Easy' }, + { value: 'medium', label: 'Medium' }, + { value: 'hard', label: 'Hard' } + ]; + + // Computed properties + readonly selectedQuestionType = computed(() => { + return this.questionForm?.get('questionType')?.value as QuestionType; + }); + + readonly showOptions = computed(() => { + const type = this.selectedQuestionType(); + return type === 'multiple_choice'; + }); + + readonly showTrueFalse = computed(() => { + const type = this.selectedQuestionType(); + return type === 'true_false'; + }); + + readonly isFormValid = computed(() => { + return this.questionForm?.valid ?? false; + }); + + ngOnInit(): void { + // Initialize form + this.initializeForm(); + + // Load categories + this.categoryService.getCategories().subscribe(); + + // Check if we're in edit mode + this.route.params + .pipe(takeUntilDestroyed()) + .subscribe(params => { + const id = params['id']; + if (id) { + this.isEditMode.set(true); + this.questionId.set(id); + this.loadQuestion(id); + } + }); + + // Watch for question type changes + this.questionForm.get('questionType')?.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((type: QuestionType) => { + this.onQuestionTypeChange(type); + }); + } + + /** + * Load existing question data + */ + private loadQuestion(id: string): void { + this.isLoadingQuestion.set(true); + + this.adminService.getQuestion(id) + .pipe(takeUntilDestroyed()) + .subscribe({ + next: (response) => { + this.isLoadingQuestion.set(false); + this.populateForm(response.data); + }, + error: (error) => { + this.isLoadingQuestion.set(false); + console.error('Error loading question:', error); + // Redirect back if question not found + this.router.navigate(['/admin/questions']); + } + }); + } + + /** + * Populate form with existing question data + */ + private populateForm(question: Question): void { + // Clear existing options + this.optionsArray.clear(); + + // Populate basic fields + this.questionForm.patchValue({ + questionText: question.questionText, + questionType: question.questionType, + categoryId: question.categoryId, + difficulty: question.difficulty, + correctAnswer: Array.isArray(question.correctAnswer) ? question.correctAnswer[0] : question.correctAnswer, + explanation: question.explanation, + points: question.points, + tags: question.tags || [], + isPublic: question.isPublic, + isGuestAccessible: question.isPublic // Map isPublic to isGuestAccessible + }); + + // Populate options for multiple choice + if (question.questionType === 'multiple_choice' && question.options) { + question.options.forEach((option: string) => { + this.optionsArray.push(this.createOption(option)); + }); + } + + // Trigger question type change to update form state + this.onQuestionTypeChange(question.questionType); + } + + /** + * Initialize form with all fields + */ + private initializeForm(): void { + this.questionForm = this.fb.group({ + questionText: ['', [Validators.required, Validators.minLength(10)]], + questionType: ['multiple_choice', Validators.required], + categoryId: ['', Validators.required], + difficulty: ['medium', Validators.required], + options: this.fb.array([ + this.createOption(''), + this.createOption(''), + this.createOption(''), + this.createOption('') + ]), + correctAnswer: ['', Validators.required], + explanation: ['', [Validators.required, Validators.minLength(10)]], + points: [10, [Validators.required, Validators.min(1), Validators.max(100)]], + tags: [[] as string[]], + isPublic: [true], + isGuestAccessible: [false] + }); + + // Add custom validator for correct answer + this.questionForm.setValidators(this.correctAnswerValidator.bind(this)); + } + + /** + * Create option form control + */ + private createOption(value: string = ''): FormGroup { + return this.fb.group({ + text: [value, Validators.required] + }); + } + + /** + * Get options form array + */ + get optionsArray(): FormArray { + return this.questionForm.get('options') as FormArray; + } + + /** + * Get tags array + */ + get tagsArray(): string[] { + return this.questionForm.get('tags')?.value || []; + } + + /** + * Handle question type change + */ + private onQuestionTypeChange(type: QuestionType): void { + const correctAnswerControl = this.questionForm.get('correctAnswer'); + + if (type === 'multiple_choice') { + // Ensure at least 2 options + while (this.optionsArray.length < 2) { + this.addOption(); + } + correctAnswerControl?.setValidators([Validators.required]); + } else if (type === 'true_false') { + // Clear options for True/False + this.optionsArray.clear(); + correctAnswerControl?.setValidators([Validators.required]); + // Set default to True if empty + if (!correctAnswerControl?.value) { + correctAnswerControl?.setValue('true'); + } + } else { + // Written answer + this.optionsArray.clear(); + correctAnswerControl?.setValidators([Validators.required, Validators.minLength(1)]); + } + + correctAnswerControl?.updateValueAndValidity(); + this.questionForm.updateValueAndValidity(); + } + + /** + * Add new option + */ + addOption(): void { + if (this.optionsArray.length < 10) { + this.optionsArray.push(this.createOption('')); + } + } + + /** + * Remove option at index + */ + removeOption(index: number): void { + if (this.optionsArray.length > 2) { + this.optionsArray.removeAt(index); + + // Clear correct answer if it matches the removed option + const correctAnswer = this.questionForm.get('correctAnswer')?.value; + const removedOption = this.optionsArray.at(index)?.get('text')?.value; + if (correctAnswer === removedOption) { + this.questionForm.get('correctAnswer')?.setValue(''); + } + } + } + + /** + * Add tag + */ + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + const tags = this.tagsArray; + + if (value && !tags.includes(value)) { + this.questionForm.get('tags')?.setValue([...tags, value]); + } + + event.chipInput!.clear(); + } + + /** + * Remove tag + */ + removeTag(tag: string): void { + const tags = this.tagsArray; + const index = tags.indexOf(tag); + + if (index >= 0) { + tags.splice(index, 1); + this.questionForm.get('tags')?.setValue([...tags]); + } + } + + /** + * Custom validator for correct answer + */ + private correctAnswerValidator(control: AbstractControl): ValidationErrors | null { + const formGroup = control as FormGroup; + const questionType = formGroup.get('questionType')?.value; + const correctAnswer = formGroup.get('correctAnswer')?.value; + const options = formGroup.get('options') as FormArray; + + if (questionType === 'multiple_choice' && correctAnswer && options) { + const optionTexts = options.controls.map(opt => opt.get('text')?.value); + const isValid = optionTexts.includes(correctAnswer); + + if (!isValid) { + return { correctAnswerMismatch: true }; + } + } + + return null; + } + + /** + * Get option text values + */ + getOptionTexts(): string[] { + return this.optionsArray.controls.map(opt => opt.get('text')?.value).filter(text => text.trim() !== ''); + } + + /** + * Submit form + */ + onSubmit(): void { + if (this.questionForm.invalid || this.isSubmitting()) { + this.markFormGroupTouched(this.questionForm); + return; + } + + this.isSubmitting.set(true); + + const formValue = this.questionForm.value; + const questionData: QuestionFormData = { + questionText: formValue.questionText, + questionType: formValue.questionType, + difficulty: formValue.difficulty, + categoryId: formValue.categoryId, + correctAnswer: formValue.correctAnswer, + explanation: formValue.explanation, + points: formValue.points || 10, + tags: formValue.tags || [], + isPublic: formValue.isPublic, + isGuestAccessible: formValue.isGuestAccessible + }; + + // Add options for multiple choice + if (formValue.questionType === 'multiple_choice') { + questionData.options = this.getOptionTexts(); + } + + // Determine if create or update + const serviceCall = this.isEditMode() && this.questionId() + ? this.adminService.updateQuestion(this.questionId()!, questionData) + : this.adminService.createQuestion(questionData); + + serviceCall + .pipe(takeUntilDestroyed()) + .subscribe({ + next: (response) => { + this.isSubmitting.set(false); + this.router.navigate(['/admin/questions']); + }, + error: (error) => { + this.isSubmitting.set(false); + console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error); + } + }); + } + + /** + * Cancel and go back + */ + onCancel(): void { + this.router.navigate(['/admin/questions']); + } + + /** + * Mark all fields as touched to show validation errors + */ + private markFormGroupTouched(formGroup: FormGroup | FormArray): void { + Object.keys(formGroup.controls).forEach(key => { + const control = formGroup.get(key); + control?.markAsTouched(); + + if (control instanceof FormGroup || control instanceof FormArray) { + this.markFormGroupTouched(control); + } + }); + } + + /** + * Get error message for field + */ + getErrorMessage(fieldName: string): string { + const control = this.questionForm.get(fieldName); + + if (!control || !control.errors || !control.touched) { + return ''; + } + + if (control.errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (control.errors['minlength']) { + return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['minlength'].requiredLength} characters`; + } + if (control.errors['min']) { + return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['min'].min}`; + } + if (control.errors['max']) { + return `${this.getFieldLabel(fieldName)} must be at most ${control.errors['max'].max}`; + } + + return ''; + } + + /** + * Get field label for error messages + */ + private getFieldLabel(fieldName: string): string { + const labels: Record = { + questionText: 'Question text', + questionType: 'Question type', + categoryId: 'Category', + difficulty: 'Difficulty', + correctAnswer: 'Correct answer', + explanation: 'Explanation', + points: 'Points' + }; + return labels[fieldName] || fieldName; + } + + /** + * Get form-level error message + */ + getFormError(): string | null { + if (this.questionForm.errors?.['correctAnswerMismatch']) { + return 'Correct answer must match one of the options'; + } + return null; + } +} diff --git a/frontend/src/app/features/admin/admin-questions/admin-questions.component.html b/frontend/src/app/features/admin/admin-questions/admin-questions.component.html new file mode 100644 index 0000000..35b8ee1 --- /dev/null +++ b/frontend/src/app/features/admin/admin-questions/admin-questions.component.html @@ -0,0 +1,226 @@ +
+ + + + + + +
+ + + Search Questions + + search + + + + + Category + + All Categories + @for (category of categories(); track category.id) { + {{ category.name }} + } + + + + + + Difficulty + + All Difficulties + Easy + Medium + Hard + + + + + + Type + + All Types + Multiple Choice + True/False + Written + + + + + + Sort By + + Date Created + Question Text + Difficulty + Points + + + + + + Order + + Ascending + Descending + + +
+
+
+ + + + + @if (isLoading()) { +
+ +

Loading questions...

+
+ } + + + @else if (error()) { +
+ error +

{{ error() }}

+ +
+ } + + + @else if (questions().length === 0) { +
+ quiz +

No Questions Found

+

No questions match your current filters. Try adjusting your search criteria.

+ +
+ } + + + @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Question +
+ {{ question.questionText.substring(0, 100) }}{{ question.questionText.length > 100 ? '...' : '' }} +
+
Type + + @if (question.questionType === 'multiple_choice') { + radio_button_checked + MCQ + } @else if (question.questionType === 'true_false') { + check_circle + T/F + } @else { + edit_note + Written + } + + Category + {{ getCategoryName(question.categoryId) }} + Difficulty + + {{ question.difficulty }} + + Points + {{ question.points }} + Status + + {{ question.isActive ? 'Active' : 'Inactive' }} + + Actions +
+ + +
+
+
+ + + + + } +
+
diff --git a/frontend/src/app/features/admin/admin-questions/admin-questions.component.scss b/frontend/src/app/features/admin/admin-questions/admin-questions.component.scss new file mode 100644 index 0000000..97c5d3b --- /dev/null +++ b/frontend/src/app/features/admin/admin-questions/admin-questions.component.scss @@ -0,0 +1,341 @@ +.admin-questions-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + + @media (max-width: 768px) { + padding: 1rem; + } +} + +// Page Header +.page-header { + margin-bottom: 2rem; + + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + } + } + + .title-section { + display: flex; + align-items: center; + gap: 1rem; + + .header-icon { + font-size: 2.5rem; + width: 2.5rem; + height: 2.5rem; + color: var(--primary-color); + } + + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: var(--text-primary); + } + + .subtitle { + margin: 0.25rem 0 0 0; + font-size: 0.95rem; + color: var(--text-secondary); + } + } + + button { + height: 42px; + padding: 0 1.5rem; + + @media (max-width: 768px) { + width: 100%; + } + + mat-icon { + margin-right: 0.5rem; + } + } +} + +// Filters Card +.filters-card { + margin-bottom: 1.5rem; + + .filters-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + + .search-field { + grid-column: span 2; + + @media (max-width: 768px) { + grid-column: span 1; + } + } + + mat-form-field { + width: 100%; + } + } +} + +// Results Card +.results-card { + min-height: 400px; +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + margin: 0; + font-size: 1rem; + color: var(--text-secondary); + } +} + +// Error State +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1rem; + + mat-icon { + font-size: 4rem; + width: 4rem; + height: 4rem; + } + + p { + margin: 0; + font-size: 1rem; + color: var(--text-secondary); + text-align: center; + } +} + +// Empty State +.empty-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1rem; + + mat-icon { + font-size: 5rem; + width: 5rem; + height: 5rem; + color: var(--text-disabled); + } + + h3 { + margin: 0; + font-size: 1.5rem; + font-weight: 500; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 1rem; + color: var(--text-secondary); + text-align: center; + max-width: 500px; + } + + button { + margin-top: 1rem; + } +} + +// Questions Table +.table-container { + overflow-x: auto; + + @media (max-width: 768px) { + margin: -1rem; + padding: 1rem; + } +} + +.questions-table { + width: 100%; + background: transparent; + + th { + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + color: var(--text-secondary); + padding: 1rem; + } + + td { + padding: 1rem; + color: var(--text-primary); + } + + tr { + border-bottom: 1px solid var(--divider-color); + + &:hover { + background-color: var(--hover-background); + } + } + + .question-text-cell { + max-width: 400px; + line-height: 1.5; + } + + mat-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + } + + .points-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 1.5rem; + padding: 0 0.5rem; + background-color: var(--primary-color); + color: white; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + } + + .action-buttons { + display: flex; + gap: 0.25rem; + + button { + width: 36px; + height: 36px; + + mat-icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + } + } + } +} + +// Pagination +.pagination-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-top: 1px solid var(--divider-color); + margin-top: 1rem; + + @media (max-width: 768px) { + flex-direction: column; + gap: 1rem; + } + + .pagination-info { + font-size: 0.875rem; + color: var(--text-secondary); + } + + .pagination-controls { + display: flex; + align-items: center; + gap: 0.25rem; + + @media (max-width: 768px) { + flex-wrap: wrap; + justify-content: center; + } + + button { + min-width: 40px; + height: 40px; + + &.active { + background-color: var(--primary-color); + color: white; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .ellipsis { + padding: 0 0.5rem; + color: var(--text-secondary); + } + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .admin-questions-container { + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-disabled: #606060; + --divider-color: #404040; + --hover-background: rgba(255, 255, 255, 0.05); + } + + .questions-table { + tr:hover { + background-color: rgba(255, 255, 255, 0.05); + } + } +} + +// Light Mode Support +@media (prefers-color-scheme: light) { + .admin-questions-container { + --text-primary: #212121; + --text-secondary: #757575; + --text-disabled: #bdbdbd; + --divider-color: #e0e0e0; + --hover-background: rgba(0, 0, 0, 0.04); + } + + .questions-table { + tr:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } +} diff --git a/frontend/src/app/features/admin/admin-questions/admin-questions.component.ts b/frontend/src/app/features/admin/admin-questions/admin-questions.component.ts new file mode 100644 index 0000000..7ab9711 --- /dev/null +++ b/frontend/src/app/features/admin/admin-questions/admin-questions.component.ts @@ -0,0 +1,304 @@ +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators'; +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 { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { AdminService } from '../../../core/services/admin.service'; +import { CategoryService } from '../../../core/services/category.service'; +import { Question } from '../../../core/models/question.model'; +import { Category } from '../../../core/models/category.model'; +import { DeleteConfirmDialogComponent } from '../delete-confirm-dialog/delete-confirm-dialog.component'; +import { PaginationService, PaginationState } from '../../../core/services/pagination.service'; +import { PaginationComponent } from '../../../shared/components/pagination/pagination.component'; + +/** + * AdminQuestionsComponent + * + * Displays and manages all questions with pagination, filtering, and sorting. + * + * Features: + * - Question table with key columns + * - Search by question text + * - Filter by category, difficulty, and type + * - Sort by various fields + * - Pagination controls + * - Action buttons (Edit, Delete, View) + * - Delete confirmation dialog + * - Responsive design (cards on mobile) + * - Loading and error states + */ +@Component({ + selector: 'app-admin-questions', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatTableModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatChipsModule, + MatMenuModule, + MatDialogModule, + PaginationComponent + ], + templateUrl: './admin-questions.component.html', + styleUrl: './admin-questions.component.scss' +}) +export class AdminQuestionsComponent implements OnInit { + private readonly adminService = inject(AdminService); + private readonly categoryService = inject(CategoryService); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly dialog = inject(MatDialog); + private readonly paginationService = inject(PaginationService); + + // State signals + readonly questions = signal([]); + readonly isLoading = signal(false); + readonly error = signal(null); + readonly categories = this.categoryService.categories; + + // Pagination + readonly currentPage = signal(1); + readonly pageSize = signal(10); + readonly totalQuestions = signal(0); + readonly totalPages = computed(() => Math.ceil(this.totalQuestions() / this.pageSize())); + + // Computed pagination state for reusable component + readonly paginationState = computed(() => { + return this.paginationService.calculatePaginationState({ + currentPage: this.currentPage(), + pageSize: this.pageSize(), + totalItems: this.totalQuestions() + }); + }); + + // Computed page numbers + readonly pageNumbers = computed(() => { + return this.paginationService.calculatePageNumbers( + this.currentPage(), + this.totalPages(), + 5 + ); + }); + + // Table configuration + displayedColumns: string[] = ['questionText', 'type', 'category', 'difficulty', 'points', 'status', 'actions']; + + // Filter form + filterForm!: FormGroup; + + // Expose Math for template + Math = Math; + + ngOnInit(): void { + this.initializeFilterForm(); + this.setupSearchDebounce(); + this.loadCategories(); + this.loadQuestions(); + } + + /** + * Initialize filter form + */ + private initializeFilterForm(): void { + this.filterForm = this.fb.group({ + search: [''], + category: ['all'], + difficulty: ['all'], + type: ['all'], + sortBy: ['createdAt'], + sortOrder: ['desc'] + }); + + // Subscribe to filter changes (except search which is debounced) + this.filterForm.valueChanges + .pipe( + debounceTime(300), + distinctUntilChanged() + ) + .subscribe(() => { + this.currentPage.set(1); + this.loadQuestions(); + }); + } + + /** + * Setup search field debounce + */ + private setupSearchDebounce(): void { + this.filterForm.get('search')?.valueChanges + .pipe( + debounceTime(500), + distinctUntilChanged() + ) + .subscribe(() => { + this.currentPage.set(1); + this.loadQuestions(); + }); + } + + /** + * Load categories for filter dropdown + */ + private loadCategories(): void { + if (this.categories().length === 0) { + this.categoryService.getCategories().subscribe(); + } + } + + /** + * Load questions with current filters + */ + loadQuestions(): void { + this.isLoading.set(true); + this.error.set(null); + + const filters = this.filterForm.value; + const params: any = { + page: this.currentPage(), + limit: this.pageSize(), + search: filters.search || undefined, + categoryId: filters.category !== 'all' ? filters.category : undefined, + difficulty: filters.difficulty !== 'all' ? filters.difficulty : undefined, + questionType: filters.type !== 'all' ? filters.type : undefined, + sortBy: filters.sortBy, + sortOrder: filters.sortOrder + }; + + // Remove undefined values + Object.keys(params).forEach(key => params[key] === undefined && delete params[key]); + + // TODO: Replace with actual API call when available + // For now, using mock data + setTimeout(() => { + this.questions.set([]); + this.totalQuestions.set(0); + this.isLoading.set(false); + }, 500); + } + + /** + * Navigate to create question page + */ + createQuestion(): void { + this.router.navigate(['/admin/questions/new']); + } + + /** + * Navigate to edit question page + */ + editQuestion(question: Question): void { + this.router.navigate(['/admin/questions', question.id, 'edit']); + } + + /** + * Open delete confirmation dialog + */ + deleteQuestion(question: Question): void { + const dialogRef = this.dialog.open(DeleteConfirmDialogComponent, { + width: '500px', + data: { + title: 'Delete Question', + message: 'Are you sure you want to delete this question? This action cannot be undone.', + itemName: question.questionText.substring(0, 100) + (question.questionText.length > 100 ? '...' : ''), + confirmText: 'Delete', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(confirmed => { + if (confirmed && question.id) { + this.performDelete(question.id); + } + }); + } + + /** + * Perform delete operation + */ + private performDelete(id: string): void { + this.isLoading.set(true); + + this.adminService.deleteQuestion(id) + .pipe(finalize(() => this.isLoading.set(false))) + .subscribe({ + next: () => { + // Reload questions after deletion + this.loadQuestions(); + }, + error: (error) => { + this.error.set('Failed to delete question'); + console.error('Delete error:', error); + } + }); + } + + /** + * Get category name by ID + */ + getCategoryName(categoryId: string | number): string { + const category = this.categories().find(c => c.id === categoryId || c.id === categoryId.toString()); + return category?.name || 'Unknown'; + } + + /** + * Get status chip color + */ + getStatusColor(isActive: boolean): string { + return isActive ? 'primary' : 'warn'; + } + + /** + * Get difficulty chip color + */ + getDifficultyColor(difficulty: string): string { + switch (difficulty.toLowerCase()) { + case 'easy': + return 'primary'; + case 'medium': + return 'accent'; + case 'hard': + return 'warn'; + default: + return ''; + } + } + + /** + * Go to specific page + */ + goToPage(page: number): void { + if (page >= 1 && page <= this.totalPages()) { + this.currentPage.set(page); + this.loadQuestions(); + } + } + + /** + * Handle page size change + */ + onPageSizeChange(pageSize: number): void { + this.pageSize.set(pageSize); + this.currentPage.set(1); // Reset to first page + this.loadQuestions(); + } +} diff --git a/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.html b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.html new file mode 100644 index 0000000..284a25c --- /dev/null +++ b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.html @@ -0,0 +1,329 @@ +
+ + + + + + + + @if (isLoading()) { +
+ +

Loading user details...

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

Error Loading User

+

{{ error() }}

+ +
+
+
+ } + + + @if (user() && !isLoading()) { +
+ + + +
+
+ account_circle +
+ +
+
+ +
+
+ event +
+ Member Since + {{ memberSince() }} +
+
+
+ schedule +
+ Last Active + {{ lastActive() }} +
+
+ @if (user()!.metadata?.registrationMethod) { +
+ how_to_reg +
+ Registration Method + {{ user()!.metadata!.registrationMethod === 'guest_conversion' ? 'Guest Conversion' : 'Direct' }} +
+
+ } +
+
+ + + + +
+ + +
+ + +
+ quiz +
+
+

{{ formatNumber(user()!.statistics.totalQuizzes) }}

+

Total Quizzes

+
+
+
+ + + +
+ grade +
+
+

{{ user()!.statistics.averageScore.toFixed(1) }}%

+

Average Score

+
+
+
+ + + +
+ check_circle +
+
+

{{ user()!.statistics.accuracy.toFixed(1) }}%

+

Accuracy

+
+
+
+ + + +
+ local_fire_department +
+
+

{{ user()!.statistics.currentStreak }}

+

Current Streak

+
+
+
+ + + +
+ help_outline +
+
+

{{ formatNumber(user()!.statistics.totalQuestionsAnswered) }}

+

Questions Answered

+
+
+
+ + + +
+ timer +
+
+

{{ formatDuration(user()!.statistics.totalTimeSpent) }}

+

Time Spent

+
+
+
+
+ + + + + + analytics + Additional Statistics + + + +
+
+ Correct Answers: + {{ formatNumber(user()!.statistics.correctAnswers) }} +
+
+ Longest Streak: + {{ user()!.statistics.longestStreak }} days +
+ @if (user()!.statistics.favoriteCategory) { +
+ Favorite Category: + + {{ user()!.statistics.favoriteCategory!.name }} + ({{ user()!.statistics.favoriteCategory!.quizCount }} quizzes) + +
+ } +
+ Quizzes This Week: + {{ user()!.statistics.recentActivity.quizzesThisWeek }} +
+
+ Quizzes This Month: + {{ user()!.statistics.recentActivity.quizzesThisMonth }} +
+
+
+
+ + + + + + history + Quiz History + + + + @if (hasQuizHistory()) { +
+ @for (quiz of user()!.quizHistory; track quiz.id) { +
+
+
+ category + {{ quiz.categoryName }} +
+
{{ formatDateTime(quiz.completedAt) }}
+
+
+
+ grade + Score: + + {{ quiz.score }}/{{ quiz.totalQuestions }} ({{ quiz.percentage.toFixed(1) }}%) + +
+
+ timer + Time: + {{ formatDuration(quiz.timeTaken) }} +
+ +
+
+ } +
+ } @else { +
+ quiz +

No quiz history available

+
+ } +
+
+ + + + + + timeline + Activity Timeline + + + + @if (hasActivity()) { + + @for (activity of user()!.activityTimeline; track activity.id) { + + + {{ getActivityIcon(activity.type) }} + +
{{ activity.description }}
+
{{ formatRelativeTime(activity.timestamp) }}
+ @if (activity.metadata) { + + } +
+ + } +
+ } @else { +
+ timeline +

No activity recorded

+
+ } +
+
+
+ } +
diff --git a/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.scss b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.scss new file mode 100644 index 0000000..0b5046e --- /dev/null +++ b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.scss @@ -0,0 +1,752 @@ +.admin-user-detail-container { + padding: 24px; + max-width: 1400px; + margin: 0 auto; +} + +// =========================== +// Page Header +// =========================== +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + .header-left { + display: flex; + align-items: center; + gap: 12px; + } + + .back-button { + color: var(--primary-color); + } + + .page-title { + margin: 0; + font-size: 28px; + font-weight: 600; + color: var(--text-primary); + } + + .header-actions { + display: flex; + gap: 8px; + } +} + +// =========================== +// Breadcrumb +// =========================== +.breadcrumb { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + font-size: 14px; + + .breadcrumb-link { + color: var(--primary-color); + text-decoration: none; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + text-decoration: underline; + } + } + + .breadcrumb-separator { + font-size: 18px; + color: var(--text-secondary); + } + + .breadcrumb-current { + color: var(--text-primary); + font-weight: 500; + } +} + +// =========================== +// Loading State +// =========================== +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + gap: 16px; + + .loading-text { + color: var(--text-secondary); + font-size: 16px; + } +} + +// =========================== +// Error State +// =========================== +.error-card { + margin-top: 24px; + + .error-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px; + text-align: center; + + .error-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--error-color); + margin-bottom: 16px; + } + + h2 { + margin: 0 0 8px; + color: var(--text-primary); + } + + p { + margin: 0 0 24px; + color: var(--text-secondary); + } + + button { + mat-icon { + margin-right: 8px; + } + } + } +} + +// =========================== +// Detail Content +// =========================== +.detail-content { + display: grid; + gap: 24px; +} + +// =========================== +// Profile Card +// =========================== +.profile-card { + .profile-header { + display: flex; + align-items: center; + gap: 24px; + width: 100%; + + .user-avatar { + mat-icon { + font-size: 80px; + width: 80px; + height: 80px; + color: var(--primary-color); + } + } + + .user-info { + flex: 1; + + .user-name { + margin: 0 0 4px; + font-size: 28px; + font-weight: 600; + color: var(--text-primary); + } + + .user-email { + margin: 0 0 12px; + font-size: 16px; + color: var(--text-secondary); + } + + .user-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + + mat-chip { + display: inline-flex; + align-items: center; + gap: 4px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &.chip-primary { + background-color: var(--primary-light); + color: var(--primary-color); + } + + &.chip-warn { + background-color: var(--warn-light); + color: var(--warn-color); + } + + &.chip-success { + background-color: var(--success-light); + color: var(--success-color); + } + + &.chip-default { + background-color: var(--bg-secondary); + color: var(--text-secondary); + } + } + } + } + } + + .profile-details { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 16px; + + .detail-row { + display: flex; + align-items: center; + gap: 12px; + + > mat-icon { + color: var(--text-secondary); + } + + .detail-info { + display: flex; + flex-direction: column; + gap: 2px; + + .detail-label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .detail-value { + font-size: 16px; + color: var(--text-primary); + font-weight: 500; + } + } + } + } + + .profile-actions { + display: flex; + gap: 12px; + padding: 16px; + border-top: 1px solid var(--divider-color); + + button { + mat-icon { + margin-right: 8px; + } + } + } +} + +// =========================== +// Statistics Grid +// =========================== +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + + .stat-card { + mat-card-content { + display: flex; + align-items: center; + gap: 16px; + padding: 24px; + + .stat-icon { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: white; + } + + &.primary { + background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); + } + + &.success { + background: linear-gradient(135deg, var(--success-color), var(--success-dark)); + } + + &.accent { + background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)); + } + + &.warn { + background: linear-gradient(135deg, var(--warn-color), var(--warn-dark)); + } + } + + .stat-info { + flex: 1; + + .stat-value { + margin: 0 0 4px; + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + } + + .stat-label { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; + } + } + } + } +} + +// =========================== +// Additional Stats Card +// =========================== +.additional-stats-card { + mat-card-header { + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 600; + + mat-icon { + color: var(--primary-color); + } + } + } + + .stats-details { + display: flex; + flex-direction: column; + gap: 12px; + + .stat-detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: var(--bg-secondary); + border-radius: 8px; + + .stat-detail-label { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; + } + + .stat-detail-value { + font-size: 16px; + color: var(--text-primary); + font-weight: 600; + } + } + } +} + +// =========================== +// Quiz History Card +// =========================== +.quiz-history-card { + mat-card-header { + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 600; + + mat-icon { + color: var(--primary-color); + } + } + } + + .quiz-history-list { + display: flex; + flex-direction: column; + gap: 16px; + + .quiz-history-item { + padding: 16px; + border-radius: 8px; + background-color: var(--bg-secondary); + border: 1px solid var(--divider-color); + transition: transform 0.2s, box-shadow 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + .quiz-history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + .quiz-category { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--text-primary); + + mat-icon { + color: var(--primary-color); + } + } + + .quiz-date { + font-size: 14px; + color: var(--text-secondary); + } + } + + .quiz-history-stats { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + + .quiz-stat { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--text-secondary); + + &.score-icon-success { + color: var(--success-color); + } + + &.score-icon-primary { + color: var(--primary-color); + } + + &.score-icon-accent { + color: var(--accent-color); + } + + &.score-icon-warn { + color: var(--warn-color); + } + } + + .quiz-stat-label { + color: var(--text-secondary); + } + + .quiz-stat-value { + font-weight: 600; + color: var(--text-primary); + + &.quiz-stat-value-success { + color: var(--success-color); + } + + &.quiz-stat-value-primary { + color: var(--primary-color); + } + + &.quiz-stat-value-accent { + color: var(--accent-color); + } + + &.quiz-stat-value-warn { + color: var(--warn-color); + } + } + } + + .quiz-action-btn { + margin-left: auto; + color: var(--primary-color); + } + } + } + } +} + +// =========================== +// Activity Timeline Card +// =========================== +.activity-timeline-card { + mat-card-header { + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 600; + + mat-icon { + color: var(--primary-color); + } + } + } + + .activity-list { + padding: 0; + + .activity-item { + padding: 16px 0; + + .activity-description { + font-weight: 500; + color: var(--text-primary); + } + + .activity-time { + font-size: 14px; + color: var(--text-secondary); + margin-top: 4px; + } + + .activity-metadata { + display: flex; + gap: 16px; + margin-top: 8px; + + .metadata-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-secondary); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + } + + mat-icon[matListItemIcon] { + &.activity-icon-primary { + color: var(--primary-color); + } + + &.activity-icon-success { + color: var(--success-color); + } + + &.activity-icon-accent { + color: var(--accent-color); + } + + &.activity-icon-warn { + color: var(--warn-color); + } + + &.activity-icon-default { + color: var(--text-secondary); + } + } + } + } +} + +// =========================== +// Empty State +// =========================== +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--text-disabled); + margin-bottom: 16px; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 16px; + } +} + +// =========================== +// Responsive Design +// =========================== + +// Tablet (768px - 1023px) +@media (max-width: 1023px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .profile-card .profile-header { + .user-avatar mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + } + + .user-info .user-name { + font-size: 24px; + } + } +} + +// Mobile (< 768px) +@media (max-width: 767px) { + .admin-user-detail-container { + padding: 16px; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + + .header-actions { + width: 100%; + justify-content: flex-end; + } + } + + .breadcrumb { + flex-wrap: wrap; + font-size: 12px; + } + + .profile-card { + .profile-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + + .user-avatar mat-icon { + font-size: 56px; + width: 56px; + height: 56px; + } + + .user-info { + width: 100%; + + .user-name { + font-size: 20px; + } + + .user-email { + font-size: 14px; + } + } + } + + .profile-actions { + flex-direction: column; + + button { + width: 100%; + } + } + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-card mat-card-content { + padding: 16px; + + .stat-icon { + width: 48px; + height: 48px; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + + .stat-info { + .stat-value { + font-size: 24px; + } + + .stat-label { + font-size: 13px; + } + } + } + + .quiz-history-item { + .quiz-history-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .quiz-history-stats { + flex-direction: column; + align-items: flex-start; + gap: 12px; + + .quiz-action-btn { + margin-left: 0; + } + } + } +} + +// =========================== +// Dark Mode Support +// =========================== +@media (prefers-color-scheme: dark) { + .admin-user-detail-container { + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-disabled: #606060; + --bg-primary: #1e1e1e; + --bg-secondary: #2a2a2a; + --divider-color: #404040; + } + + .quiz-history-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } +} diff --git a/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.ts b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.ts new file mode 100644 index 0000000..f1f861f --- /dev/null +++ b/frontend/src/app/features/admin/admin-user-detail/admin-user-detail.component.ts @@ -0,0 +1,362 @@ +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, ActivatedRoute, 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 { MatListModule } from '@angular/material/list'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdminService } from '../../../core/services/admin.service'; +import { AdminUserDetail } from '../../../core/models/admin.model'; +import { RoleUpdateDialogComponent } from '../role-update-dialog/role-update-dialog.component'; +import { StatusUpdateDialogComponent } from '../status-update-dialog/status-update-dialog.component'; + +/** + * AdminUserDetailComponent + * + * Displays comprehensive user profile for admin management: + * - User information (username, email, role, status) + * - Statistics (quizzes, scores, accuracy, streaks) + * - Quiz history with detailed breakdown + * - Activity timeline showing all user actions + * - Action buttons (Edit Role, Deactivate/Activate) + * - Breadcrumb navigation + * + * Features: + * - Signal-based reactive state + * - Real-time loading states + * - Error handling with user feedback + * - Responsive design (desktop + mobile) + * - Formatted dates and numbers + * - Color-coded status indicators + */ +@Component({ + selector: 'app-admin-user-detail', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MatProgressSpinnerModule, + MatDividerModule, + MatListModule, + MatTooltipModule, + MatMenuModule, + MatDialogModule + ], + templateUrl: './admin-user-detail.component.html', + styleUrl: './admin-user-detail.component.scss' +}) +export class AdminUserDetailComponent implements OnInit { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly dialog = inject(MatDialog); + + // Expose Math for template + Math = Math; + + // State from service + readonly user = this.adminService.selectedUserDetail; + readonly isLoading = this.adminService.isLoadingUserDetail; + readonly error = this.adminService.userDetailError; + + // Component state + readonly userId = signal(''); + + // Computed properties + readonly hasQuizHistory = computed(() => { + const userDetail = this.user(); + return userDetail && userDetail.quizHistory.length > 0; + }); + + readonly hasActivity = computed(() => { + const userDetail = this.user(); + return userDetail && userDetail.activityTimeline.length > 0; + }); + + readonly memberSince = computed(() => { + const userDetail = this.user(); + if (!userDetail) return ''; + return this.formatDate(userDetail.createdAt); + }); + + readonly lastActive = computed(() => { + const userDetail = this.user(); + if (!userDetail || !userDetail.lastLoginAt) return 'Never'; + return this.formatRelativeTime(userDetail.lastLoginAt); + }); + + constructor() { + // Clean up user detail when component is destroyed + takeUntilDestroyed()(this.route.params); + } + + ngOnInit(): void { + // Get userId from route params + this.route.params.pipe(takeUntilDestroyed()).subscribe(params => { + const id = params['id']; + if (id) { + this.userId.set(id); + this.loadUserDetail(id); + } else { + this.router.navigate(['/admin/users']); + } + }); + } + + /** + * Load user detail from API + */ + private loadUserDetail(userId: string): void { + this.adminService.getUserDetails(userId).subscribe({ + error: () => { + // Error is handled by service + // Navigate back after 3 seconds + setTimeout(() => { + this.router.navigate(['/admin/users']); + }, 3000); + } + }); + } + + /** + * Navigate back to users list + */ + goBack(): void { + this.router.navigate(['/admin/users']); + } + + /** + * Refresh user details + */ + refreshUser(): void { + const id = this.userId(); + if (id) { + this.loadUserDetail(id); + } + } + + /** + * Edit user role - Opens role update dialog + */ + editUserRole(): void { + const userDetail = this.user(); + if (!userDetail) return; + + const dialogRef = this.dialog.open(RoleUpdateDialogComponent, { + width: '600px', + maxWidth: '95vw', + data: { user: userDetail }, + disableClose: false + }); + + dialogRef.afterClosed().subscribe(newRole => { + if (newRole && newRole !== userDetail.role) { + this.adminService.updateUserRole(userDetail.id, newRole).subscribe({ + next: () => { + // User detail is automatically updated in the service + this.refreshUser(); + }, + error: () => { + // Error is handled by service + } + }); + } + }); + } + + /** + * Toggle user active status + */ + toggleUserStatus(): void { + const userDetail = this.user(); + if (!userDetail) return; + + const action = userDetail.isActive ? 'deactivate' : 'activate'; + + // Convert AdminUserDetail to AdminUser for dialog + const dialogData = { + user: { + id: userDetail.id, + username: userDetail.username, + email: userDetail.email, + role: userDetail.role, + isActive: userDetail.isActive, + createdAt: userDetail.createdAt + }, + action: action as 'activate' | 'deactivate' + }; + + const dialogRef = this.dialog.open(StatusUpdateDialogComponent, { + width: '500px', + data: dialogData, + disableClose: false, + autoFocus: true + }); + + dialogRef.afterClosed() + .pipe(takeUntilDestroyed()) + .subscribe((confirmed: boolean) => { + if (!confirmed) return; + + // Call appropriate service method based on action + const serviceCall = action === 'activate' + ? this.adminService.activateUser(userDetail.id) + : this.adminService.deactivateUser(userDetail.id); + + serviceCall + .pipe(takeUntilDestroyed()) + .subscribe({ + next: () => { + // Refresh user detail to show updated status + this.loadUserDetail(userDetail.id); + }, + error: (error) => { + console.error('Error updating user status:', error); + } + }); + }); + } + + /** + * View quiz details (navigate to quiz review) + */ + viewQuizDetails(quizId: string): void { + // Navigate to quiz review page + this.router.navigate(['/quiz', quizId, 'review']); + } + + /** + * Get icon for activity type + */ + getActivityIcon(type: string): string { + const icons: Record = { + login: 'login', + quiz_start: 'play_arrow', + quiz_complete: 'check_circle', + bookmark: 'bookmark', + profile_update: 'edit', + role_change: 'admin_panel_settings' + }; + return icons[type] || 'info'; + } + + /** + * Get color for activity type + */ + getActivityColor(type: string): string { + const colors: Record = { + login: 'primary', + quiz_start: 'accent', + quiz_complete: 'success', + bookmark: 'warn', + profile_update: 'primary', + role_change: 'warn' + }; + return colors[type] || 'default'; + } + + /** + * Get role badge color + */ + getRoleColor(role: string): string { + return role === 'admin' ? 'warn' : 'primary'; + } + + /** + * Get status badge color + */ + getStatusColor(isActive: boolean): string { + return isActive ? 'success' : 'default'; + } + + /** + * Format date to readable string + */ + formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + /** + * Format date and time + */ + formatDateTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + /** + * Format relative time (e.g., "2 hours ago") + */ + formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`; + return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) > 1 ? 's' : ''} ago`; + } + + /** + * Format time duration in seconds to readable string + */ + formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + } + + /** + * Format large numbers with commas + */ + formatNumber(num: number): string { + return num.toLocaleString('en-US'); + } + + /** + * Get score color based on percentage + */ + getScoreColor(percentage: number): string { + if (percentage >= 80) return 'success'; + if (percentage >= 60) return 'primary'; + if (percentage >= 40) return 'accent'; + return 'warn'; + } +} diff --git a/frontend/src/app/features/admin/admin-users/admin-users.component.html b/frontend/src/app/features/admin/admin-users/admin-users.component.html new file mode 100644 index 0000000..a41a3c3 --- /dev/null +++ b/frontend/src/app/features/admin/admin-users/admin-users.component.html @@ -0,0 +1,283 @@ +
+ +
+
+ +
+

User Management

+

Manage all users and their permissions

+
+
+
+ +
+
+ + + + +
+ + + Search + + search + + + + + Role + + All Roles + User + Admin + + badge + + + + + Status + + All Status + Active + Inactive + + toggle_on + + + + + Sort By + + Username + Email + Join Date + Last Login + + sort + + + + + Order + + Ascending + Descending + + swap_vert + + + + +
+
+
+ + + @if (isLoading() && users().length === 0) { +
+ +

Loading users...

+
+ } + + + @if (error() && !isLoading() && users().length === 0) { + + +
+ error_outline +
+

Failed to Load Users

+

{{ error() }}

+
+
+ +
+
+ } + + + @if (users().length > 0) { + +
+

Users

+ @if (pagination()) { + + Total: {{ pagination()?.totalUsers }} user{{ pagination()?.totalUsers !== 1 ? 's' : '' }} + + } +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Username +
+ account_circle + {{ user.username }} +
+
Email{{ user.email }}Role + + {{ user.role | uppercase }} + + Status + + {{ getStatusText(user.isActive) }} + + Joined{{ formatDate(user.createdAt) }}Last Login{{ formatDateTime(user.lastLoginAt) }}Actions + + + + + + +
+
+
+ + +
+ @for (user of users(); track user.id) { + + + account_circle + {{ user.username }} + {{ user.email }} + + +
+
+ Role: + + {{ user.role | uppercase }} + +
+
+ Status: + + {{ getStatusText(user.isActive) }} + +
+
+ Joined: + {{ formatDate(user.createdAt) }} +
+
+ Last Login: + {{ formatDateTime(user.lastLoginAt) }} +
+
+
+ + + + + +
+ } +
+ + + @if (paginationState()) { + + + } + } + + + @if (!isLoading() && !error() && users().length === 0) { + + + people_outline +

No Users Found

+

No users match your current filters.

+ +
+
+ } +
diff --git a/frontend/src/app/features/admin/admin-users/admin-users.component.scss b/frontend/src/app/features/admin/admin-users/admin-users.component.scss new file mode 100644 index 0000000..7013de4 --- /dev/null +++ b/frontend/src/app/features/admin/admin-users/admin-users.component.scss @@ -0,0 +1,466 @@ +.admin-users-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +// Header Section +.users-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + gap: 1rem; + flex-wrap: wrap; + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + + .header-title { + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: #333; + } + + .subtitle { + margin: 0.25rem 0 0 0; + color: #666; + font-size: 0.95rem; + } + } + } + + .header-actions { + display: flex; + gap: 0.75rem; + + button mat-icon { + margin-right: 0.5rem; + } + } +} + +// Filters Card +.filters-card { + margin-bottom: 2rem; + + .filters-form { + display: grid; + grid-template-columns: 2fr repeat(4, 1fr) auto; + gap: 1rem; + align-items: start; + + .search-field { + grid-column: 1; + } + + mat-form-field { + width: 100%; + + mat-icon[matPrefix] { + margin-right: 0.5rem; + color: #666; + } + } + + button { + margin-top: 0.5rem; + } + } +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + color: #666; + font-size: 1rem; + } +} + +// Error State +.error-card { + margin-bottom: 2rem; + + .error-content { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + + mat-icon { + font-size: 3rem; + width: 3rem; + height: 3rem; + } + + .error-text { + flex: 1; + + h3 { + margin: 0 0 0.5rem 0; + color: #d32f2f; + font-size: 1.25rem; + } + + p { + margin: 0; + color: #666; + } + } + } +} + +// Table Card +.table-card { + margin-bottom: 2rem; + + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e0e0e0; + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + } + + .total-count { + color: #666; + font-size: 0.9rem; + } + } + + .table-container { + overflow-x: auto; + } + + .users-table { + width: 100%; + + th { + font-weight: 600; + color: #333; + background: #f5f5f5; + } + + td, th { + padding: 1rem; + } + + .username-cell { + display: flex; + align-items: center; + gap: 0.5rem; + + .user-icon { + color: #666; + font-size: 24px; + width: 24px; + height: 24px; + } + } + + mat-chip { + font-size: 0.75rem; + min-height: 24px; + padding: 0 0.5rem; + } + + tr:hover { + background: #f9f9f9; + } + } +} + +// Mobile Cards +.mobile-cards { + display: none; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + + .user-card { + mat-card-header { + margin-bottom: 1rem; + + .card-avatar { + font-size: 40px; + width: 40px; + height: 40px; + color: #666; + } + } + + .card-info { + display: flex; + flex-direction: column; + gap: 0.75rem; + + .info-row { + display: flex; + justify-content: space-between; + align-items: center; + + .label { + font-weight: 500; + color: #666; + } + } + } + + mat-card-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid #e0e0e0; + + button { + flex: 1; + + mat-icon { + margin-right: 0.25rem; + font-size: 18px; + width: 18px; + height: 18px; + } + } + } + } +} + +// Pagination +.pagination-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + .pagination-info { + color: #666; + font-size: 0.9rem; + } + + .pagination-controls { + display: flex; + gap: 0.25rem; + align-items: center; + + button { + &.active { + background: #3f51b5; + color: white; + } + } + } +} + +// Empty State +.empty-card { + text-align: center; + padding: 4rem 2rem; + + mat-card-content { + mat-icon { + font-size: 80px; + width: 80px; + height: 80px; + color: #999; + margin-bottom: 1rem; + } + + h3 { + margin: 0 0 0.5rem 0; + color: #333; + font-size: 1.5rem; + } + + p { + margin: 0 0 1.5rem 0; + color: #666; + } + } +} + +// Responsive Design +@media (max-width: 768px) { + .admin-users-container { + padding: 1rem; + } + + .users-header { + flex-direction: column; + align-items: stretch; + + .header-left { + flex-direction: column; + align-items: flex-start; + + .header-title h1 { + font-size: 1.5rem; + } + } + + .header-actions { + width: 100%; + + button { + flex: 1; + } + } + } + + .filters-card .filters-form { + grid-template-columns: 1fr; + + .search-field { + grid-column: 1; + } + + button { + width: 100%; + } + } + + .desktop-table { + display: none; + } + + .mobile-cards { + display: flex; + } + + .pagination-container { + flex-direction: column; + gap: 1rem; + + .pagination-info { + text-align: center; + } + + .pagination-controls { + flex-wrap: wrap; + justify-content: center; + } + } +} + +@media (max-width: 1024px) and (min-width: 769px) { + .filters-card .filters-form { + grid-template-columns: 1fr 1fr; + + .search-field { + grid-column: 1 / -1; + } + + button { + grid-column: 1 / -1; + } + } + + .users-table { + font-size: 0.9rem; + + td, th { + padding: 0.75rem; + } + } +} + +@media (max-width: 1200px) { + .users-table { + th:nth-child(6), // Last Login + td:nth-child(6) { + display: none; + } + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .users-header .header-title h1 { + color: #fff; + } + + .users-header .subtitle { + color: #aaa; + } + + .loading-container p { + color: #aaa; + } + + .error-card .error-content .error-text p { + color: #aaa; + } + + .table-card { + .table-header { + border-bottom-color: #444; + + h2 { + color: #fff; + } + + .total-count { + color: #aaa; + } + } + + .users-table { + th { + background: #2a2a2a; + color: #fff; + } + + tr:hover { + background: #2a2a2a; + } + } + } + + .mobile-cards .user-card { + mat-card-actions { + border-top-color: #444; + } + } + + .pagination-container { + background: #1a1a1a; + + .pagination-info { + color: #aaa; + } + } + + .empty-card mat-card-content { + mat-icon { + color: #666; + } + + h3 { + color: #fff; + } + + p { + color: #aaa; + } + } +} diff --git a/frontend/src/app/features/admin/admin-users/admin-users.component.ts b/frontend/src/app/features/admin/admin-users/admin-users.component.ts new file mode 100644 index 0000000..e5fd93f --- /dev/null +++ b/frontend/src/app/features/admin/admin-users/admin-users.component.ts @@ -0,0 +1,400 @@ +import { Component, OnInit, inject, DestroyRef, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +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 { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { AdminService } from '../../../core/services/admin.service'; +import { AdminUser, UserListParams } from '../../../core/models/admin.model'; +import { RoleUpdateDialogComponent } from '../role-update-dialog/role-update-dialog.component'; +import { StatusUpdateDialogComponent } from '../status-update-dialog/status-update-dialog.component'; +import { PaginationService, PaginationState } from '../../../core/services/pagination.service'; +import { PaginationComponent } from '../../../shared/components/pagination/pagination.component'; + +/** + * AdminUsersComponent + * + * Displays and manages all users with pagination, filtering, and sorting. + * + * Features: + * - User table with key columns + * - Search by username/email + * - Filter by role and status + * - Sort by username, email, or date + * - Pagination controls + * - Action buttons for each user + * - Responsive design (cards on mobile) + * - Loading and error states + */ +@Component({ + selector: 'app-admin-users', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatTableModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatChipsModule, + MatMenuModule, + MatDialogModule, + PaginationComponent + ], + templateUrl: './admin-users.component.html', + styleUrl: './admin-users.component.scss' +}) +export class AdminUsersComponent implements OnInit { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); + private readonly paginationService = inject(PaginationService); + + // Service signals + readonly users = this.adminService.adminUsersState; + readonly isLoading = this.adminService.isLoadingUsers; + readonly error = this.adminService.usersError; + readonly pagination = this.adminService.usersPagination; + + // Computed pagination state for reusable component + readonly paginationState = computed(() => { + const pag = this.pagination(); + if (!pag) return null; + + return this.paginationService.calculatePaginationState({ + currentPage: pag.currentPage, + pageSize: pag.limit, + totalItems: pag.totalUsers + }); + }); + + // Computed page numbers + readonly pageNumbers = computed(() => { + const state = this.paginationState(); + if (!state) return []; + + return this.paginationService.calculatePageNumbers( + state.currentPage, + state.totalPages, + 5 + ); + }); + + // Table configuration + displayedColumns: string[] = ['username', 'email', 'role', 'status', 'joinedDate', 'lastLogin', 'actions']; + + // Filter form + filterForm!: FormGroup; + + // Current params + currentParams: UserListParams = { + page: 1, + limit: 10, + role: 'all', + isActive: 'all', + sortBy: 'createdAt', + sortOrder: 'desc', + search: '' + }; + + // Expose Math for template + Math = Math; + + ngOnInit(): void { + this.initializeFilterForm(); + this.setupSearchDebounce(); + this.loadUsersFromRoute(); + } + + /** + * Initialize filter form + */ + private initializeFilterForm(): void { + this.filterForm = this.fb.group({ + search: [''], + role: ['all'], + isActive: ['all'], + sortBy: ['createdAt'], + sortOrder: ['desc'] + }); + } + + /** + * Setup search field debounce + */ + private setupSearchDebounce(): void { + this.filterForm.get('search')?.valueChanges + .pipe( + debounceTime(500), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.applyFilters(); + }); + } + + /** + * Load users based on route query params + */ + private loadUsersFromRoute(): void { + this.route.queryParams + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(params => { + this.currentParams = { + page: +(params['page'] || 1), + limit: +(params['limit'] || 10), + role: params['role'] || 'all', + isActive: params['isActive'] || 'all', + sortBy: params['sortBy'] || 'createdAt', + sortOrder: params['sortOrder'] || 'desc', + search: params['search'] || '' + }; + + // Update form with current params + this.filterForm.patchValue({ + search: this.currentParams.search, + role: this.currentParams.role, + isActive: this.currentParams.isActive, + sortBy: this.currentParams.sortBy, + sortOrder: this.currentParams.sortOrder + }, { emitEvent: false }); + + this.loadUsers(); + }); + } + + /** + * Load users from API + */ + private loadUsers(): void { + this.adminService.getUsers(this.currentParams) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } + + /** + * Apply filters and reset to page 1 + */ + applyFilters(): void { + const formValue = this.filterForm.value; + this.currentParams = { + ...this.currentParams, + page: 1, // Reset to first page + search: formValue.search || '', + role: formValue.role || 'all', + isActive: formValue.isActive || 'all', + sortBy: formValue.sortBy || 'createdAt', + sortOrder: formValue.sortOrder || 'desc' + }; + + this.updateRouteParams(); + } + + /** + * Change page + */ + goToPage(page: number): void { + if (page < 1 || page > (this.pagination()?.totalPages ?? 1)) return; + + this.currentParams = { + ...this.currentParams, + page + }; + + this.updateRouteParams(); + } + + /** + * Handle page size change + */ + onPageSizeChange(pageSize: number): void { + this.currentParams = { + ...this.currentParams, + page: 1, + limit: pageSize + }; + + this.updateRouteParams(); + } + + /** + * Update route query parameters + */ + private updateRouteParams(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: this.currentParams, + queryParamsHandling: 'merge' + }); + } + + /** + * Refresh users list + */ + refreshUsers(): void { + this.loadUsers(); + } + + /** + * Reset all filters + */ + resetFilters(): void { + this.filterForm.reset({ + search: '', + role: 'all', + isActive: 'all', + sortBy: 'createdAt', + sortOrder: 'desc' + }); + this.applyFilters(); + } + + /** + * View user details + */ + viewUserDetails(userId: string): void { + this.router.navigate(['/admin/users', userId]); + } + + /** + * Edit user role - Opens role update dialog + */ + editUserRole(user: AdminUser): void { + const dialogRef = this.dialog.open(RoleUpdateDialogComponent, { + width: '600px', + maxWidth: '95vw', + data: { user }, + disableClose: false + }); + + dialogRef.afterClosed().subscribe(newRole => { + if (newRole && newRole !== user.role) { + this.adminService.updateUserRole(user.id, newRole).subscribe({ + next: () => { + // User list is automatically updated in the service + }, + error: () => { + // Error is handled by service + } + }); + } + }); + } + + /** + * Toggle user active status + */ + toggleUserStatus(user: AdminUser): void { + const action = user.isActive ? 'deactivate' : 'activate'; + + const dialogRef = this.dialog.open(StatusUpdateDialogComponent, { + width: '500px', + data: { + user: user, + action: action + }, + disableClose: false, + autoFocus: true + }); + + dialogRef.afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((confirmed: boolean) => { + if (!confirmed) return; + + // Call appropriate service method based on action + const serviceCall = action === 'activate' + ? this.adminService.activateUser(user.id) + : this.adminService.deactivateUser(user.id); + + serviceCall + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + // Signal update happens automatically in service + // No need to manually refresh the list + }, + error: (error) => { + console.error('Error updating user status:', error); + } + }); + }); + } + + /** + * Get role chip color + */ + getRoleColor(role: string): string { + return role === 'admin' ? 'primary' : 'accent'; + } + + /** + * Get status chip color + */ + getStatusColor(isActive: boolean): string { + return isActive ? 'primary' : 'warn'; + } + + /** + * Get status text + */ + getStatusText(isActive: boolean): string { + return isActive ? 'Active' : 'Inactive'; + } + + /** + * Format date for display + */ + formatDate(date: string | undefined): string { + if (!date) return 'Never'; + const d = new Date(date); + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + + /** + * Format date with time for display + */ + formatDateTime(date: string | undefined): string { + if (!date) return 'Never'; + const d = new Date(date); + return d.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + /** + * Navigate back to admin dashboard + */ + goBack(): void { + this.router.navigate(['/admin']); + } +} diff --git a/frontend/src/app/features/admin/category-form/category-form.html b/frontend/src/app/features/admin/category-form/category-form.html index 050a005..15dd9b0 100644 --- a/frontend/src/app/features/admin/category-form/category-form.html +++ b/frontend/src/app/features/admin/category-form/category-form.html @@ -166,10 +166,18 @@ [disabled]="categoryForm.invalid || isSubmitting()"> @if (isSubmitting()) { - Saving... - } @else { + } + + @if (isSubmitting()) { + Saving... + } @else if (isEditMode()) { + Save Changes + } @else { + Create Category + } + + @if (!isSubmitting()) { {{ isEditMode() ? 'save' : 'add' }} - {{ isEditMode() ? 'Save Changes' : 'Create Category' }} } diff --git a/frontend/src/app/features/admin/delete-confirm-dialog/delete-confirm-dialog.component.ts b/frontend/src/app/features/admin/delete-confirm-dialog/delete-confirm-dialog.component.ts new file mode 100644 index 0000000..b809b66 --- /dev/null +++ b/frontend/src/app/features/admin/delete-confirm-dialog/delete-confirm-dialog.component.ts @@ -0,0 +1,216 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +export interface DeleteConfirmDialogData { + title: string; + message: string; + itemName?: string; + confirmText?: string; + cancelText?: string; +} + +/** + * DeleteConfirmDialogComponent + * + * Reusable confirmation dialog for delete operations. + * + * Features: + * - Customizable title, message, and button text + * - Shows item name being deleted + * - Warning icon for visual emphasis + * - Accessible with keyboard navigation + */ +@Component({ + selector: 'app-delete-confirm-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule + ], + template: ` +
+
+ warning +

{{ data.title }}

+
+ + +

{{ data.message }}

+ + @if (data.itemName) { +
+ Item: +

{{ data.itemName }}

+
+ } + +
+ info + This action cannot be undone. +
+
+ + + + + +
+ `, + styles: [` + .delete-dialog { + min-width: 400px; + + @media (max-width: 600px) { + min-width: unset; + } + } + + .dialog-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + + .warning-icon { + font-size: 2.5rem; + width: 2.5rem; + height: 2.5rem; + color: #f44336; + } + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 500; + } + } + + mat-dialog-content { + padding: 0 1rem 1.5rem 1rem; + + .dialog-message { + margin: 0 0 1rem 0; + line-height: 1.6; + color: var(--text-secondary); + } + + .item-preview { + margin: 1rem 0; + padding: 1rem; + background-color: var(--background-light); + border-radius: 8px; + border-left: 4px solid var(--primary-color); + + strong { + display: block; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + } + + p { + margin: 0; + font-size: 0.95rem; + color: var(--text-primary); + word-break: break-word; + } + } + + .warning-box { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background-color: #fff3e0; + border-radius: 6px; + border-left: 4px solid #ff9800; + + mat-icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + color: #f57c00; + } + + span { + font-size: 0.875rem; + color: #e65100; + font-weight: 500; + } + } + } + + mat-dialog-actions { + padding: 1rem; + gap: 0.75rem; + + button { + min-width: 100px; + + mat-icon { + margin-right: 0.5rem; + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + } + } + } + + // Dark Mode Support + @media (prefers-color-scheme: dark) { + .delete-dialog { + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --background-light: rgba(255, 255, 255, 0.05); + } + + mat-dialog-content { + .warning-box { + background-color: rgba(255, 152, 0, 0.15); + border-left-color: #ff9800; + + mat-icon { + color: #ffb74d; + } + + span { + color: #ffb74d; + } + } + } + } + + // Light Mode Support + @media (prefers-color-scheme: light) { + .delete-dialog { + --text-primary: #212121; + --text-secondary: #757575; + --background-light: #f5f5f5; + } + } + `] +}) +export class DeleteConfirmDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DeleteConfirmDialogData + ) {} + + onCancel(): void { + this.dialogRef.close(false); + } + + onConfirm(): void { + this.dialogRef.close(true); + } +} diff --git a/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.html b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.html new file mode 100644 index 0000000..bea63e1 --- /dev/null +++ b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.html @@ -0,0 +1,251 @@ +
+ +
+
+ +
+

+ people_outline + Guest Analytics +

+

Guest user behavior and conversion insights

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

Loading guest analytics...

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

Failed to Load Analytics

+

{{ error() }}

+ +
+
+
+ } + + + @if (analytics() && !isLoading()) { + +
+ + +
+ group_add +
+
+

Total Guest Sessions

+

{{ formatNumber(totalSessions()) }}

+ @if (analytics() && analytics()!.stats.sessionsThisWeek) { +

+{{ analytics()!.stats.sessionsThisWeek }} this week

+ } +
+
+
+ + + +
+ online_prediction +
+
+

Active Sessions

+

{{ formatNumber(activeSessions()) }}

+

Currently active

+
+
+
+ + + +
+ trending_up +
+
+

Conversion Rate

+

{{ formatPercentage(conversionRate()) }}

+ @if (analytics() && analytics()!.totalConversions) { +

{{ analytics()!.totalConversions }} conversions

+ } +
+
+
+ + + +
+ quiz +
+
+

Avg Quizzes per Guest

+

{{ avgQuizzes().toFixed(1) }}

+

Per guest session

+
+
+
+
+ + + @if (timelineData().length > 0) { + + + + show_chart + Guest Session Timeline + + + +
+
+ + Active Sessions +
+
+ + New Sessions +
+
+ + Converted Sessions +
+
+
+ + + + + + + + + + + + + + + + + + + + + + @for (point of timelineData(); track point.date; let i = $index) { + + + + } + +
+
+
+ } + + + @if (funnelData().length > 0) { + + + + filter_alt + Conversion Funnel + + + +
+ + + @for (bar of getFunnelBars(); track bar.label) { + + + + + + {{ bar.label }} + + + {{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }}) + + } + +
+
+

Conversion Insights:

+
    + @for (stage of funnelData(); track stage.stage) { + @if (stage.dropoff !== undefined) { +
  • {{ formatPercentage(stage.dropoff) }} dropoff from {{ stage.stage }}
  • + } + } +
+
+
+
+ } + + +
+

Guest Management

+
+ + + +
+
+ } + + + @if (!analytics() && !isLoading() && !error()) { + + + people_outline +

No Analytics Available

+

Guest analytics will appear here once guests start using the platform

+
+
+ } +
diff --git a/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.scss b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.scss new file mode 100644 index 0000000..398a5bc --- /dev/null +++ b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.scss @@ -0,0 +1,474 @@ +.guest-analytics { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + + // Header + .analytics-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + + .header-left { + display: flex; + align-items: flex-start; + gap: 1rem; + + .header-content { + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 2rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #1a237e; + + mat-icon { + font-size: 2.5rem; + width: 2.5rem; + height: 2.5rem; + color: #3f51b5; + } + } + + .subtitle { + margin: 0; + color: #666; + font-size: 1rem; + } + } + } + + .header-actions { + display: flex; + gap: 0.5rem; + align-items: center; + + button mat-icon { + transition: transform 0.3s ease; + } + + button:hover:not([disabled]) mat-icon { + &:first-child { + transform: rotate(180deg); + } + } + } + } + + // Loading State + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + font-size: 1.1rem; + color: #666; + } + } + + // Error State + .error-card { + margin-bottom: 2rem; + border-left: 4px solid #f44336; + + .error-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem; + gap: 1rem; + + mat-icon { + font-size: 4rem; + width: 4rem; + height: 4rem; + } + + h3 { + margin: 0; + font-size: 1.5rem; + color: #333; + } + + p { + margin: 0; + color: #666; + } + + button { + margin-top: 1rem; + } + } + } + + // Statistics Grid + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; + + .stat-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + } + + mat-card-content { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.5rem; + + .stat-icon { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + border-radius: 12px; + + mat-icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + color: white; + } + } + + .stat-info { + flex: 1; + + h3 { + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + font-weight: 500; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-value { + margin: 0 0 0.25rem 0; + font-size: 2rem; + font-weight: 700; + color: #333; + } + + .stat-detail { + margin: 0; + font-size: 0.85rem; + color: #4caf50; + font-weight: 500; + } + } + } + + &.sessions-card .stat-icon { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + &.active-card .stat-icon { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + } + + &.conversion-card .stat-icon { + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); + } + + &.quizzes-card .stat-icon { + background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); + } + } + } + + // Chart Cards + .chart-card { + margin-bottom: 2rem; + + mat-card-header { + padding: 1.5rem 1.5rem 0; + + mat-card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.3rem; + color: #333; + + mat-icon { + color: #3f51b5; + } + } + } + + mat-card-content { + padding: 1.5rem; + + .chart-legend { + display: flex; + justify-content: center; + gap: 2rem; + margin-bottom: 1rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 8px; + + .legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: #666; + + .legend-color { + width: 20px; + height: 3px; + border-radius: 2px; + + &.active { + background: #3f51b5; + } + + &.new { + background: #4caf50; + } + + &.converted { + background: #ff9800; + } + } + } + } + + .chart-container { + overflow-x: auto; + + svg { + display: block; + margin: 0 auto; + + &.timeline-chart path { + transition: stroke-dashoffset 1s ease; + stroke-dasharray: 2000; + stroke-dashoffset: 2000; + animation: drawLine 2s ease forwards; + } + + &.funnel-chart rect { + transition: opacity 0.3s ease; + + &:hover { + opacity: 1 !important; + } + } + + text { + font-family: 'Roboto', sans-serif; + } + } + } + + .funnel-insights { + margin-top: 1.5rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 8px; + + p { + margin: 0 0 0.5rem 0; + color: #333; + font-weight: 500; + } + + ul { + margin: 0; + padding-left: 1.5rem; + + li { + color: #666; + margin-bottom: 0.25rem; + } + } + } + } + } + + @keyframes drawLine { + to { + stroke-dashoffset: 0; + } + } + + // Quick Actions + .quick-actions { + margin-top: 3rem; + + h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: #333; + } + + .actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + + button { + height: 60px; + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + + mat-icon { + font-size: 1.5rem; + width: 1.5rem; + height: 1.5rem; + } + } + } + } + + // Empty State + .empty-state { + margin-top: 2rem; + + mat-card-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + mat-icon { + font-size: 5rem; + width: 5rem; + height: 5rem; + color: #bdbdbd; + margin-bottom: 1rem; + } + + h3 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + color: #333; + } + + p { + margin: 0; + color: #666; + font-size: 1rem; + } + } + } + + // Responsive Design + @media (max-width: 768px) { + padding: 1rem; + + .analytics-header { + flex-direction: column; + gap: 1rem; + + .header-left { + width: 100%; + + .header-content h1 { + font-size: 1.5rem; + + mat-icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + } + } + } + + .header-actions { + width: 100%; + justify-content: space-between; + } + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .chart-card mat-card-content { + .chart-legend { + flex-direction: column; + gap: 0.5rem; + } + + .chart-container svg { + width: 100%; + height: auto; + } + } + + .quick-actions .actions-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .guest-analytics { + .analytics-header .header-left .header-content h1 { + color: #e3f2fd; + } + + .chart-card mat-card-title, + .quick-actions h2 { + color: #e0e0e0; + } + + .stats-grid .stat-card { + mat-card-content .stat-info { + h3 { + color: #bdbdbd; + } + + .stat-value { + color: #e0e0e0; + } + } + } + + .empty-state mat-card-content h3 { + color: #e0e0e0; + } + + .chart-card mat-card-content { + .chart-legend, + .funnel-insights { + background: #424242; + + .legend-item, + p, li { + color: #e0e0e0; + } + } + } + } +} diff --git a/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.ts b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.ts new file mode 100644 index 0000000..e3fa282 --- /dev/null +++ b/frontend/src/app/features/admin/guest-analytics/guest-analytics.component.ts @@ -0,0 +1,260 @@ +import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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 { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; +import { AdminService } from '../../../core/services/admin.service'; +import { GuestAnalytics } from '../../../core/models/admin.model'; + +/** + * GuestAnalyticsComponent + * + * Admin page for viewing guest user analytics featuring: + * - Guest session statistics (total, active, conversions) + * - Conversion rate and funnel visualization + * - Guest session timeline chart + * - Average quizzes per guest metric + * - CSV export functionality + * + * Features: + * - Real-time analytics with 10-min caching + * - Interactive SVG charts + * - Export data to CSV + * - Auto-refresh capability + * - Mobile-responsive layout + */ +@Component({ + selector: 'app-guest-analytics', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule + ], + templateUrl: './guest-analytics.component.html', + styleUrls: ['./guest-analytics.component.scss'] +}) +export class GuestAnalyticsComponent implements OnInit, OnDestroy { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly destroy$ = new Subject(); + + // State from service + readonly analytics = this.adminService.guestAnalyticsState; + readonly isLoading = this.adminService.isLoadingAnalytics; + readonly error = this.adminService.analyticsError; + + // Computed values for cards + readonly totalSessions = this.adminService.totalGuestSessions; + readonly activeSessions = this.adminService.activeGuestSessions; + readonly conversionRate = this.adminService.conversionRate; + readonly avgQuizzes = this.adminService.avgQuizzesPerGuest; + + // Chart data computed signals + readonly timelineData = computed(() => this.analytics()?.timeline ?? []); + readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []); + + // Chart dimensions + readonly chartWidth = 800; + readonly chartHeight = 300; + readonly funnelHeight = 400; + + ngOnInit(): void { + this.loadAnalytics(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Load guest analytics from service + */ + private loadAnalytics(): void { + this.adminService.getGuestAnalytics() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + error: (error) => { + console.error('Failed to load guest analytics:', error); + } + }); + } + + /** + * Refresh analytics (force reload) + */ + refreshAnalytics(): void { + this.adminService.refreshGuestAnalytics() + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Calculate max value for timeline chart + */ + getMaxTimelineValue(): number { + const data = this.timelineData(); + if (data.length === 0) return 1; + return Math.max( + ...data.map(d => Math.max(d.activeSessions, d.newSessions, d.convertedSessions)), + 1 + ); + } + + /** + * Calculate Y coordinate for timeline chart + */ + calculateTimelineY(value: number): number { + const maxValue = this.getMaxTimelineValue(); + const height = this.chartHeight; + const padding = 40; + const plotHeight = height - 2 * padding; + return height - padding - (value / maxValue) * plotHeight; + } + + /** + * Calculate X coordinate for timeline chart + */ + calculateTimelineX(index: number, totalPoints: number): number { + const width = this.chartWidth; + const padding = 40; + const plotWidth = width - 2 * padding; + if (totalPoints <= 1) return padding; + return padding + (index / (totalPoints - 1)) * plotWidth; + } + + /** + * Generate SVG path for timeline line + */ + getTimelinePath(dataKey: 'activeSessions' | 'newSessions' | 'convertedSessions'): string { + const data = this.timelineData(); + if (data.length === 0) return ''; + + const points = data.map((d, i) => { + const x = this.calculateTimelineX(i, data.length); + const y = this.calculateTimelineY(d[dataKey]); + return `${x},${y}`; + }); + + return `M ${points.join(' L ')}`; + } + + /** + * Get conversion funnel bar data + */ + getFunnelBars(): Array<{ + x: number; + y: number; + width: number; + height: number; + label: string; + count: number; + percentage: number; + }> { + const stages = this.funnelData(); + if (stages.length === 0) return []; + + const maxCount = Math.max(...stages.map(s => s.count), 1); + const width = this.chartWidth; + const height = this.funnelHeight; + const padding = 60; + const plotWidth = width - 2 * padding; + const plotHeight = height - 2 * padding; + const barHeight = plotHeight / stages.length - 20; + + return stages.map((stage, i) => { + const barWidth = (stage.count / maxCount) * plotWidth; + return { + x: padding, + y: padding + i * (plotHeight / stages.length) + 10, + width: barWidth, + height: barHeight, + label: stage.stage, + count: stage.count, + percentage: stage.percentage + }; + }); + } + + /** + * Export analytics data to CSV + */ + exportToCSV(): void { + const analytics = this.analytics(); + if (!analytics) return; + + // Prepare CSV content + let csvContent = 'Guest Analytics Report\n\n'; + + // Summary statistics + csvContent += 'Summary Statistics\n'; + csvContent += 'Metric,Value\n'; + csvContent += `Total Guest Sessions,${analytics.totalGuestSessions}\n`; + csvContent += `Active Guest Sessions,${analytics.activeGuestSessions}\n`; + csvContent += `Conversion Rate,${analytics.conversionRate}%\n`; + csvContent += `Average Quizzes per Guest,${analytics.averageQuizzesPerGuest}\n`; + csvContent += `Total Conversions,${analytics.totalConversions}\n\n`; + + // Timeline data + csvContent += 'Timeline Data\n'; + csvContent += 'Date,Active Sessions,New Sessions,Converted Sessions\n'; + analytics.timeline.forEach(item => { + csvContent += `${item.date},${item.activeSessions},${item.newSessions},${item.convertedSessions}\n`; + }); + csvContent += '\n'; + + // Funnel data + csvContent += 'Conversion Funnel\n'; + csvContent += 'Stage,Count,Percentage,Dropoff\n'; + analytics.conversionFunnel.forEach(stage => { + csvContent += `${stage.stage},${stage.count},${stage.percentage}%,${stage.dropoff ?? 'N/A'}\n`; + }); + + // Create and download file + 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', `guest-analytics-${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * Format number with commas + */ + formatNumber(num: number): string { + return num.toLocaleString(); + } + + /** + * Format percentage + */ + formatPercentage(num: number): string { + return `${num.toFixed(1)}%`; + } + + /** + * Navigate back to admin dashboard + */ + goBack(): void { + this.router.navigate(['/admin']); + } + + /** + * Navigate to guest settings + */ + goToSettings(): void { + this.router.navigate(['/admin/settings']); + } +} diff --git a/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.html b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.html new file mode 100644 index 0000000..6384f43 --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.html @@ -0,0 +1,255 @@ +
+ +
+
+ +
+

Edit Guest Settings

+

Configure guest user access and limitations

+
+
+
+ + + @if (isLoading() && !settings()) { +
+ +

Loading settings...

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

Failed to Load Settings

+

{{ error() }}

+
+
+ +
+
+ } + + + @if (settings() || (!isLoading() && settingsForm)) { +
+ + + +
+ lock_open +
+ Access Control + Enable or disable guest access to the platform +
+ +
+
+ +

Allow users to access the platform without registering

+
+ + +
+ @if (!settingsForm.get('guestAccessEnabled')?.value) { +
+ warning + When disabled, all users must register and login to access the platform. +
+ } +
+
+ + + + +
+ rule +
+ Quiz Limits + Set daily and per-quiz restrictions for guests +
+ +
+ + Max Quizzes Per Day + + calendar_today + Number of quizzes a guest can take per day (1-100) + @if (hasError('maxQuizzesPerDay')) { + {{ getErrorMessage('maxQuizzesPerDay') }} + } + +
+ +
+ + Max Questions Per Quiz + + quiz + Maximum questions allowed in a single quiz (1-50) + @if (hasError('maxQuestionsPerQuiz')) { + {{ getErrorMessage('maxQuestionsPerQuiz') }} + } + +
+
+
+ + + + +
+ schedule +
+ Session Configuration + Configure guest session duration +
+ +
+ + Session Expiry Hours + + timer + + How long guest sessions remain active (1-168 hours / 7 days) + @if (settingsForm.get('sessionExpiryHours')?.value) { + - {{ formatExpiryTime(settingsForm.get('sessionExpiryHours')?.value) }} + } + + @if (hasError('sessionExpiryHours')) { + {{ getErrorMessage('sessionExpiryHours') }} + } + +
+
+
+ + + + +
+ message +
+ Upgrade Prompt + Message shown when guests reach their limit +
+ +
+ + Upgrade Prompt Message + + format_quote + + {{ settingsForm.get('upgradePromptMessage')?.value?.length || 0 }} / 500 characters + + @if (hasError('upgradePromptMessage')) { + {{ getErrorMessage('upgradePromptMessage') }} + } + +
+ + + @if (settingsForm.get('upgradePromptMessage')?.value) { +
+
+ visibility + Preview: +
+
+ {{ settingsForm.get('upgradePromptMessage')?.value }} +
+
+ } +
+
+ + + @if (hasUnsavedChanges()) { + + +
+ pending_actions +
+ Pending Changes + Review changes before saving +
+ +
+ @for (change of getChangesPreview(); track change.label) { +
+
{{ change.label }}
+
+ {{ change.old }} + arrow_forward + {{ change.new }} +
+
+ } +
+
+
+ } + + +
+
+ +
+
+ + @if (isSubmitting) { + + } @else { + + } +
+
+
+ } +
diff --git a/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.scss b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.scss new file mode 100644 index 0000000..9b0c834 --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.scss @@ -0,0 +1,468 @@ +.guest-settings-edit-container { + max-width: 900px; + margin: 0 auto; + padding: 2rem; +} + +// Header Section +.settings-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + gap: 1rem; + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + + .header-title { + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: #333; + } + + .subtitle { + margin: 0.25rem 0 0 0; + color: #666; + font-size: 0.95rem; + } + } + } +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + color: #666; + font-size: 1rem; + } +} + +// Error State +.error-card { + margin-bottom: 2rem; + + .error-content { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + + mat-icon { + font-size: 3rem; + width: 3rem; + height: 3rem; + } + + .error-text { + flex: 1; + + h3 { + margin: 0 0 0.5rem 0; + color: #d32f2f; + font-size: 1.25rem; + } + + p { + margin: 0; + color: #666; + } + } + } +} + +// Settings Form +.settings-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +// Form Section Card +.form-section { + mat-card-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + + .section-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + + &.access { + background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%); + } + + &.limits { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + } + + &.session { + background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%); + } + + &.message { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + } + + &.changes { + background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%); + } + } + + mat-card-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } + + mat-card-subtitle { + margin: 0.25rem 0 0 0; + font-size: 0.85rem; + } + } + + mat-card-content { + padding-top: 1rem; + } +} + +// Toggle Field +.toggle-field { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-radius: 8px; + background: #f5f5f5; + margin-bottom: 1rem; + + .toggle-info { + flex: 1; + + label { + display: block; + font-weight: 500; + color: #333; + margin-bottom: 0.25rem; + } + + .field-description { + margin: 0; + font-size: 0.85rem; + color: #666; + } + } +} + +// Warning Banner +.warning-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: #fff3cd; + border-left: 4px solid #ffc107; + border-radius: 4px; + + mat-icon { + color: #ff9800; + } + + span { + color: #856404; + font-size: 0.9rem; + } +} + +// Form Fields +.form-row { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.full-width { + width: 100%; +} + +mat-form-field { + mat-icon[matPrefix] { + margin-right: 0.5rem; + color: #666; + } +} + +// Message Preview +.message-preview { + margin-top: 1rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 8px; + border-left: 4px solid #3f51b5; + + .preview-label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-weight: 500; + color: #3f51b5; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + .preview-content { + padding: 0.75rem; + background: white; + border-radius: 4px; + color: #333; + font-style: italic; + line-height: 1.6; + } +} + +// Changes Preview +.changes-preview { + border: 2px solid #ffa726; + + .changes-list { + display: flex; + flex-direction: column; + gap: 1rem; + + .change-item { + padding: 1rem; + background: #fff3e0; + border-radius: 8px; + + .change-label { + font-weight: 500; + color: #e65100; + margin-bottom: 0.5rem; + font-size: 0.9rem; + } + + .change-values { + display: flex; + align-items: center; + gap: 0.75rem; + + .old-value { + padding: 0.25rem 0.75rem; + background: white; + border-radius: 4px; + color: #999; + text-decoration: line-through; + font-size: 0.9rem; + } + + mat-icon { + color: #ff9800; + font-size: 20px; + width: 20px; + height: 20px; + } + + .new-value { + padding: 0.25rem 0.75rem; + background: white; + border-radius: 4px; + color: #4caf50; + font-weight: 600; + font-size: 0.9rem; + } + } + } + } +} + +// Form Actions +.form-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 0; + border-top: 1px solid #e0e0e0; + gap: 1rem; + + .actions-left, + .actions-right { + display: flex; + gap: 0.75rem; + } + + button { + mat-icon { + margin-right: 0.5rem; + } + + mat-spinner { + display: inline-block; + margin-right: 0.5rem; + } + } +} + +// Responsive Design +@media (max-width: 768px) { + .guest-settings-edit-container { + padding: 1rem; + } + + .settings-header { + .header-left { + flex-direction: column; + align-items: flex-start; + + .header-title h1 { + font-size: 1.5rem; + } + } + } + + .toggle-field { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .form-actions { + flex-direction: column; + align-items: stretch; + + .actions-left, + .actions-right { + width: 100%; + justify-content: stretch; + + button { + flex: 1; + } + } + } + + .changes-preview { + .change-item .change-values { + flex-direction: column; + align-items: flex-start; + + mat-icon { + transform: rotate(90deg); + } + } + } +} + +@media (max-width: 1024px) { + .form-section { + mat-card-header { + flex-direction: column; + align-items: flex-start; + } + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .settings-header .header-title h1 { + color: #fff; + } + + .settings-header .subtitle { + color: #aaa; + } + + .loading-container p { + color: #aaa; + } + + .error-card .error-content .error-text p { + color: #aaa; + } + + .toggle-field { + background: #2a2a2a; + + label { + color: #fff; + } + + .field-description { + color: #aaa; + } + } + + .warning-banner { + background: #4a3f2a; + + span { + color: #ffd54f; + } + } + + mat-form-field mat-icon[matPrefix] { + color: #aaa; + } + + .message-preview { + background: #2a2a2a; + + .preview-content { + background: #1a1a1a; + color: #fff; + } + } + + .changes-preview { + .changes-list .change-item { + background: #3a3a2a; + + .change-label { + color: #ffb74d; + } + + .change-values { + .old-value, + .new-value { + background: #1a1a1a; + } + } + } + } + + .form-actions { + border-top-color: #444; + } +} diff --git a/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.ts b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.ts new file mode 100644 index 0000000..206c387 --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings-edit/guest-settings-edit.component.ts @@ -0,0 +1,276 @@ +import { Component, OnInit, inject, DestroyRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +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 { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { AdminService } from '../../../core/services/admin.service'; +import { GuestSettings } from '../../../core/models/admin.model'; + +/** + * GuestSettingsEditComponent + * + * Form component for editing guest access settings. + * Allows administrators to configure guest user limitations and features. + * + * Features: + * - Reactive form with validation + * - Real-time validation errors + * - Settings preview before save + * - Form reset functionality + * - Success/error handling + * - Navigation back to view mode + */ +@Component({ + selector: 'app-guest-settings-edit', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatSlideToggleModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatDividerModule + ], + templateUrl: './guest-settings-edit.component.html', + styleUrl: './guest-settings-edit.component.scss' +}) +export class GuestSettingsEditComponent implements OnInit { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + + // Service signals + readonly settings = this.adminService.guestSettingsState; + readonly isLoading = this.adminService.isLoadingSettings; + readonly error = this.adminService.settingsError; + + // Form + settingsForm!: FormGroup; + isSubmitting = false; + originalSettings: GuestSettings | null = null; + + ngOnInit(): void { + this.initializeForm(); + this.loadSettings(); + } + + /** + * Initialize the form with validation + */ + private initializeForm(): void { + this.settingsForm = this.fb.group({ + guestAccessEnabled: [false], + maxQuizzesPerDay: [3, [Validators.required, Validators.min(1), Validators.max(100)]], + maxQuestionsPerQuiz: [10, [Validators.required, Validators.min(1), Validators.max(50)]], + sessionExpiryHours: [24, [Validators.required, Validators.min(1), Validators.max(168)]], + upgradePromptMessage: [ + 'You\'ve reached your quiz limit. Sign up for unlimited access!', + [Validators.required, Validators.minLength(10), Validators.maxLength(500)] + ] + }); + } + + /** + * Load existing settings and populate form + */ + private loadSettings(): void { + // If settings already loaded, use them + if (this.settings()) { + this.populateForm(this.settings()!); + return; + } + + // Otherwise fetch settings + this.adminService.getGuestSettings() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(settings => { + this.populateForm(settings); + }); + } + + /** + * Populate form with existing settings + */ + private populateForm(settings: GuestSettings): void { + this.originalSettings = settings; + this.settingsForm.patchValue({ + guestAccessEnabled: settings.guestAccessEnabled, + maxQuizzesPerDay: settings.maxQuizzesPerDay, + maxQuestionsPerQuiz: settings.maxQuestionsPerQuiz, + sessionExpiryHours: settings.sessionExpiryHours, + upgradePromptMessage: settings.upgradePromptMessage + }); + } + + /** + * Submit form and update settings + */ + onSubmit(): void { + if (this.settingsForm.invalid || this.isSubmitting) { + this.settingsForm.markAllAsTouched(); + return; + } + + this.isSubmitting = true; + const formData = this.settingsForm.value; + + this.adminService.updateGuestSettings(formData) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSubmitting = false; + // Navigate back to view page after short delay + setTimeout(() => { + this.router.navigate(['/admin/guest-settings']); + }, 1500); + }, + error: () => { + this.isSubmitting = false; + } + }); + } + + /** + * Cancel editing and return to view page + */ + onCancel(): void { + if (this.hasUnsavedChanges()) { + if (confirm('You have unsaved changes. Are you sure you want to cancel?')) { + this.router.navigate(['/admin/guest-settings']); + } + } else { + this.router.navigate(['/admin/guest-settings']); + } + } + + /** + * Reset form to original values + */ + onReset(): void { + if (this.originalSettings) { + this.populateForm(this.originalSettings); + } + } + + /** + * Check if form has unsaved changes + */ + hasUnsavedChanges(): boolean { + if (!this.originalSettings) return false; + + const formValue = this.settingsForm.value; + return ( + formValue.guestAccessEnabled !== this.originalSettings.guestAccessEnabled || + formValue.maxQuizzesPerDay !== this.originalSettings.maxQuizzesPerDay || + formValue.maxQuestionsPerQuiz !== this.originalSettings.maxQuestionsPerQuiz || + formValue.sessionExpiryHours !== this.originalSettings.sessionExpiryHours || + formValue.upgradePromptMessage !== this.originalSettings.upgradePromptMessage + ); + } + + /** + * Get error message for a form field + */ + getErrorMessage(fieldName: string): string { + const field = this.settingsForm.get(fieldName); + if (!field?.errors || !field.touched) return ''; + + if (field.errors['required']) return 'This field is required'; + if (field.errors['min']) return `Minimum value is ${field.errors['min'].min}`; + if (field.errors['max']) return `Maximum value is ${field.errors['max'].max}`; + if (field.errors['minlength']) return `Minimum length is ${field.errors['minlength'].requiredLength} characters`; + if (field.errors['maxlength']) return `Maximum length is ${field.errors['maxlength'].requiredLength} characters`; + + return 'Invalid value'; + } + + /** + * Check if a field has an error + */ + hasError(fieldName: string): boolean { + const field = this.settingsForm.get(fieldName); + return !!(field?.invalid && field?.touched); + } + + /** + * Get preview of changes + */ + getChangesPreview(): Array<{label: string, old: any, new: any}> { + if (!this.originalSettings || !this.hasUnsavedChanges()) return []; + + const changes: Array<{label: string, old: any, new: any}> = []; + const formValue = this.settingsForm.value; + + if (formValue.guestAccessEnabled !== this.originalSettings.guestAccessEnabled) { + changes.push({ + label: 'Guest Access', + old: this.originalSettings.guestAccessEnabled ? 'Enabled' : 'Disabled', + new: formValue.guestAccessEnabled ? 'Enabled' : 'Disabled' + }); + } + + if (formValue.maxQuizzesPerDay !== this.originalSettings.maxQuizzesPerDay) { + changes.push({ + label: 'Max Quizzes Per Day', + old: this.originalSettings.maxQuizzesPerDay, + new: formValue.maxQuizzesPerDay + }); + } + + if (formValue.maxQuestionsPerQuiz !== this.originalSettings.maxQuestionsPerQuiz) { + changes.push({ + label: 'Max Questions Per Quiz', + old: this.originalSettings.maxQuestionsPerQuiz, + new: formValue.maxQuestionsPerQuiz + }); + } + + if (formValue.sessionExpiryHours !== this.originalSettings.sessionExpiryHours) { + changes.push({ + label: 'Session Expiry Hours', + old: this.originalSettings.sessionExpiryHours, + new: formValue.sessionExpiryHours + }); + } + + if (formValue.upgradePromptMessage !== this.originalSettings.upgradePromptMessage) { + changes.push({ + label: 'Upgrade Prompt Message', + old: this.originalSettings.upgradePromptMessage, + new: formValue.upgradePromptMessage + }); + } + + return changes; + } + + /** + * Format expiry time for display + */ + formatExpiryTime(hours: number): string { + if (hours < 24) { + return `${hours} hour${hours !== 1 ? 's' : ''}`; + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + if (remainingHours === 0) { + return `${days} day${days !== 1 ? 's' : ''}`; + } + return `${days} day${days !== 1 ? 's' : ''} and ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}`; + } +} diff --git a/frontend/src/app/features/admin/guest-settings/guest-settings.component.html b/frontend/src/app/features/admin/guest-settings/guest-settings.component.html new file mode 100644 index 0000000..02dd7ba --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings/guest-settings.component.html @@ -0,0 +1,230 @@ +
+ +
+
+ +
+

Guest Access Settings

+

View and manage guest user access configuration

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

Loading guest settings...

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

Failed to Load Settings

+

{{ error() }}

+
+
+ +
+
+ } + + + @if (settings() && !isLoading()) { +
+ + + +
+ lock_open +
+ Access Control + Guest access configuration +
+ +
+
+ toggle_on + Guest Access +
+
+ + {{ getStatusText(settings()?.guestAccessEnabled ?? false) }} + +
+
+ @if (!settings()?.guestAccessEnabled) { +
+ info + Guest access is currently disabled. Users must register to access the platform. +
+ } +
+
+ + + + +
+ rule +
+ Quiz Limits + Daily and per-quiz restrictions +
+ +
+
+ calendar_today + Max Quizzes Per Day +
+
+ {{ settings()?.maxQuizzesPerDay ?? 0 }} + quizzes +
+
+
+
+ quiz + Max Questions Per Quiz +
+
+ {{ settings()?.maxQuestionsPerQuiz ?? 0 }} + questions +
+
+
+
+ + + + +
+ schedule +
+ Session Configuration + Session duration and expiry +
+ +
+
+ timer + Session Expiry Time +
+
+ {{ settings()?.sessionExpiryHours ?? 0 }} + hours + ({{ formatExpiryTime(settings()?.sessionExpiryHours ?? 0) }}) +
+
+
+
+ + + + +
+ message +
+ Upgrade Prompt + Message shown to guests when limit reached +
+ +
+ format_quote +

{{ settings()?.upgradePromptMessage ?? 'No message configured' }}

+
+
+
+ + + @if (settings()?.features) { + + +
+ settings +
+ Guest Features + Available features for guest users +
+ +
+
+ bookmark + Bookmarking + + {{ getStatusText(settings()?.features?.canBookmark ?? false) }} + +
+
+ history + View History + + {{ getStatusText(settings()?.features?.canViewHistory ?? false) }} + +
+
+ download + Export Results + + {{ getStatusText(settings()?.features?.canExportResults ?? false) }} + +
+
+
+
+ } + + + @if (settings()?.allowedCategories && settings()!.allowedCategories!.length > 0) { + + +
+ category +
+ Allowed Categories + Categories accessible to guest users +
+ +
+ @for (category of settings()?.allowedCategories; track category) { + {{ category }} + } +
+
+
+ } +
+ + +
+ + +
+ } +
diff --git a/frontend/src/app/features/admin/guest-settings/guest-settings.component.scss b/frontend/src/app/features/admin/guest-settings/guest-settings.component.scss new file mode 100644 index 0000000..100b98b --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings/guest-settings.component.scss @@ -0,0 +1,449 @@ +.guest-settings-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +// Header Section +.settings-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + gap: 1rem; + flex-wrap: wrap; + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + + .header-title { + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: #333; + } + + .subtitle { + margin: 0.25rem 0 0 0; + color: #666; + font-size: 0.95rem; + } + } + } + + .header-actions { + display: flex; + gap: 0.75rem; + align-items: center; + + button { + mat-icon { + margin-right: 0.5rem; + } + } + } +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1.5rem; + + p { + color: #666; + font-size: 1rem; + } +} + +// Error State +.error-card { + margin-bottom: 2rem; + + .error-content { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + + mat-icon { + font-size: 3rem; + width: 3rem; + height: 3rem; + } + + .error-text { + flex: 1; + + h3 { + margin: 0 0 0.5rem 0; + color: #d32f2f; + font-size: 1.25rem; + } + + p { + margin: 0; + color: #666; + } + } + } +} + +// Settings Content +.settings-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +// Settings Card +.settings-card { + mat-card-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + position: relative; + + .card-header-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + + &.enabled { + background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%); + } + + &.limits { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + } + + &.session { + background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%); + } + + &.message { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + } + + &.features { + background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); + } + + &.categories { + background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); + } + } + + mat-card-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } + + mat-card-subtitle { + margin: 0.25rem 0 0 0; + font-size: 0.85rem; + } + } + + mat-card-content { + padding-top: 1rem; + } +} + +// Setting Item +.setting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-radius: 8px; + background: #f5f5f5; + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + + .setting-label { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 500; + color: #333; + + mat-icon { + color: #666; + font-size: 20px; + width: 20px; + height: 20px; + } + } + + .setting-value { + display: flex; + align-items: center; + gap: 0.5rem; + + .value-number { + font-size: 1.5rem; + font-weight: 600; + color: #3f51b5; + } + + .value-unit { + font-size: 0.9rem; + color: #666; + } + + .value-formatted { + font-size: 0.85rem; + color: #999; + } + } +} + +// Info Banner +.info-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: #fff3cd; + border-left: 4px solid #ffc107; + border-radius: 4px; + margin-top: 1rem; + + mat-icon { + color: #ff9800; + } + + span { + color: #856404; + font-size: 0.9rem; + } +} + +// Upgrade Message +.upgrade-message { + display: flex; + gap: 1rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 8px; + border-left: 4px solid #3f51b5; + + mat-icon { + color: #3f51b5; + font-size: 24px; + width: 24px; + height: 24px; + } + + p { + margin: 0; + flex: 1; + color: #333; + font-style: italic; + line-height: 1.6; + } +} + +// Features Grid +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + + .feature-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; + border-radius: 8px; + background: #f5f5f5; + text-align: center; + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: #999; + margin-bottom: 0.25rem; + + &.enabled { + color: #4caf50; + } + } + + span { + font-size: 0.9rem; + font-weight: 500; + color: #333; + } + + mat-chip { + margin-top: 0.25rem; + } + } +} + +// Categories Chips +.categories-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + mat-chip { + font-size: 0.9rem; + } +} + +// Quick Actions +.quick-actions { + display: flex; + justify-content: center; + gap: 1rem; + padding: 1.5rem 0; + border-top: 1px solid #e0e0e0; + + button { + mat-icon { + margin-right: 0.5rem; + } + } +} + +// Responsive Design +@media (max-width: 768px) { + .guest-settings-container { + padding: 1rem; + } + + .settings-header { + flex-direction: column; + align-items: stretch; + + .header-left { + flex-direction: column; + align-items: flex-start; + + .header-title h1 { + font-size: 1.5rem; + } + } + + .header-actions { + width: 100%; + justify-content: stretch; + + button { + flex: 1; + } + } + } + + .settings-content { + grid-template-columns: 1fr; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .quick-actions { + flex-direction: column; + + button { + width: 100%; + } + } +} + +@media (max-width: 1024px) { + .settings-content { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .settings-header { + .header-title h1 { + color: #fff; + } + + .subtitle { + color: #aaa; + } + } + + .loading-container p { + color: #aaa; + } + + .error-card .error-content .error-text p { + color: #aaa; + } + + .setting-item { + background: #2a2a2a; + + .setting-label { + color: #fff; + + mat-icon { + color: #aaa; + } + } + } + + .info-banner { + background: #4a3f2a; + + span { + color: #ffd54f; + } + } + + .upgrade-message { + background: #2a2a2a; + + p { + color: #fff; + } + } + + .features-grid .feature-item { + background: #2a2a2a; + + span { + color: #fff; + } + } + + .quick-actions { + border-top-color: #444; + } +} diff --git a/frontend/src/app/features/admin/guest-settings/guest-settings.component.ts b/frontend/src/app/features/admin/guest-settings/guest-settings.component.ts new file mode 100644 index 0000000..4929e9d --- /dev/null +++ b/frontend/src/app/features/admin/guest-settings/guest-settings.component.ts @@ -0,0 +1,127 @@ +import { Component, OnInit, inject, DestroyRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +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 { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { AdminService } from '../../../core/services/admin.service'; +import { GuestSettings } from '../../../core/models/admin.model'; + +/** + * GuestSettingsComponent + * + * Displays guest access settings in read-only mode for admin users. + * Allows navigation to edit settings view. + * + * Features: + * - Read-only settings cards with icons + * - Categorized settings display (Access, Limits, Session, Features) + * - Loading and error states + * - Refresh functionality + * - Navigation to edit view + * - Status indicators for enabled/disabled features + */ +@Component({ + selector: 'app-guest-settings', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatChipsModule + ], + templateUrl: './guest-settings.component.html', + styleUrl: './guest-settings.component.scss' +}) +export class GuestSettingsComponent implements OnInit { + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + // Service signals + readonly settings = this.adminService.guestSettingsState; + readonly isLoading = this.adminService.isLoadingSettings; + readonly error = this.adminService.settingsError; + + ngOnInit(): void { + this.loadSettings(); + } + + /** + * Load guest settings from API + */ + loadSettings(): void { + this.adminService.getGuestSettings() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } + + /** + * Refresh settings (force reload) + */ + refreshSettings(): void { + this.adminService.refreshGuestSettings() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } + + /** + * Navigate to edit settings page + */ + editSettings(): void { + this.router.navigate(['/admin/guest-settings/edit']); + } + + /** + * Navigate back to admin dashboard + */ + goBack(): void { + this.router.navigate(['/admin']); + } + + /** + * Navigate to guest analytics + */ + goToAnalytics(): void { + this.router.navigate(['/admin/analytics']); + } + + /** + * Get status color for boolean settings + */ + getStatusColor(enabled: boolean): string { + return enabled ? 'primary' : 'warn'; + } + + /** + * Get status text for boolean settings + */ + getStatusText(enabled: boolean): string { + return enabled ? 'Enabled' : 'Disabled'; + } + + /** + * Get chip color for features + */ + getFeatureColor(enabled: boolean): string { + return enabled ? 'accent' : ''; + } + + /** + * Format session expiry hours to readable text + */ + formatExpiryTime(hours: number): string { + if (hours < 24) { + return `${hours} hour${hours !== 1 ? 's' : ''}`; + } + const days = Math.floor(hours / 24); + return `${days} day${days !== 1 ? 's' : ''}`; + } +} diff --git a/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.html b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.html new file mode 100644 index 0000000..f2206e1 --- /dev/null +++ b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.html @@ -0,0 +1,174 @@ +
+ + @if (!showConfirmation()) { +
+
+ admin_panel_settings +

Update User Role

+
+ + + + +
+

Select New Role

+ + +
+
+ person + Regular User +
+

{{ getRoleDescription('user') }}

+
+
+ + +
+
+ admin_panel_settings + Administrator +
+

{{ getRoleDescription('admin') }}

+
+
+
+
+ + @if (isDemotingAdmin) { +
+ warning +
+

Warning: Demoting Administrator

+

This user will lose access to:

+
    +
  • Admin dashboard and analytics
  • +
  • User management capabilities
  • +
  • System settings and configuration
  • +
  • Question and category management
  • +
+
+
+ } + + @if (isPromotingToAdmin) { +
+ info +
+

Promoting to Administrator

+

This user will gain access to:

+
    +
  • Full admin dashboard and analytics
  • +
  • Manage all users and their roles
  • +
  • Configure system settings
  • +
  • Create and manage questions/categories
  • +
+
+
+ } +
+ + + + + +
+ } + + + @if (showConfirmation()) { +
+
+ check_circle +

Confirm Role Change

+
+ + +
+
+
+ User: + {{ data.user.username }} +
+
+ arrow_downward +
+
+ Current Role: + + {{ getRoleLabel(data.user.role) }} + +
+
+ arrow_downward +
+
+ New Role: + + {{ getRoleLabel(selectedRole) }} + +
+
+ + @if (isDemotingAdmin) { +
+ error +

Important: This action will immediately revoke all administrative privileges. The user will be logged out if currently in an admin session.

+
+ } + +

+ Are you sure you want to change this user's role? +

+
+
+ + + + @if (isLoading()) { + + } @else { + + } + +
+ } +
diff --git a/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.scss b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.scss new file mode 100644 index 0000000..d573b21 --- /dev/null +++ b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.scss @@ -0,0 +1,415 @@ +.role-update-dialog { + .dialog-content { + min-width: 500px; + max-width: 600px; + } + + // =========================== + // Dialog Header + // =========================== + .dialog-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + .header-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: var(--primary-color); + + &.confirm { + color: var(--success-color); + } + } + + h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + } + } + + // =========================== + // User Info Section + // =========================== + .user-info { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background-color: var(--bg-secondary); + border-radius: 8px; + margin-bottom: 24px; + + .user-avatar { + mat-icon { + font-size: 56px; + width: 56px; + height: 56px; + color: var(--primary-color); + } + } + + .user-details { + flex: 1; + + h3 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 0 0 8px; + font-size: 14px; + color: var(--text-secondary); + } + + .current-role { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + + .label { + color: var(--text-secondary); + font-weight: 500; + } + } + } + } + + // =========================== + // Role Selector + // =========================== + .role-selector { + margin-bottom: 24px; + + .selector-title { + margin: 0 0 16px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .role-options { + display: flex; + flex-direction: column; + gap: 12px; + + .role-option { + padding: 16px; + border: 2px solid var(--divider-color); + border-radius: 8px; + transition: all 0.2s; + width: 100%; + + &:hover { + border-color: var(--primary-color); + background-color: var(--bg-secondary); + } + + &.mat-radio-checked { + border-color: var(--primary-color); + background-color: var(--primary-light); + } + + .role-option-content { + width: 100%; + margin-left: 8px; + + .role-option-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + + mat-icon { + color: var(--primary-color); + } + + .role-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .role-description { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + } + } + + // =========================== + // Role Badge + // =========================== + .role-badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + + &.role-user { + background-color: var(--primary-light); + color: var(--primary-color); + } + + &.role-admin { + background-color: var(--warn-light); + color: var(--warn-color); + } + } + + // =========================== + // Warning Box + // =========================== + .warning-box { + display: flex; + gap: 12px; + padding: 16px; + background-color: var(--warn-light); + border-left: 4px solid var(--warn-color); + border-radius: 4px; + margin-top: 16px; + + > mat-icon { + color: var(--warn-color); + flex-shrink: 0; + } + + .warning-content { + flex: 1; + + h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: var(--warn-dark); + } + + p { + margin: 0 0 8px; + font-size: 14px; + color: var(--text-primary); + } + + ul { + margin: 0; + padding-left: 20px; + font-size: 13px; + color: var(--text-secondary); + + li { + margin-bottom: 4px; + } + } + } + } + + // =========================== + // Info Box + // =========================== + .info-box { + display: flex; + gap: 12px; + padding: 16px; + background-color: var(--info-light); + border-left: 4px solid var(--info-color); + border-radius: 4px; + margin-top: 16px; + + > mat-icon { + color: var(--info-color); + flex-shrink: 0; + } + + .info-content { + flex: 1; + + h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: var(--info-dark); + } + + p { + margin: 0 0 8px; + font-size: 14px; + color: var(--text-primary); + } + + ul { + margin: 0; + padding-left: 20px; + font-size: 13px; + color: var(--text-secondary); + + li { + margin-bottom: 4px; + } + } + } + } + + // =========================== + // Confirmation Step + // =========================== + .confirmation-message { + .change-summary { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + background-color: var(--bg-secondary); + border-radius: 8px; + margin-bottom: 24px; + + .change-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + + .change-label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .change-value { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .change-arrow { + mat-icon { + color: var(--primary-color); + font-size: 32px; + width: 32px; + height: 32px; + } + } + } + + .final-warning { + display: flex; + gap: 12px; + padding: 16px; + background-color: var(--error-light); + border: 2px solid var(--error-color); + border-radius: 8px; + margin-bottom: 16px; + + mat-icon { + color: var(--error-color); + flex-shrink: 0; + } + + p { + margin: 0; + font-size: 14px; + color: var(--text-primary); + line-height: 1.5; + + strong { + color: var(--error-dark); + } + } + } + + .confirmation-question { + text-align: center; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 16px 0 0; + } + } + + // =========================== + // Dialog Actions + // =========================== + mat-dialog-actions { + padding: 16px 0 0; + margin: 0; + border-top: 1px solid var(--divider-color); + + button { + mat-icon { + margin-right: 4px; + } + + mat-spinner { + display: inline-block; + margin-right: 8px; + } + } + } +} + +// =========================== +// Responsive Design +// =========================== +@media (max-width: 767px) { + .role-update-dialog { + .dialog-content { + min-width: unset; + max-width: unset; + width: 100%; + } + + .user-info { + flex-direction: column; + text-align: center; + + .user-details { + width: 100%; + + .current-role { + justify-content: center; + } + } + } + + mat-dialog-actions { + flex-direction: column; + gap: 8px; + + button { + width: 100%; + } + } + } +} + +// =========================== +// Dark Mode Support +// =========================== +@media (prefers-color-scheme: dark) { + .role-update-dialog { + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --bg-primary: #1e1e1e; + --bg-secondary: #2a2a2a; + --divider-color: #404040; + } +} diff --git a/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.ts b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.ts new file mode 100644 index 0000000..4316a16 --- /dev/null +++ b/frontend/src/app/features/admin/role-update-dialog/role-update-dialog.component.ts @@ -0,0 +1,132 @@ +import { Component, Inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { FormsModule } from '@angular/forms'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { AdminUser } from '../../../core/models/admin.model'; + +/** + * Dialog data interface + */ +export interface RoleUpdateDialogData { + user: AdminUser; +} + +/** + * RoleUpdateDialogComponent + * + * Modal dialog for updating user role between User and Admin. + * + * Features: + * - Role selector (User/Admin) + * - Current role display + * - Warning message when demoting admin + * - Confirmation step before applying change + * - Loading state during update + * - Returns selected role or null on cancel + */ +@Component({ + selector: 'app-role-update-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatRadioModule, + FormsModule, + MatProgressSpinnerModule + ], + templateUrl: './role-update-dialog.component.html', + styleUrl: './role-update-dialog.component.scss' +}) +export class RoleUpdateDialogComponent { + // Selected role (initialize with current role) + selectedRole: 'user' | 'admin'; + + // Component state + readonly isLoading = signal(false); + readonly showConfirmation = signal(false); + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: RoleUpdateDialogData + ) { + this.selectedRole = data.user.role; + } + + /** + * Check if role has changed + */ + get hasRoleChanged(): boolean { + return this.selectedRole !== this.data.user.role; + } + + /** + * Check if demoting from admin to user + */ + get isDemotingAdmin(): boolean { + return this.data.user.role === 'admin' && this.selectedRole === 'user'; + } + + /** + * Check if promoting to admin + */ + get isPromotingToAdmin(): boolean { + return this.data.user.role === 'user' && this.selectedRole === 'admin'; + } + + /** + * Get role display label + */ + getRoleLabel(role: 'user' | 'admin'): string { + return role === 'admin' ? 'Administrator' : 'Regular User'; + } + + /** + * Get role description + */ + getRoleDescription(role: 'user' | 'admin'): string { + if (role === 'admin') { + return 'Full access to admin panel, user management, and system settings'; + } + return 'Standard user access with quiz and profile management'; + } + + /** + * Handle next button click + * Shows confirmation if role changed, otherwise closes dialog + */ + onNext(): void { + if (!this.hasRoleChanged) { + this.dialogRef.close(null); + return; + } + + this.showConfirmation.set(true); + } + + /** + * Go back to role selection + */ + onBack(): void { + this.showConfirmation.set(false); + } + + /** + * Confirm role update + */ + onConfirm(): void { + this.dialogRef.close(this.selectedRole); + } + + /** + * Cancel and close dialog + */ + onCancel(): void { + this.dialogRef.close(null); + } +} diff --git a/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.html b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.html new file mode 100644 index 0000000..7c3ce00 --- /dev/null +++ b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.html @@ -0,0 +1,91 @@ +
+ +
+ {{ dialogIcon }} +

{{ actionVerb }} User Account

+
+ + + + + + + +
+ {{ data.action === 'activate' ? 'info' : 'warning' }} +
+
+ @if (data.action === 'activate') { + Reactivate Account + } @else { + Deactivate Account + } +
+
+ @if (data.action === 'activate') { + Are you sure you want to activate {{ data.user.username }}'s account? + } @else { + Are you sure you want to deactivate {{ data.user.username }}'s account? + } +
+
+
+ + +
+
This action will:
+
    + @for (consequence of consequences; track consequence) { +
  • {{ consequence }}
  • + } +
+
+ + + @if (data.action === 'deactivate') { +
+ info +
+ Note: This is a soft delete. User data is preserved and the account can be reactivated at any time. +
+
+ } @else { +
+ check_circle +
+ Note: The user will be able to access their account immediately after activation. +
+
+ } +
+ + + + + + + +
diff --git a/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.scss b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.scss new file mode 100644 index 0000000..3530703 --- /dev/null +++ b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.scss @@ -0,0 +1,387 @@ +.status-dialog { + display: flex; + flex-direction: column; + gap: 0; + min-width: 400px; + max-width: 550px; + + @media (max-width: 768px) { + min-width: 280px; + max-width: 100%; + } +} + +// =========================== +// Dialog Header +// =========================== +.dialog-header { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 24px 16px; + border-bottom: 2px solid; + margin: 0 0 20px 0; + + .dialog-icon { + font-size: 32px; + width: 32px; + height: 32px; + } + + h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + } + + &.activate-header { + border-bottom-color: var(--mat-accent-main, #00bcd4); + + .dialog-icon { + color: var(--mat-accent-main, #00bcd4); + } + + h2 { + color: var(--mat-accent-main, #00bcd4); + } + } + + &.deactivate-header { + border-bottom-color: var(--mat-warn-main, #f44336); + + .dialog-icon { + color: var(--mat-warn-main, #f44336); + } + + h2 { + color: var(--mat-warn-main, #f44336); + } + } +} + +// =========================== +// Dialog Content +// =========================== +mat-dialog-content { + padding: 0 24px 20px; + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + max-height: 60vh; +} + +// =========================== +// User Info +// =========================== +.user-info { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background-color: var(--mat-app-surface-variant, #f5f5f5); + border-radius: 8px; + + .user-avatar { + flex-shrink: 0; + + img { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--mat-app-primary, #1976d2); + } + + .avatar-placeholder { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, var(--mat-app-primary, #1976d2), var(--mat-app-accent, #00bcd4)); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: 600; + border: 2px solid var(--mat-app-primary, #1976d2); + } + } + + .user-details { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + + .username { + font-size: 18px; + font-weight: 600; + color: var(--mat-app-on-surface, #212121); + } + + .email { + font-size: 14px; + color: var(--mat-app-on-surface-variant, #757575); + } + + .role-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + width: fit-content; + margin-top: 4px; + + &.role-admin { + background-color: rgba(255, 152, 0, 0.1); + color: #ff9800; + border: 1px solid rgba(255, 152, 0, 0.3); + } + + &.role-user { + background-color: rgba(33, 150, 243, 0.1); + color: #2196f3; + border: 1px solid rgba(33, 150, 243, 0.3); + } + } + } + + @media (max-width: 768px) { + flex-direction: column; + text-align: center; + gap: 12px; + + .user-avatar { + img, + .avatar-placeholder { + width: 48px; + height: 48px; + } + + .avatar-placeholder { + font-size: 20px; + } + } + + .user-details { + align-items: center; + + .username { + font-size: 16px; + } + + .email { + font-size: 13px; + } + } + } +} + +// =========================== +// Warning Box +// =========================== +.warning-box { + display: flex; + gap: 12px; + padding: 16px; + border-radius: 8px; + border-left: 4px solid; + + mat-icon { + flex-shrink: 0; + font-size: 24px; + width: 24px; + height: 24px; + } + + .warning-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + + .warning-title { + font-size: 16px; + font-weight: 600; + } + + .warning-message { + font-size: 14px; + line-height: 1.5; + + strong { + font-weight: 600; + } + } + } + + &.activate-warning { + background-color: rgba(0, 188, 212, 0.1); + border-left-color: var(--mat-accent-main, #00bcd4); + + mat-icon { + color: var(--mat-accent-main, #00bcd4); + } + + .warning-title { + color: var(--mat-accent-dark, #0097a7); + } + + .warning-message { + color: var(--mat-app-on-surface, #212121); + } + } + + &.deactivate-warning { + background-color: rgba(244, 67, 54, 0.1); + border-left-color: var(--mat-warn-main, #f44336); + + mat-icon { + color: var(--mat-warn-main, #f44336); + } + + .warning-title { + color: var(--mat-warn-dark, #d32f2f); + } + + .warning-message { + color: var(--mat-app-on-surface, #212121); + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + text-align: center; + } +} + +// =========================== +// Consequences +// =========================== +.consequences { + display: flex; + flex-direction: column; + gap: 12px; + + .consequences-title { + font-size: 15px; + font-weight: 600; + color: var(--mat-app-on-surface, #212121); + } + + .consequences-list { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 8px; + + li { + font-size: 14px; + line-height: 1.5; + color: var(--mat-app-on-surface-variant, #757575); + } + } +} + +// =========================== +// Info Box +// =========================== +.info-box { + display: flex; + gap: 12px; + padding: 12px 16px; + background-color: rgba(33, 150, 243, 0.1); + border-left: 4px solid var(--mat-app-primary, #1976d2); + border-radius: 8px; + + mat-icon { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--mat-app-primary, #1976d2); + } + + .info-content { + flex: 1; + font-size: 13px; + line-height: 1.5; + color: var(--mat-app-on-surface-variant, #757575); + + strong { + font-weight: 600; + color: var(--mat-app-on-surface, #212121); + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + text-align: center; + } +} + +// =========================== +// Dialog Actions +// =========================== +mat-dialog-actions { + padding: 16px 24px; + border-top: 1px solid var(--mat-app-outline-variant, #e0e0e0); + margin: 0; + gap: 12px; + + button { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + font-size: 14px; + font-weight: 500; + } + } + + @media (max-width: 768px) { + flex-direction: column-reverse; + gap: 8px; + + button { + width: 100%; + justify-content: center; + } + } +} + +// =========================== +// Dark Mode Support +// =========================== +@media (prefers-color-scheme: dark) { + .user-info { + background-color: rgba(255, 255, 255, 0.05); + } + + .warning-box { + &.activate-warning { + background-color: rgba(0, 188, 212, 0.15); + } + + &.deactivate-warning { + background-color: rgba(244, 67, 54, 0.15); + } + } + + .info-box { + background-color: rgba(33, 150, 243, 0.15); + } +} diff --git a/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.ts b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.ts new file mode 100644 index 0000000..663d8a8 --- /dev/null +++ b/frontend/src/app/features/admin/status-update-dialog/status-update-dialog.component.ts @@ -0,0 +1,109 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { AdminUser } from '../../../core/models/admin.model'; + +/** + * Dialog data interface + */ +export interface StatusUpdateDialogData { + user: AdminUser; + action: 'activate' | 'deactivate'; +} + +/** + * StatusUpdateDialogComponent + * + * Confirmation dialog for activating or deactivating user accounts. + * + * Features: + * - Clear warning message based on action + * - User information display + * - Consequences explanation + * - Confirm/Cancel buttons + * - Different colors for activate (success) vs deactivate (warn) + */ +@Component({ + selector: 'app-status-update-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule + ], + templateUrl: './status-update-dialog.component.html', + styleUrl: './status-update-dialog.component.scss' +}) +export class StatusUpdateDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: StatusUpdateDialogData + ) {} + + /** + * Get action verb (present tense) + */ + get actionVerb(): string { + return this.data.action === 'activate' ? 'Activate' : 'Deactivate'; + } + + /** + * Get action verb (past tense) + */ + get actionVerbPast(): string { + return this.data.action === 'activate' ? 'activated' : 'deactivated'; + } + + /** + * Get dialog icon based on action + */ + get dialogIcon(): string { + return this.data.action === 'activate' ? 'check_circle' : 'block'; + } + + /** + * Get button color based on action + */ + get buttonColor(): 'accent' | 'warn' { + return this.data.action === 'activate' ? 'accent' : 'warn'; + } + + /** + * Get consequences list based on action + */ + get consequences(): string[] { + if (this.data.action === 'activate') { + return [ + 'User will regain access to their account', + 'Can login and use the platform normally', + 'All previous data will be restored', + 'Quiz history and bookmarks remain intact' + ]; + } else { + return [ + 'User will lose access to their account immediately', + 'Cannot login until account is reactivated', + 'All sessions will be terminated', + 'Data is preserved but inaccessible to user', + 'User will not receive any notifications' + ]; + } + } + + /** + * Confirm action + */ + onConfirm(): void { + this.dialogRef.close(true); + } + + /** + * Cancel action + */ + onCancel(): void { + this.dialogRef.close(false); + } +} diff --git a/frontend/src/app/features/bookmarks/bookmarks.component.html b/frontend/src/app/features/bookmarks/bookmarks.component.html new file mode 100644 index 0000000..0de580d --- /dev/null +++ b/frontend/src/app/features/bookmarks/bookmarks.component.html @@ -0,0 +1,263 @@ +
+ +
+
+ +
+

My Bookmarks

+

{{ stats().total }} saved questions

+
+
+ + @if (filteredBookmarks().length > 0) { + + } +
+ + + @if (isLoading()) { +
+ +

Loading your bookmarks...

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

Failed to Load Bookmarks

+

{{ error() }}

+ +
+ } + + + @if (!isLoading() && !error()) { + +
+ + +
+ sentiment_satisfied +
+
+ {{ stats().byDifficulty.easy }} + Easy +
+
+
+ + + +
+ sentiment_neutral +
+
+ {{ stats().byDifficulty.medium }} + Medium +
+
+
+ + + +
+ sentiment_dissatisfied +
+
+ {{ stats().byDifficulty.hard }} + Hard +
+
+
+
+ + + + +
+ + + Search bookmarks + + search + @if (searchQuery()) { + + } + + + + + Category + + All Categories + @for (category of categories(); track category.id) { + {{ category.name }} + } + + category + + + + + Difficulty + + All Difficulties + @for (difficulty of difficulties; track difficulty) { + + {{ difficulty | titlecase }} + + } + + filter_list + + + + @if (searchQuery() || selectedCategory() || selectedDifficulty()) { + + } +
+
+
+ + + @if (allBookmarks().length === 0) { +
+ bookmark_border +

No Bookmarks Yet

+

Start bookmarking questions while taking quizzes to build your study collection.

+ +
+ } + + + @if (allBookmarks().length > 0 && filteredBookmarks().length === 0) { +
+ search_off +

No Matching Bookmarks

+

Try adjusting your filters or search query.

+ +
+ } + + + @if (filteredBookmarks().length > 0) { +
+ @for (bookmark of filteredBookmarks(); track bookmark.id) { + + +
+
+ {{ getDifficultyIcon(bookmark.question.difficulty) }} + {{ bookmark.question.difficulty | titlecase }} +
+ +
+
+ + +

+ {{ truncateText(bookmark.question.questionText, 200) }} +

+ +
+ + category + {{ bookmark.question.categoryName }} + + + @if (bookmark.question.tags && bookmark.question.tags.length > 0) { + + label + {{ bookmark.question.tags.slice(0, 2).join(', ') }} + @if (bookmark.question.tags.length > 2) { + +{{ bookmark.question.tags.length - 2 }} + } + + } + + + stars + {{ bookmark.question.points }} pts + +
+ +
+ schedule + Bookmarked {{ formatDate(bookmark.createdAt) }} +
+
+ + + + +
+ } +
+ } + } +
diff --git a/frontend/src/app/features/bookmarks/bookmarks.component.scss b/frontend/src/app/features/bookmarks/bookmarks.component.scss new file mode 100644 index 0000000..3cb600c --- /dev/null +++ b/frontend/src/app/features/bookmarks/bookmarks.component.scss @@ -0,0 +1,561 @@ +.bookmarks-container { + max-width: 1400px; + margin: 0 auto; + padding: 24px; + min-height: calc(100vh - 64px); + + @media (max-width: 768px) { + padding: 16px; + } +} + +// Header +.bookmarks-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + gap: 16px; + + .header-content { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + + .back-button { + color: #666; + transition: color 0.3s; + + &:hover { + color: #1a237e; + } + } + + .header-text { + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: #1a237e; + + @media (max-width: 768px) { + font-size: 1.5rem; + } + } + + .subtitle { + margin: 4px 0 0; + font-size: 0.875rem; + color: #666; + } + } + } + + .practice-button { + height: 48px; + padding: 0 24px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); + transition: all 0.3s; + + mat-icon { + margin-right: 8px; + } + + &:hover { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + transform: translateY(-2px); + } + + @media (max-width: 768px) { + padding: 0 16px; + font-size: 0.875rem; + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + + .practice-button { + width: 100%; + } + } +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 1.5rem; + + p { + color: #666; + 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: #f44336; + } + + h2 { + margin: 0; + color: #333; + } + + p { + color: #666; + margin: 0.5rem 0 1.5rem; + } +} + +// Statistics Section +.stats-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; + + .stat-card { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 12px; + transition: all 0.3s; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); + } + + mat-card-content { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + + .stat-icon { + width: 56px; + height: 56px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: white; + } + + &.easy { + background: linear-gradient(135deg, #4caf50, #8bc34a); + } + + &.medium { + background: linear-gradient(135deg, #ff9800, #ffc107); + } + + &.hard { + background: linear-gradient(135deg, #f44336, #ff5722); + } + } + + .stat-info { + display: flex; + flex-direction: column; + + .stat-value { + font-size: 2rem; + font-weight: 700; + color: #333; + line-height: 1; + } + + .stat-label { + font-size: 0.875rem; + color: #666; + margin-top: 4px; + } + } + } + } +} + +// Filters Section +.filters-card { + margin-bottom: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 12px; + + mat-card-content { + padding: 20px; + + .filters-row { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + + .search-field { + flex: 2; + min-width: 250px; + } + + .filter-field { + flex: 1; + min-width: 150px; + } + + .reset-button { + height: 56px; + min-width: 100px; + + mat-icon { + margin-right: 4px; + } + } + + @media (max-width: 768px) { + .search-field, + .filter-field { + flex: 1 1 100%; + width: 100%; + } + + .reset-button { + flex: 1 1 100%; + width: 100%; + } + } + } + } +} + +// 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: #667eea; + opacity: 0.5; + margin-bottom: 1rem; + } + + h2 { + margin: 0 0 1rem; + color: #333; + font-size: 1.75rem; + } + + p { + color: #666; + margin-bottom: 2rem; + font-size: 1rem; + } + + button { + mat-icon { + margin-right: 8px; + } + } +} + +// Bookmarks Grid +.bookmarks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 24px; + animation: fadeIn 0.5s ease-in; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 16px; + } +} + +// Bookmark Card +.bookmark-card { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 12px; + transition: all 0.3s; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + + &:hover { + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + transform: translateY(-4px); + + .remove-button { + opacity: 1; + } + } + + mat-card-header { + padding: 16px 16px 0; + + .card-header-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + .difficulty-badge { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.difficulty-easy { + background: #e8f5e9; + color: #2e7d32; + } + + &.difficulty-medium { + background: #fff3e0; + color: #e65100; + } + + &.difficulty-hard { + background: #ffebee; + color: #c62828; + } + } + + .remove-button { + opacity: 0.6; + transition: all 0.3s; + color: #f44336; + + &:hover { + opacity: 1; + background: #ffebee; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + } + } + + mat-card-content { + padding: 16px; + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + + .question-text { + color: #333; + font-size: 0.938rem; + line-height: 1.6; + margin: 0; + flex: 1; + } + + .question-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + + mat-chip { + height: 28px; + font-size: 0.75rem; + border-radius: 14px; + background: #f5f5f5; + color: #666; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + margin-right: 4px; + } + + &.category-chip { + background: #e3f2fd; + color: #1976d2; + } + + &.tags-chip { + background: #f3e5f5; + color: #7b1fa2; + } + + &.points-chip { + background: #fff3e0; + color: #e65100; + } + } + } + + .bookmark-date { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: #999; + margin-top: auto; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + } + + mat-card-actions { + padding: 0 16px 16px; + margin: 0; + border-top: 1px solid #f0f0f0; + padding-top: 12px; + + button { + mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } + } + } +} + +// Animations +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .bookmarks-container { + .bookmarks-header { + .header-text h1 { + color: #90caf9; + } + + .header-text .subtitle { + color: #bbb; + } + } + + .error-container h2, + .empty-state h2 { + color: #e0e0e0; + } + + .error-container p, + .empty-state p, + .loading-container p { + color: #bbb; + } + + .stat-card, + .filters-card, + .bookmark-card { + background: #1e1e1e; + color: #e0e0e0; + + .question-text { + color: #e0e0e0; + } + + mat-card-actions { + border-top-color: #333; + } + } + + .bookmark-card { + .difficulty-badge { + &.difficulty-easy { + background: #1b5e20; + color: #a5d6a7; + } + + &.difficulty-medium { + background: #e65100; + color: #ffcc80; + } + + &.difficulty-hard { + background: #b71c1c; + color: #ef9a9a; + } + } + + .question-meta mat-chip { + background: #2a2a2a; + color: #bbb; + + &.category-chip { + background: #0d47a1; + color: #90caf9; + } + + &.tags-chip { + background: #4a148c; + color: #ce93d8; + } + + &.points-chip { + background: #e65100; + color: #ffcc80; + } + } + + .bookmark-date { + color: #777; + } + } + } +} diff --git a/frontend/src/app/features/bookmarks/bookmarks.component.ts b/frontend/src/app/features/bookmarks/bookmarks.component.ts new file mode 100644 index 0000000..0c4ddb5 --- /dev/null +++ b/frontend/src/app/features/bookmarks/bookmarks.component.ts @@ -0,0 +1,275 @@ +import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +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 { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Subject, takeUntil } from 'rxjs'; +import { BookmarkService } from '../../core/services/bookmark.service'; +import { AuthService } from '../../core/services/auth.service'; +import { QuizService } from '../../core/services/quiz.service'; +import { ToastService } from '../../core/services/toast.service'; +import { Bookmark } from '../../core/models/bookmark.model'; + +@Component({ + selector: 'app-bookmarks', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterLink, + MatCardModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MatProgressSpinnerModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatTooltipModule + ], + templateUrl: './bookmarks.component.html', + styleUrls: ['./bookmarks.component.scss'] +}) +export class BookmarksComponent implements OnInit, OnDestroy { + private bookmarkService = inject(BookmarkService); + private authService = inject(AuthService); + private quizService = inject(QuizService); + private toastService = inject(ToastService); + private router = inject(Router); + private destroy$ = new Subject(); + + // Signals + searchQuery = signal(''); + selectedCategory = signal(null); + selectedDifficulty = signal(null); + isRemoving = signal>(new Set()); + + // Get bookmarks from service + isLoading = this.bookmarkService.isLoading; + error = this.bookmarkService.error; + allBookmarks = this.bookmarkService.bookmarksState; + + // Current user + currentUser = this.authService.getCurrentUser(); + + // Computed filtered bookmarks + filteredBookmarks = computed(() => { + let bookmarks = this.allBookmarks(); + + // Apply search filter + const query = this.searchQuery(); + if (query.trim()) { + bookmarks = this.bookmarkService.searchBookmarks(query); + } + + // Apply category filter + const category = this.selectedCategory(); + if (category) { + bookmarks = bookmarks.filter(b => b.question.categoryId === category); + } + + // Apply difficulty filter + const difficulty = this.selectedDifficulty(); + if (difficulty) { + bookmarks = bookmarks.filter(b => b.question.difficulty === difficulty); + } + + return bookmarks; + }); + + // Categories for filter + categories = computed(() => this.bookmarkService.getCategories()); + + // Difficulty levels + difficulties = ['easy', 'medium', 'hard']; + + // Statistics + stats = computed(() => { + const bookmarks = this.allBookmarks(); + return { + total: bookmarks.length, + byDifficulty: { + easy: bookmarks.filter(b => b.question.difficulty === 'easy').length, + medium: bookmarks.filter(b => b.question.difficulty === 'medium').length, + hard: bookmarks.filter(b => b.question.difficulty === 'hard').length + } + }; + }); + + ngOnInit(): void { + if (!this.currentUser) { + this.toastService.error('Please log in to view bookmarks'); + this.router.navigate(['/login']); + return; + } + + this.loadBookmarks(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Load bookmarks + */ + loadBookmarks(forceRefresh = false): void { + if (!this.currentUser) return; + + this.bookmarkService.getBookmarks(this.currentUser.id, forceRefresh) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + error: (error) => { + console.error('Error loading bookmarks:', error); + } + }); + } + + /** + * Remove bookmark + */ + removeBookmark(questionId: string, event: Event): void { + event.stopPropagation(); + + if (!this.currentUser) return; + + // Add to removing set to show loading spinner + this.isRemoving.update(set => { + const newSet = new Set(set); + newSet.add(questionId); + return newSet; + }); + + this.bookmarkService.removeBookmark(this.currentUser.id, questionId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isRemoving.update(set => { + const newSet = new Set(set); + newSet.delete(questionId); + return newSet; + }); + }, + error: (error) => { + console.error('Error removing bookmark:', error); + this.isRemoving.update(set => { + const newSet = new Set(set); + newSet.delete(questionId); + return newSet; + }); + } + }); + } + + /** + * Check if bookmark is being removed + */ + isRemovingBookmark(questionId: string): boolean { + return this.isRemoving().has(questionId); + } + + /** + * Practice bookmarked questions + */ + practiceBookmarkedQuestions(): void { + const bookmarks = this.filteredBookmarks(); + + if (bookmarks.length === 0) { + this.toastService.warning('No bookmarks to practice'); + return; + } + + // Navigate to quiz setup with bookmarked questions + // For now, just show a message + this.toastService.info(`Starting quiz with ${bookmarks.length} bookmarked questions`); + + // TODO: Implement quiz from bookmarks + // this.router.navigate(['/quiz/setup'], { + // queryParams: { bookmarks: 'true' } + // }); + } + + /** + * View question details + */ + viewQuestion(bookmark: Bookmark): void { + // Navigate to question detail or quiz review + // For now, just show a toast + this.toastService.info('Question detail view coming soon'); + } + + /** + * Reset filters + */ + resetFilters(): void { + this.searchQuery.set(''); + this.selectedCategory.set(null); + this.selectedDifficulty.set(null); + } + + /** + * Get difficulty badge class + */ + getDifficultyClass(difficulty: string): string { + switch (difficulty) { + case 'easy': + return 'difficulty-easy'; + case 'medium': + return 'difficulty-medium'; + case 'hard': + return 'difficulty-hard'; + default: + return ''; + } + } + + /** + * Get difficulty icon + */ + getDifficultyIcon(difficulty: string): string { + switch (difficulty) { + case 'easy': + return 'sentiment_satisfied'; + case 'medium': + return 'sentiment_neutral'; + case 'hard': + return 'sentiment_dissatisfied'; + default: + return 'help_outline'; + } + } + + /** + * Truncate text + */ + truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + + /** + * Format date + */ + formatDate(date: string): string { + const d = new Date(date); + const now = new Date(); + const diffMs = now.getTime() - d.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`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; + return `${Math.floor(diffDays / 365)} years ago`; + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.html b/frontend/src/app/features/dashboard/dashboard.component.html index dba7808..7cb2a0b 100644 --- a/frontend/src/app/features/dashboard/dashboard.component.html +++ b/frontend/src/app/features/dashboard/dashboard.component.html @@ -24,10 +24,16 @@

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

Ready to test your knowledge today?

- +
+ + +
diff --git a/frontend/src/app/features/dashboard/dashboard.component.scss b/frontend/src/app/features/dashboard/dashboard.component.scss index dd8d9ea..c5eeb43 100644 --- a/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/frontend/src/app/features/dashboard/dashboard.component.scss @@ -78,9 +78,14 @@ } } - .start-quiz-btn { - background-color: white; - color: var(--color-primary); + .welcome-actions { + display: flex; + gap: 1rem; + flex-wrap: wrap; + } + + .start-quiz-btn, + .profile-btn { font-weight: 600; padding: 0 2rem; height: 48px; @@ -94,6 +99,36 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } } + + .start-quiz-btn { + background-color: white; + color: var(--color-primary); + } + + .profile-btn { + background-color: transparent; + color: white; + border-color: white; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + + .welcome-actions { + width: 100%; + + button { + flex: 1; + min-width: 0; + } + } + } } // Empty State diff --git a/frontend/src/app/features/profile/profile-settings.component.html b/frontend/src/app/features/profile/profile-settings.component.html new file mode 100644 index 0000000..443cdfb --- /dev/null +++ b/frontend/src/app/features/profile/profile-settings.component.html @@ -0,0 +1,277 @@ +
+
+ +

Profile Settings

+
+ + + + +
+ + + Update Your Profile + + + +
+ + + Username + + person + {{ getErrorMessage(profileForm, 'username') }} + Letters, numbers, and underscores only (3-30 characters) + + + + + Email Address + + email + {{ getErrorMessage(profileForm, 'email') }} + + + +
+ + + +
+
+
+
+ + + @if (currentUser) { + + + Account Information + + +
+ Account ID: + {{ currentUser.id }} +
+
+ Role: + + {{ currentUser.role }} + +
+
+ Member Since: + {{ currentUser.createdAt | date:'medium' }} +
+
+ Last Updated: + {{ currentUser.updatedAt | date:'medium' }} +
+
+
+ } +
+
+ + + +
+ + + Change Your Password + + + +
+ + + Current Password + + lock + + {{ getErrorMessage(passwordForm, 'currentPassword') }} + + + + + + + New Password + + lock_open + + {{ getErrorMessage(passwordForm, 'newPassword') }} + At least 8 characters with uppercase, lowercase, number, and special character + + + + @if (passwordForm.get('newPassword')?.value) { +
+
+
+
+ + {{ getPasswordStrength(passwordForm.get('newPassword')?.value).label }} + +
+ } + + + + Confirm New Password + + lock_outline + + {{ getErrorMessage(passwordForm, 'confirmPassword') }} + + + + @if (hasFormError(passwordForm, 'passwordMismatch')) { +
+ error + Passwords do not match +
+ } + + +
+ + + +
+
+
+
+ + + + + + security + Security Tips + + + +
    +
  • Use a strong, unique password that you don't use anywhere else
  • +
  • Include a mix of uppercase and lowercase letters, numbers, and symbols
  • +
  • Avoid using personal information like your name or birthday
  • +
  • Consider using a password manager to generate and store passwords
  • +
  • Change your password regularly, especially if you suspect unauthorized access
  • +
+
+
+
+
+
+
diff --git a/frontend/src/app/features/profile/profile-settings.component.scss b/frontend/src/app/features/profile/profile-settings.component.scss new file mode 100644 index 0000000..363ea60 --- /dev/null +++ b/frontend/src/app/features/profile/profile-settings.component.scss @@ -0,0 +1,448 @@ +.profile-settings-container { + max-width: 900px; + margin: 0 auto; + padding: 24px; + min-height: calc(100vh - 64px); + + @media (max-width: 768px) { + padding: 16px; + } +} + +.settings-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + + h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + color: #1a237e; + + @media (max-width: 768px) { + font-size: 1.5rem; + } + } + + .back-button { + color: #666; + transition: color 0.3s; + + &:hover { + color: #1a237e; + } + } +} + +.settings-tabs { + ::ng-deep .mat-mdc-tab-labels { + background: #fff; + border-radius: 8px 8px 0 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + ::ng-deep .mat-mdc-tab-label { + font-size: 1rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0 32px; + min-width: 160px; + + @media (max-width: 768px) { + font-size: 0.875rem; + padding: 0 16px; + min-width: 120px; + } + } + + ::ng-deep .mat-mdc-tab-body-content { + padding: 0; + } +} + +.tab-content { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px 0; + + @media (max-width: 768px) { + gap: 16px; + padding: 16px 0; + } +} + +.settings-card, +.info-card, +.tips-card { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 8px; + overflow: hidden; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + mat-card-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px 24px; + margin: 0 0 24px 0; + + mat-card-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 24px; + height: 24px; + width: 24px; + } + } + } + + mat-card-content { + padding: 0 24px 24px; + + @media (max-width: 768px) { + padding: 0 16px 16px; + } + } +} + +// Form Styles +form { + display: flex; + flex-direction: column; + gap: 20px; + + .full-width { + width: 100%; + } + + mat-form-field { + ::ng-deep .mat-mdc-form-field-subscript-wrapper { + margin-top: 4px; + } + + mat-icon[matPrefix] { + margin-right: 8px; + color: #666; + } + } + + mat-divider { + margin: 16px 0; + } +} + +// Action Buttons +.action-buttons { + display: flex; + gap: 12px; + margin-top: 8px; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + + button { + width: 100%; + } + } + + .save-button { + display: flex; + align-items: center; + gap: 8px; + padding: 0 24px; + height: 48px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); + transition: all 0.3s; + + &:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + mat-spinner { + margin-right: 8px; + } + + mat-icon { + font-size: 20px; + height: 20px; + width: 20px; + } + } + + button[mat-button] { + height: 48px; + font-weight: 500; + } +} + +// Password Strength Indicator +.password-strength { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + + .strength-bar { + flex: 1; + height: 8px; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; + + .strength-fill { + height: 100%; + transition: width 0.3s, background-color 0.3s; + border-radius: 4px; + + &.warn { + background: linear-gradient(90deg, #f44336, #ff5722); + } + + &.accent { + background: linear-gradient(90deg, #ff9800, #ffc107); + } + + &.primary { + background: linear-gradient(90deg, #4caf50, #8bc34a); + } + } + } + + .strength-label { + font-size: 0.875rem; + font-weight: 600; + min-width: 60px; + text-align: right; + + &.warn { + color: #f44336; + } + + &.accent { + color: #ff9800; + } + + &.primary { + color: #4caf50; + } + } +} + +// Form Error +.form-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #ffebee; + color: #c62828; + border-radius: 4px; + font-size: 0.875rem; + margin-top: -8px; + margin-bottom: 8px; + animation: slideDown 0.3s ease; + + mat-icon { + font-size: 20px; + height: 20px; + width: 20px; + } +} + +// Info Card +.info-card { + .info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #e0e0e0; + + &:last-child { + border-bottom: none; + } + + .info-label { + font-weight: 500; + color: #666; + font-size: 0.875rem; + } + + .info-value { + font-weight: 600; + color: #333; + text-align: right; + + &.role-badge { + padding: 4px 12px; + border-radius: 12px; + background: #e3f2fd; + color: #1976d2; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + + &.admin { + background: #fce4ec; + color: #c2185b; + } + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 4px; + + .info-value { + text-align: left; + } + } + } +} + +// Tips Card +.tips-card { + mat-card-header { + background: linear-gradient(135deg, #43a047 0%, #66bb6a 100%); + } + + .tips-list { + margin: 0; + padding-left: 20px; + list-style: none; + + li { + position: relative; + padding: 8px 0 8px 24px; + color: #555; + font-size: 0.875rem; + line-height: 1.6; + + &::before { + content: '✓'; + position: absolute; + left: 0; + color: #43a047; + font-weight: bold; + font-size: 1rem; + } + } + } +} + +// Animations +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +// Loading State +button[disabled] { + cursor: not-allowed; + opacity: 0.6; +} + +mat-spinner { + display: inline-block; +} + +// Responsive Design +@media (max-width: 768px) { + .profile-settings-container { + padding: 12px; + } + + .settings-card, + .info-card, + .tips-card { + mat-card-header { + padding: 16px; + } + + mat-card-content { + padding: 0 16px 16px; + } + } + + form { + gap: 16px; + } +} + +// Dark Mode Support +@media (prefers-color-scheme: dark) { + .profile-settings-container { + .settings-header h1 { + color: #90caf9; + } + + .settings-card, + .info-card, + .tips-card { + background: #1e1e1e; + color: #e0e0e0; + + mat-card-header { + background: linear-gradient(135deg, #1a237e 0%, #283593 100%); + } + } + + .info-card .info-row { + border-bottom-color: #333; + + .info-label { + color: #bbb; + } + + .info-value { + color: #e0e0e0; + } + } + + .tips-card .tips-list li { + color: #bbb; + } + + .form-error { + background: #3e2723; + color: #ff8a80; + } + } +} diff --git a/frontend/src/app/features/profile/profile-settings.component.ts b/frontend/src/app/features/profile/profile-settings.component.ts new file mode 100644 index 0000000..3148763 --- /dev/null +++ b/frontend/src/app/features/profile/profile-settings.component.ts @@ -0,0 +1,347 @@ +import { Component, OnInit, OnDestroy, signal, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; +import { Router, RouterLink } 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTabsModule } from '@angular/material/tabs'; +import { Subject, takeUntil } from 'rxjs'; +import { AuthService } from '../../core/services/auth.service'; +import { UserService } from '../../core/services/user.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-profile-settings', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + RouterLink, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatDividerModule, + MatTabsModule + ], + templateUrl: './profile-settings.component.html', + styleUrls: ['./profile-settings.component.scss'] +}) +export class ProfileSettingsComponent implements OnInit, OnDestroy { + private fb = inject(FormBuilder); + private authService = inject(AuthService); + private userService = inject(UserService); + private toastService = inject(ToastService); + private router = inject(Router); + private destroy$ = new Subject(); + + // Signals + isLoading = signal(false); + showCurrentPassword = signal(false); + showNewPassword = signal(false); + showConfirmPassword = signal(false); + + // Forms + profileForm!: FormGroup; + passwordForm!: FormGroup; + + // Current user + currentUser = this.authService.getCurrentUser(); + + ngOnInit(): void { + if (!this.currentUser) { + this.toastService.error('Please log in to access settings'); + this.router.navigate(['/login']); + return; + } + + this.initForms(); + this.prefillProfileForm(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Initialize forms + */ + private initForms(): void { + // Profile form + this.profileForm = this.fb.group({ + username: [ + '', + [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(30), + Validators.pattern(/^[a-zA-Z0-9_]+$/) + ] + ], + email: [ + '', + [ + Validators.required, + Validators.email, + Validators.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) + ] + ] + }); + + // Password form + this.passwordForm = this.fb.group({ + currentPassword: ['', [Validators.required, Validators.minLength(6)]], + newPassword: [ + '', + [ + Validators.required, + Validators.minLength(8), + Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) + ] + ], + confirmPassword: ['', [Validators.required]] + }, { + validators: this.passwordMatchValidator + }); + } + + /** + * Pre-fill profile form with current user data + */ + private prefillProfileForm(): void { + if (this.currentUser) { + this.profileForm.patchValue({ + username: this.currentUser.username, + email: this.currentUser.email + }); + } + } + + /** + * Custom validator for password match + */ + private passwordMatchValidator(control: AbstractControl): ValidationErrors | null { + const newPassword = control.get('newPassword')?.value; + const confirmPassword = control.get('confirmPassword')?.value; + + if (newPassword && confirmPassword && newPassword !== confirmPassword) { + return { passwordMismatch: true }; + } + + return null; + } + + /** + * Save profile changes + */ + saveProfile(): void { + if (this.profileForm.invalid || !this.currentUser) { + this.profileForm.markAllAsTouched(); + return; + } + + const formValue = this.profileForm.value; + + // Check if anything changed + const hasChanges = + formValue.username !== this.currentUser.username || + formValue.email !== this.currentUser.email; + + if (!hasChanges) { + this.toastService.info('No changes to save'); + return; + } + + this.isLoading.set(true); + + this.userService.updateProfile(this.currentUser.id, formValue) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isLoading.set(false); + this.toastService.success('Profile updated successfully'); + + // Reload page to reflect changes + setTimeout(() => { + window.location.reload(); + }, 1000); + }, + error: (error) => { + this.isLoading.set(false); + console.error('Error updating profile:', error); + + // Error handling is done in the service + if (error.status === 409) { + // Highlight the specific field causing conflict + const message = error.error?.message?.toLowerCase() || ''; + if (message.includes('username')) { + this.profileForm.get('username')?.setErrors({ taken: true }); + } + if (message.includes('email')) { + this.profileForm.get('email')?.setErrors({ taken: true }); + } + } + } + }); + } + + /** + * Change password + */ + changePassword(): void { + if (this.passwordForm.invalid || !this.currentUser) { + this.passwordForm.markAllAsTouched(); + return; + } + + const { currentPassword, newPassword } = this.passwordForm.value; + + this.isLoading.set(true); + + this.userService.updateProfile(this.currentUser.id, { + currentPassword, + newPassword + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.isLoading.set(false); + this.toastService.success('Password changed successfully'); + this.passwordForm.reset(); + }, + error: (error) => { + this.isLoading.set(false); + console.error('Error changing password:', error); + + if (error.status === 401) { + this.passwordForm.get('currentPassword')?.setErrors({ incorrect: true }); + this.toastService.error('Current password is incorrect'); + } + } + }); + } + + /** + * Cancel profile changes + */ + cancelProfile(): void { + this.prefillProfileForm(); + this.profileForm.markAsPristine(); + this.profileForm.markAsUntouched(); + } + + /** + * Cancel password changes + */ + cancelPassword(): void { + this.passwordForm.reset(); + this.passwordForm.markAsPristine(); + this.passwordForm.markAsUntouched(); + } + + /** + * Toggle password visibility + */ + togglePasswordVisibility(field: 'current' | 'new' | 'confirm'): void { + if (field === 'current') { + this.showCurrentPassword.update(val => !val); + } else if (field === 'new') { + this.showNewPassword.update(val => !val); + } else { + this.showConfirmPassword.update(val => !val); + } + } + + /** + * Get form control error message + */ + getErrorMessage(formGroup: FormGroup, controlName: string): string { + const control = formGroup.get(controlName); + + if (!control || !control.errors || !control.touched) { + return ''; + } + + const errors = control.errors; + + // Username errors + if (controlName === 'username') { + if (errors['required']) return 'Username is required'; + if (errors['minlength']) return 'Username must be at least 3 characters'; + if (errors['maxlength']) return 'Username must not exceed 30 characters'; + if (errors['pattern']) return 'Username can only contain letters, numbers, and underscores'; + if (errors['taken']) return 'Username is already taken'; + } + + // Email errors + if (controlName === 'email') { + if (errors['required']) return 'Email is required'; + if (errors['email'] || errors['pattern']) return 'Please enter a valid email address'; + if (errors['taken']) return 'Email is already registered'; + } + + // Password errors + if (controlName === 'currentPassword') { + if (errors['required']) return 'Current password is required'; + if (errors['minlength']) return 'Password must be at least 6 characters'; + if (errors['incorrect']) return 'Current password is incorrect'; + } + + if (controlName === 'newPassword') { + if (errors['required']) return 'New password is required'; + if (errors['minlength']) return 'Password must be at least 8 characters'; + if (errors['pattern']) return 'Password must contain uppercase, lowercase, number, and special character'; + } + + if (controlName === 'confirmPassword') { + if (errors['required']) return 'Please confirm your password'; + if (formGroup.errors?.['passwordMismatch']) return 'Passwords do not match'; + } + + return 'Invalid input'; + } + + /** + * Check if form group has specific error + */ + hasFormError(formGroup: FormGroup, errorName: string): boolean { + return formGroup.errors?.[errorName] && formGroup.touched; + } + + /** + * Get password strength + */ + getPasswordStrength(password: string): { strength: number; label: string; color: string } { + if (!password) { + return { strength: 0, label: '', color: '' }; + } + + let strength = 0; + + // Length + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + + // Complexity + if (/[a-z]/.test(password)) strength++; + if (/[A-Z]/.test(password)) strength++; + if (/\d/.test(password)) strength++; + if (/[@$!%*?&]/.test(password)) strength++; + + if (strength <= 2) { + return { strength: 33, label: 'Weak', color: 'warn' }; + } else if (strength <= 4) { + return { strength: 66, label: 'Medium', color: 'accent' }; + } else { + return { strength: 100, label: 'Strong', color: 'primary' }; + } + } +} diff --git a/frontend/src/app/features/quiz/quiz-question/quiz-question.html b/frontend/src/app/features/quiz/quiz-question/quiz-question.html index 8024b74..1d62d37 100644 --- a/frontend/src/app/features/quiz/quiz-question/quiz-question.html +++ b/frontend/src/app/features/quiz/quiz-question/quiz-question.html @@ -153,8 +153,10 @@ Submitting... } @else { - send - Submit Answer + + send + Submit Answer + } } @else { @@ -164,11 +166,15 @@ color="primary" (click)="nextQuestion()"> @if (isLastQuestion()) { - flag - Complete Quiz + + flag + Complete Quiz + } @else { - arrow_forward - Next Question + + arrow_forward + Next Question + } } diff --git a/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html index ae29c1f..06433b6 100644 --- a/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html +++ b/frontend/src/app/features/quiz/quiz-setup/quiz-setup.html @@ -189,8 +189,10 @@ Starting... } @else { - play_arrow - Start Quiz + + play_arrow + Start Quiz + } diff --git a/frontend/src/app/shared/components/back-button/back-button.component.ts b/frontend/src/app/shared/components/back-button/back-button.component.ts new file mode 100644 index 0000000..3967ebf --- /dev/null +++ b/frontend/src/app/shared/components/back-button/back-button.component.ts @@ -0,0 +1,126 @@ +import { Component, inject, input } from '@angular/core'; +import { CommonModule, Location } from '@angular/common'; +import { Router, RouterModule } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +/** + * BackButtonComponent + * + * A reusable back navigation button that uses browser history or custom route. + * + * Features: + * - Uses browser history by default + * - Supports custom fallback route + * - Configurable button style (icon, text, or both) + * - Tooltip support + * - Keyboard accessible + * - Responsive design + * + * Usage: + * ```html + * + * + * + * + * + * + * + * + * + * + * + * ``` + */ +@Component({ + selector: 'app-back-button', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatButtonModule, + MatIconModule, + MatTooltipModule + ], + template: ` + + `, + styles: [` + button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: auto; + + mat-icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + } + + &.icon-only { + padding: 0.5rem; + min-width: 40px; + } + } + + // Focus styles + button:focus-visible { + outline: 2px solid var(--primary-color, #1976d2); + outline-offset: 2px; + } + `] +}) +export class BackButtonComponent { + private location = inject(Location); + private router = inject(Router); + + /** + * Custom route to navigate to if history is empty + */ + fallbackRoute = input('/'); + + /** + * Whether to show text label + */ + showText = input(true); + + /** + * Button label text + */ + label = input('Back'); + + /** + * Icon to display + */ + icon = input('arrow_back'); + + /** + * Tooltip text + */ + tooltip = input('Go back'); + + /** + * Navigate back using browser history or fallback route + */ + goBack(): void { + // Check if there's history to go back to + if (window.history.length > 1) { + this.location.back(); + } else { + // No history, use fallback route + this.router.navigate([this.fallbackRoute()]); + } + } +} diff --git a/frontend/src/app/shared/components/breadcrumb/breadcrumb.component.ts b/frontend/src/app/shared/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000..31d617d --- /dev/null +++ b/frontend/src/app/shared/components/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,298 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, NavigationEnd, ActivatedRoute, RouterModule } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { filter, distinctUntilChanged, map } from 'rxjs/operators'; + +export interface Breadcrumb { + label: string; + url: string; + icon?: string; +} + +/** + * BreadcrumbComponent + * + * Displays a breadcrumb trail showing the current navigation path. + * + * Features: + * - Automatically generates breadcrumbs from route hierarchy + * - Shows home icon for root + * - Supports custom labels via route data + * - Clickable navigation + * - Responsive design + * - ARIA labels for accessibility + * + * Usage in route config: + * ```typescript + * { + * path: 'categories/:id', + * component: CategoryDetailComponent, + * data: { breadcrumb: 'Category Detail' } + * } + * ``` + */ +@Component({ + selector: 'app-breadcrumb', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatIconModule + ], + template: ` + + `, + styles: [` + .breadcrumb-container { + padding: 0.75rem 0; + background: transparent; + } + + .breadcrumb-list { + display: flex; + flex-wrap: wrap; + align-items: center; + list-style: none; + margin: 0; + padding: 0; + gap: 0.5rem; + } + + .breadcrumb-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + + &:first-child { + .breadcrumb-link mat-icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + } + } + } + + .breadcrumb-link { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--text-secondary); + text-decoration: none; + transition: color 0.2s ease; + padding: 0.25rem 0.5rem; + border-radius: 4px; + + &:hover { + color: var(--primary-color); + background-color: var(--hover-background); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + } + + .breadcrumb-current { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--text-primary); + font-weight: 500; + padding: 0.25rem 0.5rem; + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + } + + .separator { + font-size: 1rem; + width: 1rem; + height: 1rem; + color: var(--text-disabled); + } + + // Responsive + @media (max-width: 768px) { + .breadcrumb-list { + gap: 0.25rem; + } + + .breadcrumb-item { + font-size: 0.8125rem; + } + + .breadcrumb-link span, + .breadcrumb-current span { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + // Dark mode + @media (prefers-color-scheme: dark) { + .breadcrumb-container { + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-disabled: #606060; + --primary-color: #2196f3; + --hover-background: rgba(255, 255, 255, 0.08); + } + } + + // Light mode + @media (prefers-color-scheme: light) { + .breadcrumb-container { + --text-primary: #212121; + --text-secondary: #757575; + --text-disabled: #bdbdbd; + --primary-color: #1976d2; + --hover-background: rgba(0, 0, 0, 0.04); + } + } + `] +}) +export class BreadcrumbComponent implements OnInit { + private router = inject(Router); + private activatedRoute = inject(ActivatedRoute); + + breadcrumbs: Breadcrumb[] = []; + + ngOnInit(): void { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + distinctUntilChanged() + ) + .subscribe(() => { + this.breadcrumbs = this.buildBreadcrumbs(this.activatedRoute.root); + }); + + // Initial breadcrumb + this.breadcrumbs = this.buildBreadcrumbs(this.activatedRoute.root); + } + + /** + * Build breadcrumbs from route tree + */ + private buildBreadcrumbs( + route: ActivatedRoute, + url: string = '', + breadcrumbs: Breadcrumb[] = [] + ): Breadcrumb[] { + // Add home breadcrumb if this is the first item + if (breadcrumbs.length === 0) { + breadcrumbs.push({ + label: 'Home', + url: '/', + icon: 'home' + }); + } + + // Get the child routes + const children: ActivatedRoute[] = route.children; + + // Return if there are no more children + if (children.length === 0) { + return breadcrumbs; + } + + // Iterate over each child + for (const child of children) { + // Get the route's URL segment + const routeURL: string = child.snapshot.url + .map(segment => segment.path) + .join('/'); + + // Skip empty path routes + if (routeURL === '') { + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + // Append route URL to URL + url += `/${routeURL}`; + + // Get breadcrumb label from route data or generate from URL + const label = child.snapshot.data['breadcrumb'] || this.formatLabel(routeURL); + + // Skip if label is explicitly set to null (hide breadcrumb) + if (label === null) { + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + // Add breadcrumb + const breadcrumb: Breadcrumb = { + label, + url + }; + + // Don't add duplicate breadcrumbs + if (!breadcrumbs.find(b => b.url === url)) { + breadcrumbs.push(breadcrumb); + } + + // Recursive call + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + return breadcrumbs; + } + + /** + * Format URL segment into readable label + */ + private formatLabel(segment: string): string { + // Handle numeric IDs + if (/^\d+$/.test(segment)) { + return 'Details'; + } + + // Handle UUIDs or long strings + if (segment.length > 20) { + return 'Details'; + } + + // Convert kebab-case or snake_case to Title Case + return segment + .replace(/[-_]/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } +} diff --git a/frontend/src/app/shared/components/error/error.component.ts b/frontend/src/app/shared/components/error/error.component.ts new file mode 100644 index 0000000..5b965f7 --- /dev/null +++ b/frontend/src/app/shared/components/error/error.component.ts @@ -0,0 +1,379 @@ +import { Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCardModule } from '@angular/material/card'; +import { Router } from '@angular/router'; + +/** + * Error Component + * Displays critical errors with retry and navigation options + * Used for full-page error states (500, network errors, etc.) + */ +@Component({ + selector: 'app-error', + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatCardModule + ], + template: ` +
+ + + +
+ + {{ getIcon() }} + +
+ + +

{{ title() }}

+ + +

{{ message() }}

+ + + @if (errorCode()) { +

Error Code: {{ errorCode() }}

+ } + + +
+ @if (showRetry()) { + + } + + @if (showReload()) { + + } + + + + @if (showBack()) { + + } +
+ + + @if (showDetails() && errorDetails()) { +
+ + + @if (detailsExpanded) { +
+
{{ errorDetails() }}
+
+ } +
+ } + + + @if (showSupport()) { +
+

If the problem persists, please contact support.

+
+ } +
+
+
+ `, + styles: [` + .error-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 1rem; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + } + + .error-card { + max-width: 600px; + width: 100%; + text-align: center; + padding: 2rem; + } + + .error-icon { + margin-bottom: 1.5rem; + } + + .error-icon mat-icon { + font-size: 80px; + width: 80px; + height: 80px; + } + + .error-icon-500 { + color: #f44336; /* Red for server errors */ + } + + .error-icon-404 { + color: #ff9800; /* Orange for not found */ + } + + .error-icon-403 { + color: #ff9800; /* Orange for forbidden */ + } + + .error-icon-401 { + color: #ff9800; /* Orange for unauthorized */ + } + + .error-icon-network { + color: #9e9e9e; /* Gray for network errors */ + } + + .error-icon-default { + color: #f44336; /* Red for generic errors */ + } + + .error-title { + font-size: 2rem; + font-weight: 600; + color: #333; + margin-bottom: 1rem; + } + + .error-message { + font-size: 1.125rem; + color: #666; + margin-bottom: 1rem; + line-height: 1.6; + } + + .error-code { + font-size: 0.875rem; + color: #999; + font-family: 'Courier New', monospace; + margin-bottom: 1.5rem; + } + + .error-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + margin: 2rem 0; + } + + .error-actions button { + min-width: 140px; + } + + .error-details { + margin-top: 2rem; + text-align: left; + } + + .details-toggle { + width: 100%; + justify-content: center; + margin-bottom: 1rem; + } + + .details-content { + background: #f5f5f5; + border-radius: 4px; + padding: 1rem; + max-height: 300px; + overflow-y: auto; + } + + .details-content pre { + margin: 0; + font-size: 0.875rem; + color: #333; + white-space: pre-wrap; + word-break: break-word; + } + + .support-message { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid #e0e0e0; + } + + .support-message p { + color: #666; + font-size: 0.875rem; + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + .error-container { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + } + + .error-card { + background: #1e1e1e; + color: #e0e0e0; + } + + .error-title { + color: #e0e0e0; + } + + .error-message { + color: #b0b0b0; + } + + .details-content { + background: #2a2a2a; + } + + .details-content pre { + color: #e0e0e0; + } + + .support-message { + border-top-color: #333; + } + } + + /* Responsive design */ + @media (max-width: 768px) { + .error-card { + padding: 1.5rem; + } + + .error-icon mat-icon { + font-size: 60px; + width: 60px; + height: 60px; + } + + .error-title { + font-size: 1.5rem; + } + + .error-message { + font-size: 1rem; + } + + .error-actions { + flex-direction: column; + } + + .error-actions button { + width: 100%; + } + } + `] +}) +export class ErrorComponent { + // Input signals + title = input('Something Went Wrong'); + message = input('An unexpected error occurred. Please try again or contact support if the problem persists.'); + errorCode = input(null); + errorType = input<'500' | '404' | '403' | '401' | 'network' | 'default'>('default'); + errorDetails = input(null); + showRetry = input(true); + showReload = input(false); + showBack = input(true); + showDetails = input(false); + showSupport = input(true); + + // Output events + retry = output(); + + // Local state + detailsExpanded = false; + + constructor(private router: Router) {} + + /** + * Get appropriate icon based on error type + */ + getIcon(): string { + switch (this.errorType()) { + case '500': + return 'report_problem'; + case '404': + return 'search_off'; + case '403': + return 'lock'; + case '401': + return 'person_off'; + case 'network': + return 'cloud_off'; + default: + return 'error_outline'; + } + } + + /** + * Toggle error details visibility + */ + toggleDetails(): void { + this.detailsExpanded = !this.detailsExpanded; + } + + /** + * Emit retry event + */ + onRetry(): void { + this.retry.emit(); + } + + /** + * Reload the page + */ + reloadPage(): void { + window.location.reload(); + } + + /** + * Navigate to home page + */ + goHome(): void { + this.router.navigate(['/']); + } + + /** + * Navigate back in history + */ + goBack(): void { + if (window.history.length > 1) { + window.history.back(); + } else { + this.goHome(); + } + } +} diff --git a/frontend/src/app/shared/components/header/header.html b/frontend/src/app/shared/components/header/header.html index aa09fac..933ed29 100644 --- a/frontend/src/app/shared/components/header/header.html +++ b/frontend/src/app/shared/components/header/header.html @@ -15,6 +15,11 @@ Interview Quiz + +
+ +
+
diff --git a/frontend/src/app/shared/components/header/header.scss b/frontend/src/app/shared/components/header/header.scss index 03cd420..588c76f 100644 --- a/frontend/src/app/shared/components/header/header.scss +++ b/frontend/src/app/shared/components/header/header.scss @@ -59,6 +59,17 @@ } } +.search-wrapper { + flex: 1; + max-width: 600px; + margin: 0 var(--spacing-lg); + + @media (max-width: 1024px) { + max-width: 400px; + margin: 0 var(--spacing-md); + } +} + .spacer { flex: 1; } diff --git a/frontend/src/app/shared/components/header/header.ts b/frontend/src/app/shared/components/header/header.ts index 1895c66..79f059e 100644 --- a/frontend/src/app/shared/components/header/header.ts +++ b/frontend/src/app/shared/components/header/header.ts @@ -17,6 +17,7 @@ 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'; +import { SearchComponent } from '../search/search.component'; @Component({ selector: 'app-header', @@ -31,7 +32,8 @@ import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dia MatDividerModule, MatDialogModule, MatProgressSpinnerModule, - MatChipsModule + MatChipsModule, + SearchComponent ], templateUrl: './header.html', styleUrl: './header.scss' diff --git a/frontend/src/app/shared/components/loading-spinner/loading-spinner.html b/frontend/src/app/shared/components/loading-spinner/loading-spinner.html index 108e463..177d9c4 100644 --- a/frontend/src/app/shared/components/loading-spinner/loading-spinner.html +++ b/frontend/src/app/shared/components/loading-spinner/loading-spinner.html @@ -1,6 +1,11 @@ -
+
-

{{ message() }}

+
diff --git a/frontend/src/app/shared/components/pagination/pagination.component.ts b/frontend/src/app/shared/components/pagination/pagination.component.ts new file mode 100644 index 0000000..b59d193 --- /dev/null +++ b/frontend/src/app/shared/components/pagination/pagination.component.ts @@ -0,0 +1,334 @@ +import { Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { PaginationState } from '../../../core/services/pagination.service'; + +/** + * Pagination Component + * Reusable pagination controls with customizable options + * + * Features: + * - Previous/Next buttons + * - First/Last page buttons + * - Page number buttons with active state + * - "Showing X-Y of Z results" display + * - Page size selector + * - Responsive design (fewer buttons on mobile) + * - Accessible with keyboard navigation and ARIA labels + * + * @example + * + * + */ +@Component({ + selector: 'app-pagination', + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + MatSelectModule, + MatFormFieldModule + ], + template: ` +
+ +
+ + Showing {{ state()!.startIndex }} + to {{ state()!.endIndex }} + of {{ state()!.totalItems }} {{ itemLabel() }} + +
+ +
+ + @if (showPageSizeSelector()) { + + + @for (option of pageSizeOptions(); track option) { + {{ option }} per page + } + + + } + + +
+ + @if (showFirstLast()) { + + } + + + + + +
+ @for (page of pageNumbers(); track page) { + @if (page === '...') { + {{ page }} + } @else { + + } + } +
+ + + + + + @if (showFirstLast()) { + + } +
+
+
+ `, + styles: [` + .pagination-container { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: var(--surface-color, #fff); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .pagination-info { + display: flex; + align-items: center; + justify-content: center; + } + + .info-text { + font-size: 0.875rem; + color: var(--text-secondary, #666); + } + + .info-text strong { + color: var(--text-primary, #333); + font-weight: 600; + } + + .pagination-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + + .page-size-selector { + min-width: 140px; + } + + .page-size-selector ::ng-deep .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .pagination-controls { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .page-numbers { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .page-button { + min-width: 40px; + height: 40px; + padding: 0 8px; + border-radius: 4px; + transition: all 0.2s ease; + } + + .page-button:hover { + background-color: var(--hover-color, rgba(0, 0, 0, 0.04)); + } + + .page-button.active { + background-color: var(--primary-color, #1976d2); + color: white; + font-weight: 600; + } + + .page-button.active:hover { + background-color: var(--primary-dark, #1565c0); + } + + .ellipsis { + padding: 0 8px; + color: var(--text-secondary, #666); + user-select: none; + } + + /* Responsive design */ + @media (max-width: 768px) { + .pagination-container { + padding: 0.75rem; + } + + .pagination-actions { + flex-direction: column; + align-items: stretch; + } + + .page-size-selector { + width: 100%; + } + + .pagination-controls { + justify-content: center; + } + + .page-numbers { + gap: 0.125rem; + } + + .page-button { + min-width: 36px; + height: 36px; + font-size: 0.875rem; + } + + /* Hide ellipsis on very small screens */ + @media (max-width: 480px) { + .ellipsis { + display: none; + } + } + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + .pagination-container { + background: var(--surface-dark, #1e1e1e); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .info-text { + color: var(--text-secondary-dark, #b0b0b0); + } + + .info-text strong { + color: var(--text-primary-dark, #e0e0e0); + } + + .page-button:hover { + background-color: rgba(255, 255, 255, 0.08); + } + + .ellipsis { + color: var(--text-secondary-dark, #b0b0b0); + } + } + + /* Focus styles for accessibility */ + .page-button:focus-visible, + button:focus-visible { + outline: 2px solid var(--primary-color, #1976d2); + outline-offset: 2px; + } + + /* Disabled state */ + button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + `] +}) +export class PaginationComponent { + // Input signals + state = input.required(); + pageNumbers = input<(number | string)[]>([]); + pageSizeOptions = input([10, 25, 50, 100]); + showPageSizeSelector = input(true); + showFirstLast = input(true); + maxVisiblePages = input(5); + itemLabel = input('results'); + + // Output events + pageChange = output(); + pageSizeChange = output(); + + /** + * Handle page change + */ + onPageChange(page: number): void { + if (page >= 1 && page <= (this.state()?.totalPages ?? 1)) { + this.pageChange.emit(page); + } + } + + /** + * Handle page button click (for page numbers) + */ + handlePageClick(page: number | string): void { + if (typeof page === 'number') { + this.onPageChange(page); + } + } + + /** + * Handle page size change + */ + onPageSizeChange(pageSize: number): void { + this.pageSizeChange.emit(pageSize); + } +} diff --git a/frontend/src/app/shared/components/search/search.component.html b/frontend/src/app/shared/components/search/search.component.html new file mode 100644 index 0000000..68714ab --- /dev/null +++ b/frontend/src/app/shared/components/search/search.component.html @@ -0,0 +1,166 @@ +
+ +
+ + search + + + @if (searchQuery()) { + + } + +
+ + + @if (showDropdown()) { +
+ + @if (isSearching()) { +
+ +

Searching...

+
+ } + + + @else if (isEmptySearch()) { +
+ search_off +

No results found for "{{ searchQuery() }}"

+ Try different keywords or check your spelling +
+ } + + + @else if (hasResults()) { +
+ + @if (searchResults().categories.length > 0) { +
+
+ category + Categories + {{ searchResults().categories.length }} +
+ + @for (category of searchResults().categories; track category.id) { +
+ {{ category.icon || 'category' }} +
+
+ @if (category.description) { +
{{ category.description }}
+ } +
+ chevron_right +
+ } +
+ } + + + @if (searchResults().questions.length > 0) { + @if (searchResults().categories.length > 0) { + + } + +
+
+ quiz + Questions + {{ searchResults().questions.length }} +
+ + @for (question of searchResults().questions; track question.id) { +
+ quiz +
+
+
+ @if (question.category) { + + category + {{ question.category }} + + } + @if (question.difficulty) { + + {{ question.difficulty }} + + } +
+
+ chevron_right +
+ } +
+ } + + + @if (searchResults().quizzes.length > 0) { + @if (searchResults().categories.length > 0 || searchResults().questions.length > 0) { + + } + +
+
+ assessment + Quiz History + {{ searchResults().quizzes.length }} +
+ + @for (quiz of searchResults().quizzes; track quiz.id) { +
+ assessment +
+
{{ quiz.title }}
+ @if (quiz.description) { +
{{ quiz.description }}
+ } +
+ chevron_right +
+ } +
+ } + + + + +
+ } +
+ } +
diff --git a/frontend/src/app/shared/components/search/search.component.scss b/frontend/src/app/shared/components/search/search.component.scss new file mode 100644 index 0000000..f2cdf89 --- /dev/null +++ b/frontend/src/app/shared/components/search/search.component.scss @@ -0,0 +1,352 @@ +.search-container { + position: relative; + width: 100%; + max-width: 600px; + + @media (max-width: 768px) { + max-width: 100%; + } +} + +// Search Input +.search-input-wrapper { + position: relative; + + .search-field { + width: 100%; + + ::ng-deep { + .mat-mdc-form-field-infix { + padding: 0.5rem 0; + } + + .mat-mdc-text-field-wrapper { + padding: 0; + } + + input { + font-size: 0.95rem; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + } +} + +// Search Dropdown +.search-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + background: var(--surface-color); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + max-height: 500px; + overflow-y: auto; + z-index: 1000; + animation: dropdownSlide 0.2s ease-out; + + @media (max-width: 768px) { + max-height: 400px; + } + + @keyframes dropdownSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + // Custom scrollbar + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbar-color); + border-radius: 3px; + + &:hover { + background: var(--scrollbar-hover-color); + } + } +} + +// Loading State +.search-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + gap: 1rem; + + p { + margin: 0; + font-size: 0.95rem; + color: var(--text-secondary); + } +} + +// Empty State +.search-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + gap: 0.75rem; + text-align: center; + + mat-icon { + font-size: 3rem; + width: 3rem; + height: 3rem; + color: var(--text-disabled); + } + + p { + margin: 0; + font-size: 0.95rem; + color: var(--text-primary); + + strong { + color: var(--primary-color); + } + } + + .hint { + font-size: 0.875rem; + color: var(--text-secondary); + } +} + +// Results +.search-results { + padding: 0.5rem 0; +} + +.results-section { + padding: 0.5rem 0; + + .section-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + + mat-icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + } + + .count { + margin-left: auto; + padding: 0.125rem 0.5rem; + background-color: var(--chip-background); + border-radius: 12px; + font-size: 0.75rem; + } + } +} + +// Result Item +.result-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover, + &.selected { + background-color: var(--hover-background); + } + + &.selected { + border-left: 3px solid var(--primary-color); + padding-left: calc(1rem - 3px); + } + + .result-icon { + flex-shrink: 0; + font-size: 1.5rem; + width: 1.5rem; + height: 1.5rem; + color: var(--icon-color); + } + + .result-content { + flex: 1; + min-width: 0; + + .result-title { + font-size: 0.95rem; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + margin-bottom: 0.25rem; + + // Truncate long titles + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + ::ng-deep mark { + background-color: var(--highlight-background); + color: var(--highlight-text); + padding: 0.125rem 0.25rem; + border-radius: 3px; + font-weight: 600; + } + } + + .result-description { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.4; + margin-top: 0.25rem; + + // Truncate long descriptions + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .result-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; + flex-wrap: wrap; + + .meta-item { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: var(--text-secondary); + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + } + + .difficulty-chip { + height: 20px; + font-size: 0.75rem; + padding: 0 0.5rem; + + ::ng-deep .mdc-evolution-chip__action--primary { + padding: 0 0.5rem; + } + } + } + } + + .navigate-icon { + flex-shrink: 0; + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + color: var(--text-disabled); + } +} + +// See All Link +.see-all-link { + padding: 0.5rem 1rem; + text-align: center; + + button { + width: 100%; + justify-content: center; + + mat-icon { + margin-right: 0.5rem; + } + } +} + +// Dark Mode +@media (prefers-color-scheme: dark) { + .search-container { + --surface-color: #1e1e1e; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-disabled: #606060; + --primary-color: #2196f3; + --icon-color: #90caf9; + --hover-background: rgba(255, 255, 255, 0.08); + --chip-background: rgba(255, 255, 255, 0.1); + --highlight-background: rgba(33, 150, 243, 0.3); + --highlight-text: #ffffff; + --scrollbar-color: rgba(255, 255, 255, 0.2); + --scrollbar-hover-color: rgba(255, 255, 255, 0.3); + } +} + +// Light Mode +@media (prefers-color-scheme: light) { + .search-container { + --surface-color: #ffffff; + --text-primary: #212121; + --text-secondary: #757575; + --text-disabled: #bdbdbd; + --primary-color: #1976d2; + --icon-color: #1976d2; + --hover-background: rgba(0, 0, 0, 0.04); + --chip-background: rgba(0, 0, 0, 0.08); + --highlight-background: rgba(33, 150, 243, 0.2); + --highlight-text: #0d47a1; + --scrollbar-color: rgba(0, 0, 0, 0.2); + --scrollbar-hover-color: rgba(0, 0, 0, 0.3); + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + .search-dropdown { + animation: none; + } + + .result-item { + transition: none; + } +} + +// Focus styles +.search-field { + ::ng-deep input:focus { + outline: none; + } +} + +.result-item:focus { + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} diff --git a/frontend/src/app/shared/components/search/search.component.ts b/frontend/src/app/shared/components/search/search.component.ts new file mode 100644 index 0000000..c099158 --- /dev/null +++ b/frontend/src/app/shared/components/search/search.component.ts @@ -0,0 +1,313 @@ +import { Component, OnInit, inject, signal, effect, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatChipsModule } from '@angular/material/chips'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { SearchService, SearchResultItem, SearchResultType } from '../../../core/services/search.service'; + +/** + * SearchComponent + * + * Global search component for searching across questions, categories, and quizzes. + * + * Features: + * - Debounced search input (500ms) + * - Dropdown results with grouped sections + * - Keyboard navigation (arrow keys, enter, escape) + * - Click outside to close + * - Loading indicator + * - Empty state + * - Result highlighting + * - "See All Results" link + * - Responsive design + */ +@Component({ + selector: 'app-search', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatButtonModule, + MatProgressSpinnerModule, + MatDividerModule, + MatChipsModule + ], + templateUrl: './search.component.html', + styleUrl: './search.component.scss' +}) +export class SearchComponent implements OnInit { + private readonly searchService = inject(SearchService); + private readonly router = inject(Router); + + @ViewChild('searchInput') searchInput!: ElementRef; + + // Service signals + readonly searchResults = this.searchService.searchResults; + readonly isSearching = this.searchService.isSearching; + readonly hasSearched = this.searchService.hasSearched; + + // Component state + readonly searchQuery = signal(''); + readonly showDropdown = signal(false); + readonly selectedIndex = signal(-1); + + // Search input subject for debouncing + private searchSubject = new Subject(); + + // Flattened results for keyboard navigation + private flatResults: SearchResultItem[] = []; + + ngOnInit(): void { + this.setupSearchDebounce(); + this.setupClickOutside(); + } + + /** + * Setup debounced search + */ + private setupSearchDebounce(): void { + this.searchSubject + .pipe( + debounceTime(500), + distinctUntilChanged() + ) + .subscribe((query) => { + if (query.trim().length >= 2) { + this.searchService.search(query).subscribe(() => { + this.showDropdown.set(true); + this.updateFlatResults(); + }); + } else { + this.clearSearch(); + } + }); + } + + /** + * Setup click outside to close dropdown + */ + private setupClickOutside(): void { + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + const searchContainer = document.querySelector('.search-container'); + + if (searchContainer && !searchContainer.contains(target)) { + this.closeDropdown(); + } + }); + } + + /** + * Handle search input change + */ + onSearchInput(query: string): void { + this.searchQuery.set(query); + this.searchSubject.next(query); + + if (query.trim().length < 2) { + this.closeDropdown(); + } + } + + /** + * Handle keyboard navigation + */ + onKeyDown(event: KeyboardEvent): void { + const key = event.key; + + if (!this.showDropdown()) return; + + switch (key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateDown(); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateUp(); + break; + case 'Enter': + event.preventDefault(); + this.selectCurrentResult(); + break; + case 'Escape': + event.preventDefault(); + this.closeDropdown(); + break; + } + } + + /** + * Navigate down in results + */ + private navigateDown(): void { + const maxIndex = this.flatResults.length - 1; + if (this.selectedIndex() < maxIndex) { + this.selectedIndex.update(i => i + 1); + this.scrollToSelected(); + } + } + + /** + * Navigate up in results + */ + private navigateUp(): void { + if (this.selectedIndex() > 0) { + this.selectedIndex.update(i => i - 1); + this.scrollToSelected(); + } + } + + /** + * Select current highlighted result + */ + private selectCurrentResult(): void { + const index = this.selectedIndex(); + if (index >= 0 && index < this.flatResults.length) { + this.navigateToResult(this.flatResults[index]); + } + } + + /** + * Scroll to selected result in dropdown + */ + private scrollToSelected(): void { + setTimeout(() => { + const selected = document.querySelector('.result-item.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, 0); + } + + /** + * Update flat results array for keyboard navigation + */ + private updateFlatResults(): void { + const results = this.searchResults(); + this.flatResults = [ + ...results.categories, + ...results.questions, + ...results.quizzes + ]; + this.selectedIndex.set(-1); + } + + /** + * Navigate to search result + */ + navigateToResult(result: SearchResultItem): void { + if (result.url) { + this.router.navigate([result.url]); + this.closeDropdown(); + this.clearSearch(); + } + } + + /** + * Navigate to full search results page + */ + viewAllResults(): void { + this.router.navigate(['/search'], { + queryParams: { q: this.searchQuery() } + }); + this.closeDropdown(); + } + + /** + * Clear search + */ + clearSearch(): void { + this.searchQuery.set(''); + this.searchService.clearResults(); + this.showDropdown.set(false); + this.selectedIndex.set(-1); + this.flatResults = []; + } + + /** + * Close dropdown + */ + closeDropdown(): void { + this.showDropdown.set(false); + this.selectedIndex.set(-1); + } + + /** + * Focus search input + */ + focusSearch(): void { + this.searchInput?.nativeElement.focus(); + } + + /** + * Get icon for result type + */ + getTypeIcon(type: SearchResultType): string { + switch (type) { + case 'question': + return 'quiz'; + case 'category': + return 'category'; + case 'quiz': + return 'assessment'; + default: + return 'search'; + } + } + + /** + * Get chip color for difficulty + */ + getDifficultyColor(difficulty?: string): string { + if (!difficulty) return ''; + + switch (difficulty.toLowerCase()) { + case 'easy': + return 'primary'; + case 'medium': + return 'accent'; + case 'hard': + return 'warn'; + default: + return ''; + } + } + + /** + * Check if result is selected + */ + isSelected(result: SearchResultItem): boolean { + const index = this.selectedIndex(); + if (index < 0) return false; + + return this.flatResults[index]?.id === result.id && + this.flatResults[index]?.type === result.type; + } + + /** + * Check if there are any results + */ + hasResults(): boolean { + const results = this.searchResults(); + return results.totalResults > 0; + } + + /** + * Check if search is empty + */ + isEmptySearch(): boolean { + return this.hasSearched() && !this.hasResults() && !this.isSearching(); + } +}