add changes
This commit is contained in:
@@ -499,21 +499,21 @@
|
|||||||
**Purpose:** Update user profile
|
**Purpose:** Update user profile
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `UserService.updateProfile(userId, data)` method
|
- [x] Add `UserService.updateProfile(userId, data)` method
|
||||||
- [ ] Update `authState` signal with new user data
|
- [x] Update `authState` signal with new user data
|
||||||
- [ ] Validate form data (email, username)
|
- [x] Validate form data (email, username)
|
||||||
- [ ] Handle 409 Conflict for duplicate email/username
|
- [x] Handle 409 Conflict for duplicate email/username
|
||||||
- [ ] Handle password change separately (if supported)
|
- [x] Handle password change separately (if supported)
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `ProfileSettingsComponent` for editing profile
|
- [x] Build `ProfileSettingsComponent` for editing profile
|
||||||
- [ ] Create form with username, email fields
|
- [x] Create form with username, email fields
|
||||||
- [ ] Add password change section (current, new, confirm)
|
- [x] Add password change section (current, new, confirm)
|
||||||
- [ ] Show validation errors inline
|
- [x] Show validation errors inline
|
||||||
- [ ] Add "Save Changes" and "Cancel" buttons
|
- [x] Add "Save Changes" and "Cancel" buttons
|
||||||
- [ ] Display success toast after update
|
- [x] Display success toast after update
|
||||||
- [ ] Show loading spinner on submit
|
- [x] Show loading spinner on submit
|
||||||
- [ ] Pre-fill form with current user data
|
- [x] Pre-fill form with current user data
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -523,19 +523,19 @@
|
|||||||
**Purpose:** Get user's bookmarked questions
|
**Purpose:** Get user's bookmarked questions
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create `BookmarkService` with `getBookmarks(userId)` method
|
- [x] Create `BookmarkService` with `getBookmarks(userId)` method
|
||||||
- [ ] Store bookmarks in `bookmarksState` signal
|
- [x] Store bookmarks in `bookmarksState` signal
|
||||||
- [ ] Implement caching (5 min TTL)
|
- [x] Implement caching (5 min TTL)
|
||||||
- [ ] Handle 401 if not authenticated
|
- [x] Handle 401 if not authenticated
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `BookmarksComponent` displaying all bookmarked questions
|
- [x] Build `BookmarksComponent` displaying all bookmarked questions
|
||||||
- [ ] Show question cards with text, category, difficulty
|
- [x] Show question cards with text, category, difficulty
|
||||||
- [ ] Add "Remove Bookmark" button for each question
|
- [x] Add "Remove Bookmark" button for each question
|
||||||
- [ ] Add "Practice Bookmarked Questions" button to start quiz
|
- [x] Add "Practice Bookmarked Questions" button to start quiz
|
||||||
- [ ] Show empty state if no bookmarks
|
- [x] Show empty state if no bookmarks
|
||||||
- [ ] Implement grid layout (responsive)
|
- [x] Implement grid layout (responsive)
|
||||||
- [ ] Add search/filter for bookmarks
|
- [x] Add search/filter for bookmarks
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -543,17 +543,17 @@
|
|||||||
**Purpose:** Add question to bookmarks
|
**Purpose:** Add question to bookmarks
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `BookmarkService.addBookmark(userId, questionId)` method
|
- [x] Add `BookmarkService.addBookmark(userId, questionId)` method
|
||||||
- [ ] Update `bookmarksState` signal optimistically
|
- [x] Update `bookmarksState` signal optimistically
|
||||||
- [ ] Handle 409 if already bookmarked
|
- [x] Handle 409 if already bookmarked
|
||||||
- [ ] Show success/error toast
|
- [x] Show success/error toast
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Add bookmark icon button on question cards
|
- [x] Add bookmark icon button on question cards
|
||||||
- [ ] Show filled/unfilled icon based on bookmark status
|
- [x] Show filled/unfilled icon based on bookmark status
|
||||||
- [ ] Animate icon on toggle
|
- [x] Animate icon on toggle
|
||||||
- [ ] Display success toast "Question bookmarked"
|
- [x] Display success toast "Question bookmarked"
|
||||||
- [ ] Ensure button is accessible with ARIA label
|
- [x] Ensure button is accessible with ARIA label
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -561,14 +561,14 @@
|
|||||||
**Purpose:** Remove bookmark
|
**Purpose:** Remove bookmark
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `BookmarkService.removeBookmark(userId, questionId)` method
|
- [x] Add `BookmarkService.removeBookmark(userId, questionId)` method
|
||||||
- [ ] Update `bookmarksState` signal optimistically
|
- [x] Update `bookmarksState` signal optimistically
|
||||||
- [ ] Handle 404 if bookmark not found
|
- [x] Handle 404 if bookmark not found
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Toggle bookmark icon to unfilled state
|
- [x] Toggle bookmark icon to unfilled state
|
||||||
- [ ] Remove question from bookmarks list
|
- [x] Remove question from bookmarks list
|
||||||
- [ ] Display success toast "Bookmark removed"
|
- [x] Display success toast "Bookmark removed"
|
||||||
- [ ] Add undo option in toast (optional)
|
- [ ] Add undo option in toast (optional)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -579,25 +579,25 @@
|
|||||||
**Purpose:** Get system-wide statistics
|
**Purpose:** Get system-wide statistics
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create `AdminService` with `getStatistics()` method
|
- [x] Create `AdminService` with `getStatistics()` method
|
||||||
- [ ] Store stats in `adminStatsState` signal
|
- [x] Store stats in `adminStatsState` signal
|
||||||
- [ ] Implement caching (5 min TTL)
|
- [x] Implement caching (5 min TTL)
|
||||||
- [ ] Handle 401/403 authorization errors
|
- [x] Handle 401/403 authorization errors
|
||||||
- [ ] Create admin auth guard for routes
|
- [x] Create admin auth guard for routes
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `AdminDashboardComponent` as admin landing page
|
- [x] Build `AdminDashboardComponent` as admin landing page
|
||||||
- [ ] Display statistics cards:
|
- [x] Display statistics cards:
|
||||||
- Total users
|
- Total users
|
||||||
- Active users (last 7 days)
|
- Active users (last 7 days)
|
||||||
- Total quiz sessions
|
- Total quiz sessions
|
||||||
- Total questions
|
- Total questions
|
||||||
- [ ] Show user growth chart (line chart)
|
- [x] Show user growth chart (line chart)
|
||||||
- [ ] Display most popular categories (bar chart)
|
- [x] Display most popular categories (bar chart)
|
||||||
- [ ] Show average quiz scores
|
- [x] Show average quiz scores
|
||||||
- [ ] Add date range picker for filtering stats
|
- [x] Add date range picker for filtering stats
|
||||||
- [ ] Implement responsive layout
|
- [x] Implement responsive layout
|
||||||
- [ ] Show loading skeletons for charts
|
- [x] Show loading skeletons for charts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -605,20 +605,20 @@
|
|||||||
**Purpose:** Get guest user analytics
|
**Purpose:** Get guest user analytics
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.getGuestAnalytics()` method
|
- [x] Add `AdminService.getGuestAnalytics()` method
|
||||||
- [ ] Store analytics in `guestAnalyticsState` signal
|
- [x] Store analytics in `guestAnalyticsState` signal
|
||||||
- [ ] Implement caching (10 min TTL)
|
- [x] Implement caching (10 min TTL)
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `GuestAnalyticsComponent` (admin)
|
- [x] Build `GuestAnalyticsComponent` (admin)
|
||||||
- [ ] Display guest statistics:
|
- [x] Display guest statistics:
|
||||||
- Total guest sessions
|
- Total guest sessions
|
||||||
- Active guest sessions
|
- Active guest sessions
|
||||||
- Guest-to-user conversion rate
|
- Guest-to-user conversion rate
|
||||||
- Average quizzes per guest
|
- Average quizzes per guest
|
||||||
- [ ] Show conversion funnel chart
|
- [x] Show conversion funnel chart
|
||||||
- [ ] Display guest session timeline chart
|
- [x] Display guest session timeline chart
|
||||||
- [ ] Add export functionality
|
- [x] Add export functionality
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -626,13 +626,13 @@
|
|||||||
**Purpose:** Get guest access settings
|
**Purpose:** Get guest access settings
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.getGuestSettings()` method
|
- [x] Add `AdminService.getGuestSettings()` method
|
||||||
- [ ] Store settings in `guestSettingsState` signal
|
- [x] Store settings in `guestSettingsState` signal
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `GuestSettingsComponent` (admin) for viewing settings
|
- [x] Build `GuestSettingsComponent` (admin) for viewing settings
|
||||||
- [ ] Display current settings in read-only cards
|
- [x] Display current settings in read-only cards
|
||||||
- [ ] Add "Edit Settings" button
|
- [x] Add "Edit Settings" button
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -640,22 +640,22 @@
|
|||||||
**Purpose:** Update guest access settings
|
**Purpose:** Update guest access settings
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.updateGuestSettings(data)` method
|
- [x] Add `AdminService.updateGuestSettings(data)` method
|
||||||
- [ ] Update `guestSettingsState` signal
|
- [x] Update `guestSettingsState` signal
|
||||||
- [ ] Validate form data
|
- [x] Validate form data
|
||||||
- [ ] Handle success/error responses
|
- [x] Handle success/error responses
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build settings form with fields:
|
- [x] Build settings form with fields:
|
||||||
- Guest access enabled toggle
|
- Guest access enabled toggle
|
||||||
- Max quizzes per day (number input)
|
- Max quizzes per day (number input)
|
||||||
- Max questions per quiz (number input)
|
- Max questions per quiz (number input)
|
||||||
- Session expiry hours (number input)
|
- Session expiry hours (number input)
|
||||||
- Upgrade prompt message (textarea)
|
- Upgrade prompt message (textarea)
|
||||||
- [ ] Add "Save Changes" and "Cancel" buttons
|
- [x] Add "Save Changes" and "Cancel" buttons
|
||||||
- [ ] Show validation errors inline
|
- [x] Show validation errors inline
|
||||||
- [ ] Display success toast after update
|
- [x] Display success toast after update
|
||||||
- [ ] Show preview of settings changes
|
- [x] Show preview of settings changes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -663,21 +663,21 @@
|
|||||||
**Purpose:** Get all users with pagination
|
**Purpose:** Get all users with pagination
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method
|
- [x] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method
|
||||||
- [ ] Store users in `adminUsersState` signal
|
- [x] Store users in `adminUsersState` signal
|
||||||
- [ ] Implement pagination, filtering, and sorting
|
- [x] Implement pagination, filtering, and sorting
|
||||||
- [ ] Handle query parameters in URL
|
- [x] Handle query parameters in URL
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `AdminUsersComponent` displaying user list
|
- [x] Build `AdminUsersComponent` displaying user list
|
||||||
- [ ] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions
|
- [x] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions
|
||||||
- [ ] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive)
|
- [x] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive)
|
||||||
- [ ] Add sort dropdown (Username, Email, Date)
|
- [x] Add sort dropdown (Username, Email, Date)
|
||||||
- [ ] Add search input for username/email
|
- [x] Add search input for username/email
|
||||||
- [ ] Implement pagination controls
|
- [x] Implement pagination controls
|
||||||
- [ ] Add action buttons (Edit Role, View Details, Deactivate/Activate)
|
- [x] Add action buttons (Edit Role, View Details, Deactivate/Activate)
|
||||||
- [ ] Show loading spinner during fetch
|
- [x] Show loading spinner during fetch
|
||||||
- [ ] Make table responsive (stack on mobile)
|
- [x] Make table responsive (stack on mobile)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -685,16 +685,16 @@
|
|||||||
**Purpose:** Get user details
|
**Purpose:** Get user details
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.getUserDetails(userId)` method
|
- [x] Add `AdminService.getUserDetails(userId)` method
|
||||||
- [ ] Store user details in signal
|
- [x] Store user details in signal
|
||||||
- [ ] Handle 404 if user not found
|
- [x] Handle 404 if user not found
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `AdminUserDetailComponent` showing full user profile
|
- [x] Build `AdminUserDetailComponent` showing full user profile
|
||||||
- [ ] Display user info, statistics, quiz history
|
- [x] Display user info, statistics, quiz history
|
||||||
- [ ] Add "Edit Role" and "Deactivate" buttons
|
- [x] Add "Edit Role" and "Deactivate" buttons
|
||||||
- [ ] Show user activity timeline
|
- [x] Show user activity timeline
|
||||||
- [ ] Add breadcrumb navigation
|
- [x] Add breadcrumb navigation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -702,16 +702,16 @@
|
|||||||
**Purpose:** Update user role
|
**Purpose:** Update user role
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.updateUserRole(userId, role)` method
|
- [x] Add `AdminService.updateUserRole(userId, role)` method
|
||||||
- [ ] Update user in `adminUsersState` signal
|
- [x] Update user in `adminUsersState` signal
|
||||||
- [ ] Handle validation errors
|
- [x] Handle validation errors
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build role update modal/dialog
|
- [x] Build role update modal/dialog
|
||||||
- [ ] Add role selector (User, Admin)
|
- [x] Add role selector (User, Admin)
|
||||||
- [ ] Show confirmation dialog
|
- [x] Show confirmation dialog
|
||||||
- [ ] Display success toast after update
|
- [x] Display success toast after update
|
||||||
- [ ] Show warning if demoting admin
|
- [x] Show warning if demoting admin
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -719,13 +719,13 @@
|
|||||||
**Purpose:** Reactivate user
|
**Purpose:** Reactivate user
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.activateUser(userId)` method
|
- [x] Add `AdminService.activateUser(userId)` method
|
||||||
- [ ] Update user status in signal
|
- [x] Update user status in signal
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Add "Activate" button for inactive users
|
- [x] Add "Activate" button for inactive users
|
||||||
- [ ] Show confirmation dialog
|
- [x] Show confirmation dialog
|
||||||
- [ ] Display success toast after activation
|
- [x] Display success toast after activation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -733,15 +733,15 @@
|
|||||||
**Purpose:** Deactivate user
|
**Purpose:** Deactivate user
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.deactivateUser(userId)` method
|
- [x] Add `AdminService.deactivateUser(userId)` method
|
||||||
- [ ] Update user status in signal
|
- [x] Update user status in signal
|
||||||
- [ ] Handle soft delete
|
- [x] Handle soft delete
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Add "Deactivate" button for active users
|
- [x] Add "Deactivate" button for active users
|
||||||
- [ ] Show confirmation dialog with warning message
|
- [x] Show confirmation dialog with warning message
|
||||||
- [ ] Display success toast after deactivation
|
- [x] Display success toast after deactivation
|
||||||
- [ ] Show "Reactivate" button for deactivated users
|
- [x] Show "Reactivate" button for deactivated users
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -749,13 +749,13 @@
|
|||||||
**Purpose:** Create new question
|
**Purpose:** Create new question
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.createQuestion(data)` method
|
- [x] Add `AdminService.createQuestion(data)` method
|
||||||
- [ ] Validate question data (type, options, correct answer)
|
- [x] Validate question data (type, options, correct answer)
|
||||||
- [ ] Handle 401/403 authorization errors
|
- [x] Handle 401/403 authorization errors
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `AdminQuestionFormComponent` for creating questions
|
- [x] Build `AdminQuestionFormComponent` for creating questions
|
||||||
- [ ] Create form with fields:
|
- [x] Create form with fields:
|
||||||
- Question text (textarea)
|
- Question text (textarea)
|
||||||
- Question type selector (Multiple Choice, True/False, Written)
|
- Question type selector (Multiple Choice, True/False, Written)
|
||||||
- Category selector
|
- Category selector
|
||||||
@@ -766,13 +766,13 @@
|
|||||||
- Points (number)
|
- Points (number)
|
||||||
- Tags (chip input)
|
- Tags (chip input)
|
||||||
- Guest accessible checkbox
|
- Guest accessible checkbox
|
||||||
- [ ] Show/hide options based on question type
|
- [x] Show/hide options based on question type
|
||||||
- [ ] Add dynamic option inputs for MCQ (Add/Remove buttons)
|
- [x] Add dynamic option inputs for MCQ (Add/Remove buttons)
|
||||||
- [ ] Validate correct answer matches options
|
- [x] Validate correct answer matches options
|
||||||
- [ ] Show question preview panel
|
- [x] Show question preview panel
|
||||||
- [ ] Display validation errors inline
|
- [x] Display validation errors inline
|
||||||
- [ ] Add "Save Question" and "Cancel" buttons
|
- [x] Add "Save Question" and "Cancel" buttons
|
||||||
- [ ] Show success toast after creation
|
- [x] Show success toast after creation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -780,16 +780,16 @@
|
|||||||
**Purpose:** Update question
|
**Purpose:** Update question
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.updateQuestion(id, data)` method
|
- [x] Add `AdminService.updateQuestion(id, data)` method
|
||||||
- [ ] Pre-fill form with existing question data
|
- [x] Pre-fill form with existing question data
|
||||||
- [ ] Handle 404 if question not found
|
- [x] Handle 404 if question not found
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Reuse `AdminQuestionFormComponent` in edit mode
|
- [x] Reuse `AdminQuestionFormComponent` in edit mode
|
||||||
- [ ] Pre-populate all form fields
|
- [x] Pre-populate all form fields
|
||||||
- [ ] Show "Editing: Question ID" header
|
- [x] Show "Editing: Question ID" header
|
||||||
- [ ] Add version history section (optional)
|
- [ ] Add version history section (optional)
|
||||||
- [ ] Display success toast after update
|
- [x] Display success toast after update
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -797,14 +797,14 @@
|
|||||||
**Purpose:** Delete question
|
**Purpose:** Delete question
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Add `AdminService.deleteQuestion(id)` method
|
- [x] Add `AdminService.deleteQuestion(id)` method
|
||||||
- [ ] Handle soft delete
|
- [x] Handle soft delete
|
||||||
- [ ] Update question list after deletion
|
- [x] Update question list after deletion
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Add delete button in admin question list
|
- [x] Add delete button in admin question list
|
||||||
- [ ] Show confirmation dialog with warning
|
- [x] Show confirmation dialog with warning
|
||||||
- [ ] Display success toast after deletion
|
- [x] Display success toast after deletion
|
||||||
- [ ] Add "Restore" option for soft-deleted questions
|
- [ ] Add "Restore" option for soft-deleted questions
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -814,21 +814,21 @@
|
|||||||
### Search Functionality
|
### Search Functionality
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create `SearchService` for global search
|
- [x] Create `SearchService` for global search
|
||||||
- [ ] Implement debounced search input
|
- [x] Implement debounced search input
|
||||||
- [ ] Search across questions, categories, quizzes
|
- [x] Search across questions, categories, quizzes
|
||||||
- [ ] Store search results in signal
|
- [x] Store search results in signal
|
||||||
- [ ] Handle empty search results
|
- [x] Handle empty search results
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `SearchComponent` in header/navbar
|
- [x] Build `SearchComponent` in header/navbar
|
||||||
- [ ] Create search input with icon
|
- [x] Create search input with icon
|
||||||
- [ ] Display search results dropdown
|
- [x] Display search results dropdown
|
||||||
- [ ] Highlight matching text in results
|
- [x] Highlight matching text in results
|
||||||
- [ ] Add "See All Results" link
|
- [x] Add "See All Results" link
|
||||||
- [ ] Implement keyboard navigation (arrow keys, enter)
|
- [x] Implement keyboard navigation (arrow keys, enter)
|
||||||
- [ ] Show loading indicator during search
|
- [x] Show loading indicator during search
|
||||||
- [ ] Display empty state for no results
|
- [x] Display empty state for no results
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -850,7 +850,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Social Share
|
<!-- ### Social Share
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create `ShareService` for social sharing
|
- [ ] Create `ShareService` for social sharing
|
||||||
@@ -866,62 +866,79 @@
|
|||||||
- [ ] Create shareable result card template
|
- [ ] Create shareable result card template
|
||||||
- [ ] Add privacy toggle (public/private share)
|
- [ ] Add privacy toggle (public/private share)
|
||||||
|
|
||||||
---
|
--- -->
|
||||||
|
|
||||||
### Pagination Component
|
### Pagination Component
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create reusable `PaginationService`
|
- [x] Create reusable `PaginationService`
|
||||||
- [ ] Calculate page numbers and ranges
|
- [x] Calculate page numbers and ranges
|
||||||
- [ ] Handle page change events
|
- [x] Handle page change events
|
||||||
- [ ] Update URL query parameters
|
- [x] Update URL query parameters
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build reusable `PaginationComponent`
|
- [x] Build reusable `PaginationComponent`
|
||||||
- [ ] Show Previous, Next, and page numbers
|
- [x] Show Previous, Next, and page numbers
|
||||||
- [ ] Highlight current page
|
- [x] Highlight current page
|
||||||
- [ ] Disable Previous on first page, Next on last page
|
- [x] Disable Previous on first page, Next on last page
|
||||||
- [ ] Display "Showing X-Y of Z results"
|
- [x] Display "Showing X-Y of Z results"
|
||||||
- [ ] Implement responsive design (fewer page numbers on mobile)
|
- [x] Implement responsive design (fewer page numbers on mobile)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create global error handler service
|
- [x] Create global error handler service
|
||||||
- [ ] Log errors to console and external service (optional)
|
- [x] Log errors to console and external service (optional)
|
||||||
- [ ] Display user-friendly error messages
|
- [x] Display user-friendly error messages
|
||||||
- [ ] Handle network errors gracefully
|
- [x] Handle network errors gracefully
|
||||||
- [ ] Implement retry logic for failed requests
|
- [x] Implement retry logic for failed requests
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build `ErrorComponent` for displaying errors
|
- [x] Build `ErrorComponent` for displaying errors
|
||||||
- [ ] Create error toast component
|
- [x] Create error toast component
|
||||||
- [ ] Show "Something went wrong" page for critical errors
|
- [x] Show "Something went wrong" page for critical errors
|
||||||
- [ ] Add "Retry" button for recoverable errors
|
- [x] Add "Retry" button for recoverable errors
|
||||||
- [ ] Display specific error messages (401, 403, 404, 500)
|
- [x] Display specific error messages (401, 403, 404, 500)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Loading States
|
### Loading States
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Create `LoadingService` for global loading state
|
- [x] Create `LoadingService` for global loading state
|
||||||
- [ ] Use signals for loading indicators
|
- [x] Use signals for loading indicators
|
||||||
- [ ] Show loading during HTTP requests
|
- [x] Show loading during HTTP requests
|
||||||
- [ ] Handle concurrent loading states
|
- [x] Handle concurrent loading states
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Build reusable `LoadingSpinnerComponent`
|
- [x] Build reusable `LoadingSpinnerComponent`
|
||||||
- [ ] Create skeleton loaders for lists and cards
|
- [x] Create skeleton loaders for lists and cards
|
||||||
- [ ] Show inline loading spinners on buttons
|
- [x] Show inline loading spinners on buttons
|
||||||
- [ ] Add progress bar at top of page for navigation
|
- [x] Add progress bar at top of page for navigation
|
||||||
- [ ] Ensure loading states are accessible (ARIA live regions)
|
- [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
|
<!-- ### Offline Support
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Implement service worker for PWA
|
- [ ] Implement service worker for PWA
|
||||||
@@ -956,42 +973,42 @@
|
|||||||
- [ ] Design install button/banner
|
- [ ] Design install button/banner
|
||||||
- [ ] Test PWA on mobile devices (iOS, Android)
|
- [ ] Test PWA on mobile devices (iOS, Android)
|
||||||
|
|
||||||
---
|
--- -->
|
||||||
|
|
||||||
## Routing & Navigation
|
## Routing & Navigation
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
- [ ] Configure app routes with lazy loading:
|
- [x] Configure app routes with lazy loading:
|
||||||
- `/` - Landing page (guest welcome or dashboard)
|
- [x] `/` - Landing page (guest welcome or dashboard)
|
||||||
- `/login` - Login page
|
- [x] `/login` - Login page
|
||||||
- `/register` - Register page
|
- [x] `/register` - Register page
|
||||||
- `/dashboard` - User dashboard (auth guard)
|
- [x] `/dashboard` - User dashboard (auth guard)
|
||||||
- `/categories` - Category list
|
- [x] `/categories` - Category list
|
||||||
- `/categories/:id` - Category detail
|
- [x] `/categories/:id` - Category detail
|
||||||
- `/quiz/setup` - Quiz setup
|
- [x] `/quiz/setup` - Quiz setup
|
||||||
- `/quiz/:sessionId` - Active quiz
|
- [x] `/quiz/:sessionId` - Active quiz
|
||||||
- `/quiz/:sessionId/results` - Quiz results
|
- [x] `/quiz/:sessionId/results` - Quiz results
|
||||||
- `/quiz/:sessionId/review` - Quiz review
|
- [x] `/quiz/:sessionId/review` - Quiz review
|
||||||
- `/bookmarks` - Bookmarked questions (auth guard)
|
- [x] `/bookmarks` - Bookmarked questions (auth guard)
|
||||||
- `/history` - Quiz history (auth guard)
|
- [x] `/history` - Quiz history (auth guard)
|
||||||
- `/profile` - User profile (auth guard)
|
- [x] `/profile` - User profile (auth guard)
|
||||||
- `/admin` - Admin dashboard (admin guard)
|
- [x] `/admin` - Admin dashboard (admin guard)
|
||||||
- `/admin/users` - User management (admin guard)
|
- [x] `/admin/users` - User management (admin guard)
|
||||||
- `/admin/questions` - Question management (admin guard)
|
- [x] `/admin/questions` - Question management (admin guard)
|
||||||
- `/admin/categories` - Category management (admin guard)
|
- [x] `/admin/categories` - Category management (admin guard)
|
||||||
- `/admin/settings` - Guest settings (admin guard)
|
- [x] `/admin/settings` - Guest settings (admin guard) - **Exists as /admin/guest-settings**
|
||||||
- `/admin/analytics` - Analytics (admin guard)
|
- [x] `/admin/analytics` - Analytics (admin guard)
|
||||||
- [ ] Create auth guard for protected routes
|
- [x] Create auth guard for protected routes
|
||||||
- [ ] Create admin guard for admin-only routes
|
- [x] Create admin guard for admin-only routes
|
||||||
- [ ] Create guest guard to prevent access to auth-only content
|
- [x] Create guest guard to prevent access to auth-only content
|
||||||
- [ ] Implement route preloading strategy
|
- [x] Implement route preloading strategy
|
||||||
- [ ] Handle 404 redirect
|
- [x] Handle 404 redirect
|
||||||
|
|
||||||
**UI Tasks:**
|
**UI Tasks:**
|
||||||
- [ ] Create navigation menu with links
|
- [x] Create navigation menu with links
|
||||||
- [ ] Highlight active route in navigation
|
- [x] Highlight active route in navigation
|
||||||
- [ ] Implement breadcrumb component
|
- [x] Implement breadcrumb component
|
||||||
- [ ] Add back button where appropriate
|
- [x] Add back button where appropriate
|
||||||
- [ ] Ensure smooth transitions between routes
|
- [ ] Ensure smooth transitions between routes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
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 = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter(routes),
|
provideRouter(
|
||||||
|
routes,
|
||||||
|
withPreloading(PreloadAllModules)
|
||||||
|
),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([
|
withInterceptors([
|
||||||
|
loadingInterceptor,
|
||||||
authInterceptor,
|
authInterceptor,
|
||||||
guestInterceptor,
|
guestInterceptor,
|
||||||
errorInterceptor
|
errorInterceptor
|
||||||
])
|
])
|
||||||
)
|
),
|
||||||
|
{ provide: ErrorHandler, useClass: GlobalErrorHandlerService }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
<app-loading></app-loading>
|
<app-loading></app-loading>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Navigation Progress Bar -->
|
||||||
|
@if (isNavigating()) {
|
||||||
|
<mat-progress-bar
|
||||||
|
mode="indeterminate"
|
||||||
|
class="navigation-progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Page loading">
|
||||||
|
</mat-progress-bar>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<app-toast-container></app-toast-container>
|
<app-toast-container></app-toast-container>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { authGuard, guestGuard } from './core/guards';
|
import { authGuard, guestGuard } from './core/guards';
|
||||||
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
|
||||||
export const routes: Routes = [
|
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)
|
// Authentication routes (guest only - redirect to dashboard if already logged in)
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
@@ -51,6 +74,22 @@ export const routes: Routes = [
|
|||||||
title: 'Quiz History - Quiz Platform'
|
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
|
// Quiz routes
|
||||||
{
|
{
|
||||||
path: 'quiz/setup',
|
path: 'quiz/setup',
|
||||||
@@ -73,23 +112,87 @@ export const routes: Routes = [
|
|||||||
title: 'Review Quiz - Quiz Platform'
|
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',
|
path: 'admin/categories',
|
||||||
loadComponent: () => import('./features/admin/admin-category-list/admin-category-list').then(m => m.AdminCategoryListComponent),
|
loadComponent: () => import('./features/admin/admin-category-list/admin-category-list').then(m => m.AdminCategoryListComponent),
|
||||||
|
canActivate: [adminGuard],
|
||||||
title: 'Manage Categories - Admin'
|
title: 'Manage Categories - Admin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/categories/new',
|
path: 'admin/categories/new',
|
||||||
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
|
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
|
||||||
|
canActivate: [adminGuard],
|
||||||
title: 'Create Category - Admin'
|
title: 'Create Category - Admin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/categories/edit/:id',
|
path: 'admin/categories/edit/:id',
|
||||||
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
|
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
|
||||||
|
canActivate: [adminGuard],
|
||||||
title: 'Edit Category - Admin'
|
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
|
// TODO: Add more routes as components are created
|
||||||
// - Home page (public)
|
// - Home page (public)
|
||||||
// - Quiz history (protected with authGuard)
|
// - Quiz history (protected with authGuard)
|
||||||
|
|||||||
@@ -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 Layout
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, signal, inject, OnInit, computed } from '@angular/core';
|
import { Component, signal, inject, OnInit, computed } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
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 { ToastContainerComponent } from './shared/components/toast-container/toast-container';
|
||||||
import { HeaderComponent } from './shared/components/header/header';
|
import { HeaderComponent } from './shared/components/header/header';
|
||||||
import { SidebarComponent } from './shared/components/sidebar/sidebar';
|
import { SidebarComponent } from './shared/components/sidebar/sidebar';
|
||||||
@@ -16,6 +18,7 @@ import { ToastService } from './core/services/toast.service';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
|
MatProgressBarModule,
|
||||||
ToastContainerComponent,
|
ToastContainerComponent,
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
SidebarComponent,
|
SidebarComponent,
|
||||||
@@ -39,6 +42,9 @@ export class App implements OnInit {
|
|||||||
// Signal for app initialization state
|
// Signal for app initialization state
|
||||||
isInitializing = signal<boolean>(true);
|
isInitializing = signal<boolean>(true);
|
||||||
|
|
||||||
|
// Signal for navigation loading state
|
||||||
|
isNavigating = signal<boolean>(false);
|
||||||
|
|
||||||
// Computed signal to check if user is guest
|
// Computed signal to check if user is guest
|
||||||
isGuest = computed(() => {
|
isGuest = computed(() => {
|
||||||
return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated;
|
return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated;
|
||||||
@@ -46,6 +52,24 @@ export class App implements OnInit {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initializeApp();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
47
frontend/src/app/core/guards/admin.guard.ts
Normal file
47
frontend/src/app/core/guards/admin.guard.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './auth.interceptor';
|
export * from './auth.interceptor';
|
||||||
export * from './guest.interceptor';
|
export * from './guest.interceptor';
|
||||||
export * from './error.interceptor';
|
export * from './error.interceptor';
|
||||||
|
export * from './loading.interceptor';
|
||||||
|
|||||||
27
frontend/src/app/core/interceptors/loading.interceptor.ts
Normal file
27
frontend/src/app/core/interceptors/loading.interceptor.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
280
frontend/src/app/core/models/admin.model.ts
Normal file
280
frontend/src/app/core/models/admin.model.ts
Normal file
@@ -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<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
57
frontend/src/app/core/models/bookmark.model.ts
Normal file
57
frontend/src/app/core/models/bookmark.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -70,6 +70,17 @@ export interface UserProfileUpdate {
|
|||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Profile Update Response
|
||||||
|
*/
|
||||||
|
export interface UserProfileUpdateResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookmark
|
* Bookmark
|
||||||
*/
|
*/
|
||||||
|
|||||||
815
frontend/src/app/core/services/admin.service.ts
Normal file
815
frontend/src/app/core/services/admin.service.ts
Normal file
@@ -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<string, AdminCacheEntry<any>>();
|
||||||
|
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<AdminStatistics | null>(null);
|
||||||
|
readonly isLoadingStats = signal<boolean>(false);
|
||||||
|
readonly statsError = signal<string | null>(null);
|
||||||
|
|
||||||
|
// State signals - Guest Analytics
|
||||||
|
readonly guestAnalyticsState = signal<GuestAnalytics | null>(null);
|
||||||
|
readonly isLoadingAnalytics = signal<boolean>(false);
|
||||||
|
readonly analyticsError = signal<string | null>(null);
|
||||||
|
|
||||||
|
// State signals - Guest Settings
|
||||||
|
readonly guestSettingsState = signal<GuestSettings | null>(null);
|
||||||
|
readonly isLoadingSettings = signal<boolean>(false);
|
||||||
|
readonly settingsError = signal<string | null>(null);
|
||||||
|
|
||||||
|
// State signals - User Management
|
||||||
|
readonly adminUsersState = signal<AdminUser[]>([]);
|
||||||
|
readonly isLoadingUsers = signal<boolean>(false);
|
||||||
|
readonly usersError = signal<string | null>(null);
|
||||||
|
readonly usersPagination = signal<{
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalUsers: number;
|
||||||
|
limit: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
readonly currentUserFilters = signal<UserListParams>({});
|
||||||
|
|
||||||
|
// State signals - User Detail
|
||||||
|
readonly selectedUserDetail = signal<AdminUserDetail | null>(null);
|
||||||
|
readonly isLoadingUserDetail = signal<boolean>(false);
|
||||||
|
readonly userDetailError = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Date range filter
|
||||||
|
readonly dateRangeFilter = signal<DateRangeFilter>({
|
||||||
|
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<AdminStatistics> {
|
||||||
|
const cacheKey = 'admin-statistics';
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = this.getFromCache<AdminStatistics>(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<AdminStatisticsResponse>(`${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<AdminStatistics> {
|
||||||
|
this.isLoadingStats.set(true);
|
||||||
|
this.statsError.set(null);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.get<AdminStatisticsResponse>(`${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<AdminStatistics> {
|
||||||
|
this.invalidateCache('admin-statistics');
|
||||||
|
return this.getStatistics(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get guest user analytics
|
||||||
|
* Implements 10-minute caching
|
||||||
|
*/
|
||||||
|
getGuestAnalytics(forceRefresh: boolean = false): Observable<GuestAnalytics> {
|
||||||
|
const cacheKey = 'guest-analytics';
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = this.getFromCache<GuestAnalytics>(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<GuestAnalyticsResponse>(`${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<GuestAnalytics> {
|
||||||
|
this.invalidateCache('guest-analytics');
|
||||||
|
return this.getGuestAnalytics(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data from cache if not expired
|
||||||
|
*/
|
||||||
|
private getFromCache<T>(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<T>(key: string, data: T, ttl: number = this.STATS_CACHE_TTL): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry: AdminCacheEntry<T> = {
|
||||||
|
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<never> {
|
||||||
|
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<GuestSettings> {
|
||||||
|
const cacheKey = 'guest-settings';
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = this.getFromCache<GuestSettings>(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<GuestSettingsResponse>(`${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<GuestSettings> {
|
||||||
|
this.invalidateCache('guest-settings');
|
||||||
|
return this.getGuestSettings(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update guest access settings
|
||||||
|
* Invalidates cache and updates state
|
||||||
|
*/
|
||||||
|
updateGuestSettings(data: Partial<GuestSettings>): Observable<GuestSettings> {
|
||||||
|
this.isLoadingSettings.set(true);
|
||||||
|
this.settingsError.set(null);
|
||||||
|
|
||||||
|
return this.http.put<GuestSettingsResponse>(`${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<never> {
|
||||||
|
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<AdminUserListResponse> {
|
||||||
|
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<AdminUserListResponse>(`${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<AdminUserListResponse> {
|
||||||
|
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<AdminUserDetail> {
|
||||||
|
this.isLoadingUserDetail.set(true);
|
||||||
|
this.userDetailError.set(null);
|
||||||
|
|
||||||
|
return this.http.get<AdminUserDetailResponse>(`${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<never> {
|
||||||
|
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<never> {
|
||||||
|
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<never> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
270
frontend/src/app/core/services/bookmark.service.ts
Normal file
270
frontend/src/app/core/services/bookmark.service.ts
Normal file
@@ -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<T> {
|
||||||
|
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<Bookmark[]>([]);
|
||||||
|
isLoading = signal<boolean>(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
private bookmarksCache = new Map<string, CacheEntry<Bookmark[]>>();
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
totalBookmarks = computed(() => this.bookmarksState().length);
|
||||||
|
hasBookmarks = computed(() => this.bookmarksState().length > 0);
|
||||||
|
bookmarksByCategory = computed(() => {
|
||||||
|
const bookmarks = this.bookmarksState();
|
||||||
|
const grouped = new Map<string, Bookmark[]>();
|
||||||
|
|
||||||
|
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<Bookmark[]> {
|
||||||
|
// 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<BookmarksResponse>(`${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<Bookmark> {
|
||||||
|
const request: AddBookmarkRequest = { questionId };
|
||||||
|
|
||||||
|
return this.http.post<AddBookmarkResponse>(
|
||||||
|
`${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<void> {
|
||||||
|
return this.http.delete<void>(
|
||||||
|
`${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<string, string>();
|
||||||
|
|
||||||
|
this.bookmarksState().forEach(bookmark => {
|
||||||
|
categoriesMap.set(
|
||||||
|
bookmark.question.categoryId,
|
||||||
|
bookmark.question.categoryName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(categoriesMap.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
frontend/src/app/core/services/global-error-handler.service.ts
Normal file
107
frontend/src/app/core/services/global-error-handler.service.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,5 @@ export * from './theme.service';
|
|||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './category.service';
|
export * from './category.service';
|
||||||
export * from './guest.service';
|
export * from './guest.service';
|
||||||
|
export * from './global-error-handler.service';
|
||||||
|
export * from './pagination.service';
|
||||||
|
|||||||
240
frontend/src/app/core/services/pagination.service.ts
Normal file
240
frontend/src/app/core/services/pagination.service.ts
Normal file
@@ -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<PaginationConfig>(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<T>(items: T[], currentPage: number, pageSize: number): T[] {
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
296
frontend/src/app/core/services/search.service.ts
Normal file
296
frontend/src/app/core/services/search.service.ts
Normal file
@@ -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<SearchResults>({
|
||||||
|
questions: [],
|
||||||
|
categories: [],
|
||||||
|
quizzes: [],
|
||||||
|
totalResults: 0
|
||||||
|
});
|
||||||
|
readonly isSearching = signal<boolean>(false);
|
||||||
|
readonly searchQuery = signal<string>('');
|
||||||
|
readonly hasSearched = signal<boolean>(false);
|
||||||
|
|
||||||
|
// Cache for recent searches (optional optimization)
|
||||||
|
private searchCache = new Map<string, { results: SearchResults; timestamp: number }>();
|
||||||
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform global search across all entities
|
||||||
|
*/
|
||||||
|
search(query: string): Observable<SearchResults> {
|
||||||
|
// 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<SearchResponse>(`${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<SearchResultItem[]> {
|
||||||
|
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<SearchResponse>(`${this.apiUrl}`, { params }).pipe(
|
||||||
|
switchMap((response) => of(this.transformQuestions(response.data.questions))),
|
||||||
|
catchError(() => of([]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search only categories
|
||||||
|
*/
|
||||||
|
searchCategories(query: string, limit: number = 10): Observable<SearchResultItem[]> {
|
||||||
|
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<SearchResponse>(`${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, '<mark>$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import { Router } from '@angular/router';
|
|||||||
import { catchError, tap, map } from 'rxjs/operators';
|
import { catchError, tap, map } from 'rxjs/operators';
|
||||||
import { of, Observable } from 'rxjs';
|
import { of, Observable } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
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 { ToastService } from './toast.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
interface CacheEntry<T> {
|
interface CacheEntry<T> {
|
||||||
data: T;
|
data: T;
|
||||||
@@ -19,6 +21,8 @@ export class UserService {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private storageService = inject(StorageService);
|
||||||
|
|
||||||
private readonly API_URL = `${environment.apiUrl}/users`;
|
private readonly API_URL = `${environment.apiUrl}/users`;
|
||||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
@@ -123,12 +127,23 @@ export class UserService {
|
|||||||
/**
|
/**
|
||||||
* Update user profile
|
* Update user profile
|
||||||
*/
|
*/
|
||||||
updateProfile(userId: string, data: UserProfileUpdate): Observable<any> {
|
updateProfile(userId: string, data: UserProfileUpdate): Observable<UserProfileUpdateResponse> {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
return this.http.put(`${this.API_URL}/${userId}`, data).pipe(
|
return this.http.put<UserProfileUpdateResponse>(`${this.API_URL}/${userId}`, data).pipe(
|
||||||
tap(response => {
|
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.isLoading.set(false);
|
||||||
this.toastService.success('Profile updated successfully');
|
this.toastService.success('Profile updated successfully');
|
||||||
// Invalidate dashboard cache
|
// Invalidate dashboard cache
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<div class="admin-dashboard">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>
|
||||||
|
<mat-icon>admin_panel_settings</mat-icon>
|
||||||
|
Admin Dashboard
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">System-wide statistics and analytics</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-icon-button (click)="refreshStats()" [disabled]="isLoading()" matTooltip="Refresh statistics">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range Filter -->
|
||||||
|
<mat-card class="filter-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="dateRangeForm" class="date-filter">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>date_range</mat-icon>
|
||||||
|
Filter by Date Range
|
||||||
|
</h3>
|
||||||
|
<div class="date-inputs">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Start Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="startPicker" formControlName="startDate">
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="startPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #startPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>End Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="endPicker" formControlName="endDate">
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="endPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #endPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-raised-button color="primary" (click)="applyDateFilter()"
|
||||||
|
[disabled]="!dateRangeForm.value.startDate || !dateRangeForm.value.endDate">
|
||||||
|
Apply Filter
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (hasDateFilter()) {
|
||||||
|
<button mat-raised-button (click)="clearDateFilter()">
|
||||||
|
Clear Filter
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="60"></mat-spinner>
|
||||||
|
<p>Loading statistics...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading()) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon color="warn">error_outline</mat-icon>
|
||||||
|
<h3>Failed to Load Statistics</h3>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="refreshStats()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Statistics Content -->
|
||||||
|
@if (stats() && !isLoading()) {
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<mat-card class="stat-card users-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>people</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Total Users</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(totalUsers()) }}</p>
|
||||||
|
@if (stats() && stats()!.stats.newUsersThisWeek) {
|
||||||
|
<p class="stat-detail">+{{ stats()!.stats.newUsersThisWeek }} this week</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card active-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>trending_up</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Active Users</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(activeUsers()) }}</p>
|
||||||
|
<p class="stat-detail">Last 7 days</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card quizzes-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Total Quizzes</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(totalQuizSessions()) }}</p>
|
||||||
|
@if (stats() && stats()!.stats.quizzesThisWeek) {
|
||||||
|
<p class="stat-detail">+{{ stats()!.stats.quizzesThisWeek }} this week</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card questions-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>help_outline</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Total Questions</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(totalQuestions()) }}</p>
|
||||||
|
<p class="stat-detail">In database</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Average Score Card -->
|
||||||
|
<mat-card class="score-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>bar_chart</mat-icon>
|
||||||
|
Average Quiz Score
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="score-display">
|
||||||
|
<div class="score-circle">
|
||||||
|
<span class="score-value">{{ formatPercentage(averageScore()) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="score-description">
|
||||||
|
@if (averageScore() >= 80) {
|
||||||
|
<span class="excellent">Excellent performance across all quizzes</span>
|
||||||
|
} @else if (averageScore() >= 60) {
|
||||||
|
<span class="good">Good performance overall</span>
|
||||||
|
} @else {
|
||||||
|
<span class="needs-improvement">Room for improvement</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- User Growth Chart -->
|
||||||
|
@if (userGrowthData().length > 0) {
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>show_chart</mat-icon>
|
||||||
|
User Growth Over Time
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="chart-container">
|
||||||
|
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="line-chart">
|
||||||
|
<!-- Grid lines -->
|
||||||
|
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Axes -->
|
||||||
|
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Data line -->
|
||||||
|
<path [attr.d]="getUserGrowthPath()" fill="none" stroke="#3f51b5" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
@for (point of userGrowthData(); track point.date; let i = $index) {
|
||||||
|
<circle [attr.cx]="calculateChartX(i, userGrowthData().length)"
|
||||||
|
[attr.cy]="calculateChartY(point.count, i)"
|
||||||
|
r="4" fill="#3f51b5"/>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Popular Categories Chart -->
|
||||||
|
@if (popularCategories().length > 0) {
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
Most Popular Categories
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="chart-container">
|
||||||
|
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="bar-chart">
|
||||||
|
<!-- Grid lines -->
|
||||||
|
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Axes -->
|
||||||
|
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Bars -->
|
||||||
|
@for (bar of getCategoryBars(); track bar.label) {
|
||||||
|
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
|
||||||
|
[attr.height]="bar.height" fill="#4caf50" opacity="0.8"/>
|
||||||
|
<text [attr.x]="bar.x + bar.width / 2" [attr.y]="bar.y - 5"
|
||||||
|
text-anchor="middle" font-size="12" fill="#333">{{ bar.value }}</text>
|
||||||
|
<text [attr.x]="bar.x + bar.width / 2" y="280"
|
||||||
|
text-anchor="middle" font-size="11" fill="#666">{{ bar.label }}</text>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h2>Quick Actions</h2>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button mat-raised-button color="primary" (click)="goToUsers()">
|
||||||
|
<mat-icon>people</mat-icon>
|
||||||
|
Manage Users
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="goToQuestions()">
|
||||||
|
<mat-icon>help_outline</mat-icon>
|
||||||
|
Manage Questions
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="goToAnalytics()">
|
||||||
|
<mat-icon>analytics</mat-icon>
|
||||||
|
View Analytics
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="goToSettings()">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
System Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Empty State (no data yet) -->
|
||||||
|
@if (!stats() && !isLoading() && !error()) {
|
||||||
|
<mat-card class="empty-state">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon>analytics</mat-icon>
|
||||||
|
<h3>No Statistics Available</h3>
|
||||||
|
<p>Statistics will appear here once users start taking quizzes</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>();
|
||||||
|
|
||||||
|
// 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<Date | null>(null),
|
||||||
|
endDate: new FormControl<Date | null>(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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<div class="question-form-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="form-header">
|
||||||
|
@if (isEditMode()) {
|
||||||
|
<h1>
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Question
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">Update the details below to modify the quiz question</p>
|
||||||
|
@if (questionId()) {
|
||||||
|
<p class="question-id">Question ID: {{ questionId() }}</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<h1>
|
||||||
|
<mat-icon>add_circle</mat-icon>
|
||||||
|
Create New Question
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">Fill in the details below to create a new quiz question</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-layout">
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoadingQuestion()) {
|
||||||
|
<mat-card class="form-card loading-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-icon class="loading-icon">hourglass_empty</mat-icon>
|
||||||
|
<p>Loading question data...</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else {
|
||||||
|
<!-- Form Section -->
|
||||||
|
<mat-card class="form-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="questionForm" (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Form-level Error -->
|
||||||
|
@if (getFormError()) {
|
||||||
|
<div class="form-error">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<span>{{ getFormError() }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Question Text -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Question Text</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="questionText"
|
||||||
|
placeholder="Enter your question here..."
|
||||||
|
rows="4"
|
||||||
|
required>
|
||||||
|
</textarea>
|
||||||
|
<mat-hint>Minimum 10 characters</mat-hint>
|
||||||
|
@if (getErrorMessage('questionText')) {
|
||||||
|
<mat-error>{{ getErrorMessage('questionText') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Question Type & Category Row -->
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Question Type</mat-label>
|
||||||
|
<mat-select formControlName="questionType" required>
|
||||||
|
@for (type of questionTypes; track type.value) {
|
||||||
|
<mat-option [value]="type.value">
|
||||||
|
{{ type.label }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (getErrorMessage('questionType')) {
|
||||||
|
<mat-error>{{ getErrorMessage('questionType') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Category</mat-label>
|
||||||
|
<mat-select formControlName="categoryId" required>
|
||||||
|
@if (isLoadingCategories()) {
|
||||||
|
<mat-option disabled>Loading categories...</mat-option>
|
||||||
|
} @else {
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<mat-option [value]="category.id">
|
||||||
|
{{ category.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (getErrorMessage('categoryId')) {
|
||||||
|
<mat-error>{{ getErrorMessage('categoryId') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Difficulty & Points Row -->
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Difficulty</mat-label>
|
||||||
|
<mat-select formControlName="difficulty" required>
|
||||||
|
@for (level of difficultyLevels; track level.value) {
|
||||||
|
<mat-option [value]="level.value">
|
||||||
|
{{ level.label }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (getErrorMessage('difficulty')) {
|
||||||
|
<mat-error>{{ getErrorMessage('difficulty') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Points</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
formControlName="points"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
placeholder="10"
|
||||||
|
required>
|
||||||
|
<mat-hint>Between 1 and 100</mat-hint>
|
||||||
|
@if (getErrorMessage('points')) {
|
||||||
|
<mat-error>{{ getErrorMessage('points') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Multiple Choice Options -->
|
||||||
|
@if (showOptions()) {
|
||||||
|
<div class="options-section">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>list</mat-icon>
|
||||||
|
Answer Options
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div formArrayName="options" class="options-list">
|
||||||
|
@for (option of optionsArray.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index" class="option-row">
|
||||||
|
<span class="option-label">Option {{ $index + 1 }}</span>
|
||||||
|
<mat-form-field appearance="outline" class="option-input">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="text"
|
||||||
|
[placeholder]="'Enter option ' + ($index + 1)"
|
||||||
|
required>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (optionsArray.length > 2) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
color="warn"
|
||||||
|
(click)="removeOption($index)"
|
||||||
|
matTooltip="Remove option">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (optionsArray.length < 10) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
(click)="addOption()"
|
||||||
|
class="add-option-btn">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Add Option
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Correct Answer Selection -->
|
||||||
|
<div class="correct-answer-section">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Correct Answer
|
||||||
|
</h3>
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Select Correct Answer</mat-label>
|
||||||
|
<mat-select formControlName="correctAnswer" required>
|
||||||
|
@for (optionText of getOptionTexts(); track $index) {
|
||||||
|
<mat-option [value]="optionText">
|
||||||
|
{{ optionText }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (getErrorMessage('correctAnswer')) {
|
||||||
|
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- True/False Options -->
|
||||||
|
@if (showTrueFalse()) {
|
||||||
|
<div class="correct-answer-section">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Correct Answer
|
||||||
|
</h3>
|
||||||
|
<mat-radio-group formControlName="correctAnswer" class="radio-group">
|
||||||
|
<mat-radio-button value="true">True</mat-radio-button>
|
||||||
|
<mat-radio-button value="false">False</mat-radio-button>
|
||||||
|
</mat-radio-group>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Written Answer -->
|
||||||
|
@if (selectedQuestionType() === 'written') {
|
||||||
|
<div class="correct-answer-section">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Sample Correct Answer
|
||||||
|
</h3>
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Expected Answer</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="correctAnswer"
|
||||||
|
placeholder="Enter a sample correct answer..."
|
||||||
|
rows="3"
|
||||||
|
required>
|
||||||
|
</textarea>
|
||||||
|
<mat-hint>This is a reference answer for grading</mat-hint>
|
||||||
|
@if (getErrorMessage('correctAnswer')) {
|
||||||
|
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Explanation -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Explanation</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="explanation"
|
||||||
|
placeholder="Explain why this is the correct answer..."
|
||||||
|
rows="4"
|
||||||
|
required>
|
||||||
|
</textarea>
|
||||||
|
<mat-hint>Minimum 10 characters</mat-hint>
|
||||||
|
@if (getErrorMessage('explanation')) {
|
||||||
|
<mat-error>{{ getErrorMessage('explanation') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>
|
||||||
|
<mat-icon>label</mat-icon>
|
||||||
|
Tags (Optional)
|
||||||
|
</h3>
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Add Tags</mat-label>
|
||||||
|
<mat-chip-grid #chipGrid>
|
||||||
|
@for (tag of tagsArray; track tag) {
|
||||||
|
<mat-chip-row (removed)="removeTag(tag)">
|
||||||
|
{{ tag }}
|
||||||
|
<button matChipRemove>
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-chip-row>
|
||||||
|
}
|
||||||
|
</mat-chip-grid>
|
||||||
|
<input
|
||||||
|
placeholder="Type tag and press Enter..."
|
||||||
|
[matChipInputFor]="chipGrid"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
(matChipInputTokenEnd)="addTag($event)">
|
||||||
|
<mat-hint>Press Enter or comma to add tags</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accessibility Checkboxes -->
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<mat-checkbox formControlName="isPublic">
|
||||||
|
Make question public
|
||||||
|
</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="isGuestAccessible">
|
||||||
|
Allow guest access
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="onCancel()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!isFormValid() || isSubmitting() || isLoadingQuestion()">
|
||||||
|
@if (isSubmitting()) {
|
||||||
|
<ng-container>
|
||||||
|
<mat-icon>hourglass_empty</mat-icon>
|
||||||
|
<span>{{ isEditMode() ? 'Updating...' : 'Creating...' }}</span>
|
||||||
|
</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container>
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
<span>{{ isEditMode() ? 'Update Question' : 'Save Question' }}</span>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Preview Panel -->
|
||||||
|
<mat-card class="preview-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Preview
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="preview-content">
|
||||||
|
<!-- Question Preview -->
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Question:</div>
|
||||||
|
<div class="preview-text">
|
||||||
|
{{ questionForm.get('questionText')?.value || 'Your question will appear here...' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type & Difficulty -->
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span class="preview-badge type-badge">
|
||||||
|
{{ questionForm.get('questionType')?.value | titlecase }}
|
||||||
|
</span>
|
||||||
|
<span class="preview-badge difficulty-badge" [class]="'difficulty-' + questionForm.get('difficulty')?.value">
|
||||||
|
{{ questionForm.get('difficulty')?.value | titlecase }}
|
||||||
|
</span>
|
||||||
|
<span class="preview-badge points-badge">
|
||||||
|
{{ questionForm.get('points')?.value || 10 }} Points
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options Preview (MCQ) -->
|
||||||
|
@if (showOptions() && getOptionTexts().length > 0) {
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Options:</div>
|
||||||
|
<div class="preview-options">
|
||||||
|
@for (optionText of getOptionTexts(); track $index) {
|
||||||
|
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === optionText">
|
||||||
|
<mat-icon>{{ questionForm.get('correctAnswer')?.value === optionText ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>
|
||||||
|
<span>{{ optionText }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- True/False Preview -->
|
||||||
|
@if (showTrueFalse()) {
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Options:</div>
|
||||||
|
<div class="preview-options">
|
||||||
|
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'true'">
|
||||||
|
<mat-icon>{{ questionForm.get('correctAnswer')?.value === 'true' ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>
|
||||||
|
<span>True</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'false'">
|
||||||
|
<mat-icon>{{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>
|
||||||
|
<span>False</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Explanation Preview -->
|
||||||
|
@if (questionForm.get('explanation')?.value) {
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Explanation:</div>
|
||||||
|
<div class="preview-explanation">
|
||||||
|
{{ questionForm.get('explanation')?.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Tags Preview -->
|
||||||
|
@if (tagsArray.length > 0) {
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Tags:</div>
|
||||||
|
<div class="preview-tags">
|
||||||
|
@for (tag of tagsArray; track tag) {
|
||||||
|
<span class="preview-tag">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Accessibility Preview -->
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-label">Access:</div>
|
||||||
|
<div class="preview-access">
|
||||||
|
@if (questionForm.get('isPublic')?.value) {
|
||||||
|
<span class="access-badge public">Public</span>
|
||||||
|
} @else {
|
||||||
|
<span class="access-badge private">Private</span>
|
||||||
|
}
|
||||||
|
@if (questionForm.get('isGuestAccessible')?.value) {
|
||||||
|
<span class="access-badge guest">Guest Accessible</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<div class="admin-questions-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<mat-icon class="header-icon">quiz</mat-icon>
|
||||||
|
<div>
|
||||||
|
<h1>Question Management</h1>
|
||||||
|
<p class="subtitle">Create, edit, and manage quiz questions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="primary" (click)="createQuestion()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Create Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Card -->
|
||||||
|
<mat-card class="filters-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="filterForm" class="filters-form">
|
||||||
|
<!-- Search -->
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-label>Search Questions</mat-label>
|
||||||
|
<input matInput formControlName="search" placeholder="Search by question text...">
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Category</mat-label>
|
||||||
|
<mat-select formControlName="category">
|
||||||
|
<mat-option value="all">All Categories</mat-option>
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Difficulty Filter -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Difficulty</mat-label>
|
||||||
|
<mat-select formControlName="difficulty">
|
||||||
|
<mat-option value="all">All Difficulties</mat-option>
|
||||||
|
<mat-option value="easy">Easy</mat-option>
|
||||||
|
<mat-option value="medium">Medium</mat-option>
|
||||||
|
<mat-option value="hard">Hard</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Type Filter -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Type</mat-label>
|
||||||
|
<mat-select formControlName="type">
|
||||||
|
<mat-option value="all">All Types</mat-option>
|
||||||
|
<mat-option value="multiple_choice">Multiple Choice</mat-option>
|
||||||
|
<mat-option value="true_false">True/False</mat-option>
|
||||||
|
<mat-option value="written">Written</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sort By -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Sort By</mat-label>
|
||||||
|
<mat-select formControlName="sortBy">
|
||||||
|
<mat-option value="createdAt">Date Created</mat-option>
|
||||||
|
<mat-option value="questionText">Question Text</mat-option>
|
||||||
|
<mat-option value="difficulty">Difficulty</mat-option>
|
||||||
|
<mat-option value="points">Points</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sort Order -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order</mat-label>
|
||||||
|
<mat-select formControlName="sortOrder">
|
||||||
|
<mat-option value="asc">Ascending</mat-option>
|
||||||
|
<mat-option value="desc">Descending</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Results Card -->
|
||||||
|
<mat-card class="results-card">
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="50"></mat-spinner>
|
||||||
|
<p>Loading questions...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@else if (error()) {
|
||||||
|
<div class="error-container">
|
||||||
|
<mat-icon color="warn">error</mat-icon>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="loadQuestions()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
@else if (questions().length === 0) {
|
||||||
|
<div class="empty-container">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
<h3>No Questions Found</h3>
|
||||||
|
<p>No questions match your current filters. Try adjusting your search criteria.</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="createQuestion()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Create First Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Questions Table (Desktop) -->
|
||||||
|
@else {
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="questions()" class="questions-table">
|
||||||
|
<!-- Question Text Column -->
|
||||||
|
<ng-container matColumnDef="questionText">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Question</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<div class="question-text-cell">
|
||||||
|
{{ question.questionText.substring(0, 100) }}{{ question.questionText.length > 100 ? '...' : '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Type Column -->
|
||||||
|
<ng-container matColumnDef="type">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<mat-chip>
|
||||||
|
@if (question.questionType === 'multiple_choice') {
|
||||||
|
<mat-icon>radio_button_checked</mat-icon>
|
||||||
|
<span>MCQ</span>
|
||||||
|
} @else if (question.questionType === 'true_false') {
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<span>T/F</span>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>edit_note</mat-icon>
|
||||||
|
<span>Written</span>
|
||||||
|
}
|
||||||
|
</mat-chip>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Category Column -->
|
||||||
|
<ng-container matColumnDef="category">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Category</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
{{ getCategoryName(question.categoryId) }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Difficulty Column -->
|
||||||
|
<ng-container matColumnDef="difficulty">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Difficulty</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<mat-chip [color]="getDifficultyColor(question.difficulty)">
|
||||||
|
{{ question.difficulty }}
|
||||||
|
</mat-chip>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Points Column -->
|
||||||
|
<ng-container matColumnDef="points">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Points</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<span class="points-badge">{{ question.points }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Status Column -->
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<mat-chip [color]="getStatusColor(question.isActive)">
|
||||||
|
{{ question.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</mat-chip>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let question">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button mat-icon-button color="primary"
|
||||||
|
(click)="editQuestion(question)"
|
||||||
|
matTooltip="Edit Question">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button color="warn"
|
||||||
|
(click)="deleteQuestion(question)"
|
||||||
|
matTooltip="Delete Question">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<app-pagination
|
||||||
|
[state]="paginationState()"
|
||||||
|
[pageNumbers]="pageNumbers()"
|
||||||
|
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||||
|
[showFirstLast]="true"
|
||||||
|
[itemLabel]="'questions'"
|
||||||
|
(pageChange)="goToPage($event)"
|
||||||
|
(pageSizeChange)="onPageSizeChange($event)">
|
||||||
|
</app-pagination>
|
||||||
|
}
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Question[]>([]);
|
||||||
|
readonly isLoading = signal<boolean>(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly categories = this.categoryService.categories;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
readonly currentPage = signal<number>(1);
|
||||||
|
readonly pageSize = signal<number>(10);
|
||||||
|
readonly totalQuestions = signal<number>(0);
|
||||||
|
readonly totalPages = computed(() => Math.ceil(this.totalQuestions() / this.pageSize()));
|
||||||
|
|
||||||
|
// Computed pagination state for reusable component
|
||||||
|
readonly paginationState = computed<PaginationState>(() => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<div class="admin-user-detail-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button mat-icon-button (click)="goBack()" class="back-button" aria-label="Go back to users list">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">User Details</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-icon-button (click)="refreshUser()" [disabled]="isLoading()"
|
||||||
|
matTooltip="Refresh user details" aria-label="Refresh">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb navigation">
|
||||||
|
<a routerLink="/admin" class="breadcrumb-link">Admin</a>
|
||||||
|
<mat-icon class="breadcrumb-separator">chevron_right</mat-icon>
|
||||||
|
<a routerLink="/admin/users" class="breadcrumb-link">Users</a>
|
||||||
|
<mat-icon class="breadcrumb-separator">chevron_right</mat-icon>
|
||||||
|
<span class="breadcrumb-current">{{ user()?.username || 'User Detail' }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
<p class="loading-text">Loading user details...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading()) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon class="error-icon">error</mat-icon>
|
||||||
|
<h2>Error Loading User</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Back to Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- User Detail Content -->
|
||||||
|
@if (user() && !isLoading()) {
|
||||||
|
<div class="detail-content">
|
||||||
|
<!-- User Profile Card -->
|
||||||
|
<mat-card class="profile-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<mat-icon>account_circle</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<h2 class="user-name">{{ user()!.username }}</h2>
|
||||||
|
<p class="user-email">{{ user()!.email }}</p>
|
||||||
|
<div class="user-badges">
|
||||||
|
<mat-chip [class]="'chip-' + getRoleColor(user()!.role)">
|
||||||
|
<mat-icon>{{ user()!.role === 'admin' ? 'admin_panel_settings' : 'person' }}</mat-icon>
|
||||||
|
{{ user()!.role | titlecase }}
|
||||||
|
</mat-chip>
|
||||||
|
<mat-chip [class]="'chip-' + getStatusColor(user()!.isActive)">
|
||||||
|
<mat-icon>{{ user()!.isActive ? 'check_circle' : 'cancel' }}</mat-icon>
|
||||||
|
{{ user()!.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<mat-icon>event</mat-icon>
|
||||||
|
<div class="detail-info">
|
||||||
|
<span class="detail-label">Member Since</span>
|
||||||
|
<span class="detail-value">{{ memberSince() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
<div class="detail-info">
|
||||||
|
<span class="detail-label">Last Active</span>
|
||||||
|
<span class="detail-value">{{ lastActive() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (user()!.metadata?.registrationMethod) {
|
||||||
|
<div class="detail-row">
|
||||||
|
<mat-icon>how_to_reg</mat-icon>
|
||||||
|
<div class="detail-info">
|
||||||
|
<span class="detail-label">Registration Method</span>
|
||||||
|
<span class="detail-value">{{ user()!.metadata!.registrationMethod === 'guest_conversion' ? 'Guest Conversion' : 'Direct' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions class="profile-actions">
|
||||||
|
<button mat-raised-button color="primary" (click)="editUserRole()">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button [color]="user()!.isActive ? 'warn' : 'accent'" (click)="toggleUserStatus()">
|
||||||
|
<mat-icon>{{ user()!.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||||
|
{{ user()!.isActive ? 'Deactivate' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ formatNumber(user()!.statistics.totalQuizzes) }}</h3>
|
||||||
|
<p class="stat-label">Total Quizzes</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<mat-icon>grade</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ user()!.statistics.averageScore.toFixed(1) }}%</h3>
|
||||||
|
<p class="stat-label">Average Score</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon accent">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ user()!.statistics.accuracy.toFixed(1) }}%</h3>
|
||||||
|
<p class="stat-label">Accuracy</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon warn">
|
||||||
|
<mat-icon>local_fire_department</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ user()!.statistics.currentStreak }}</h3>
|
||||||
|
<p class="stat-label">Current Streak</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<mat-icon>help_outline</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ formatNumber(user()!.statistics.totalQuestionsAnswered) }}</h3>
|
||||||
|
<p class="stat-label">Questions Answered</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<mat-icon>timer</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-value">{{ formatDuration(user()!.statistics.totalTimeSpent) }}</h3>
|
||||||
|
<p class="stat-label">Time Spent</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Stats Card -->
|
||||||
|
<mat-card class="additional-stats-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>analytics</mat-icon>
|
||||||
|
Additional Statistics
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stats-details">
|
||||||
|
<div class="stat-detail-row">
|
||||||
|
<span class="stat-detail-label">Correct Answers:</span>
|
||||||
|
<span class="stat-detail-value">{{ formatNumber(user()!.statistics.correctAnswers) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail-row">
|
||||||
|
<span class="stat-detail-label">Longest Streak:</span>
|
||||||
|
<span class="stat-detail-value">{{ user()!.statistics.longestStreak }} days</span>
|
||||||
|
</div>
|
||||||
|
@if (user()!.statistics.favoriteCategory) {
|
||||||
|
<div class="stat-detail-row">
|
||||||
|
<span class="stat-detail-label">Favorite Category:</span>
|
||||||
|
<span class="stat-detail-value">
|
||||||
|
{{ user()!.statistics.favoriteCategory!.name }}
|
||||||
|
({{ user()!.statistics.favoriteCategory!.quizCount }} quizzes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="stat-detail-row">
|
||||||
|
<span class="stat-detail-label">Quizzes This Week:</span>
|
||||||
|
<span class="stat-detail-value">{{ user()!.statistics.recentActivity.quizzesThisWeek }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail-row">
|
||||||
|
<span class="stat-detail-label">Quizzes This Month:</span>
|
||||||
|
<span class="stat-detail-value">{{ user()!.statistics.recentActivity.quizzesThisMonth }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Quiz History -->
|
||||||
|
<mat-card class="quiz-history-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>history</mat-icon>
|
||||||
|
Quiz History
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
@if (hasQuizHistory()) {
|
||||||
|
<div class="quiz-history-list">
|
||||||
|
@for (quiz of user()!.quizHistory; track quiz.id) {
|
||||||
|
<div class="quiz-history-item">
|
||||||
|
<div class="quiz-history-header">
|
||||||
|
<div class="quiz-category">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
<span>{{ quiz.categoryName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-date">{{ formatDateTime(quiz.completedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-history-stats">
|
||||||
|
<div class="quiz-stat">
|
||||||
|
<mat-icon [class]="'score-icon-' + getScoreColor(quiz.percentage)">grade</mat-icon>
|
||||||
|
<span class="quiz-stat-label">Score:</span>
|
||||||
|
<span [class]="'quiz-stat-value-' + getScoreColor(quiz.percentage)">
|
||||||
|
{{ quiz.score }}/{{ quiz.totalQuestions }} ({{ quiz.percentage.toFixed(1) }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-stat">
|
||||||
|
<mat-icon>timer</mat-icon>
|
||||||
|
<span class="quiz-stat-label">Time:</span>
|
||||||
|
<span class="quiz-stat-value">{{ formatDuration(quiz.timeTaken) }}</span>
|
||||||
|
</div>
|
||||||
|
<button mat-icon-button (click)="viewQuizDetails(quiz.id)"
|
||||||
|
matTooltip="View quiz details" class="quiz-action-btn">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
<p>No quiz history available</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Activity Timeline -->
|
||||||
|
<mat-card class="activity-timeline-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>timeline</mat-icon>
|
||||||
|
Activity Timeline
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
@if (hasActivity()) {
|
||||||
|
<mat-list class="activity-list">
|
||||||
|
@for (activity of user()!.activityTimeline; track activity.id) {
|
||||||
|
<mat-list-item class="activity-item">
|
||||||
|
<mat-icon [class]="'activity-icon-' + getActivityColor(activity.type)" matListItemIcon>
|
||||||
|
{{ getActivityIcon(activity.type) }}
|
||||||
|
</mat-icon>
|
||||||
|
<div matListItemTitle class="activity-description">{{ activity.description }}</div>
|
||||||
|
<div matListItemLine class="activity-time">{{ formatRelativeTime(activity.timestamp) }}</div>
|
||||||
|
@if (activity.metadata) {
|
||||||
|
<div matListItemLine class="activity-metadata">
|
||||||
|
@if (activity.metadata.categoryName) {
|
||||||
|
<span class="metadata-item">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
{{ activity.metadata.categoryName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (activity.metadata.score !== undefined) {
|
||||||
|
<span class="metadata-item">
|
||||||
|
<mat-icon>grade</mat-icon>
|
||||||
|
{{ activity.metadata.score }}%
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-list-item>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
}
|
||||||
|
</mat-list>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>timeline</mat-icon>
|
||||||
|
<p>No activity recorded</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>('');
|
||||||
|
|
||||||
|
// 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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<div class="admin-users-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="users-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="header-title">
|
||||||
|
<h1>User Management</h1>
|
||||||
|
<p class="subtitle">Manage all users and their permissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-stroked-button (click)="refreshUsers()" [disabled]="isLoading()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<mat-card class="filters-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="filterForm" class="filters-form">
|
||||||
|
<!-- Search -->
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-label>Search</mat-label>
|
||||||
|
<input matInput formControlName="search" placeholder="Search by username or email">
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Role Filter -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Role</mat-label>
|
||||||
|
<mat-select formControlName="role" (selectionChange)="applyFilters()">
|
||||||
|
<mat-option value="all">All Roles</mat-option>
|
||||||
|
<mat-option value="user">User</mat-option>
|
||||||
|
<mat-option value="admin">Admin</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>badge</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Status</mat-label>
|
||||||
|
<mat-select formControlName="isActive" (selectionChange)="applyFilters()">
|
||||||
|
<mat-option value="all">All Status</mat-option>
|
||||||
|
<mat-option value="active">Active</mat-option>
|
||||||
|
<mat-option value="inactive">Inactive</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>toggle_on</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sort By -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Sort By</mat-label>
|
||||||
|
<mat-select formControlName="sortBy" (selectionChange)="applyFilters()">
|
||||||
|
<mat-option value="username">Username</mat-option>
|
||||||
|
<mat-option value="email">Email</mat-option>
|
||||||
|
<mat-option value="createdAt">Join Date</mat-option>
|
||||||
|
<mat-option value="lastLoginAt">Last Login</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>sort</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sort Order -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order</mat-label>
|
||||||
|
<mat-select formControlName="sortOrder" (selectionChange)="applyFilters()">
|
||||||
|
<mat-option value="asc">Ascending</mat-option>
|
||||||
|
<mat-option value="desc">Descending</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>swap_vert</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Reset Button -->
|
||||||
|
<button mat-stroked-button type="button" (click)="resetFilters()">
|
||||||
|
<mat-icon>clear</mat-icon>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading() && users().length === 0) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="60"></mat-spinner>
|
||||||
|
<p>Loading users...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading() && users().length === 0) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon color="warn">error_outline</mat-icon>
|
||||||
|
<div class="error-text">
|
||||||
|
<h3>Failed to Load Users</h3>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="primary" (click)="refreshUsers()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Users Table (Desktop) -->
|
||||||
|
@if (users().length > 0) {
|
||||||
|
<mat-card class="table-card desktop-table">
|
||||||
|
<div class="table-header">
|
||||||
|
<h2>Users</h2>
|
||||||
|
@if (pagination()) {
|
||||||
|
<span class="total-count">
|
||||||
|
Total: {{ pagination()?.totalUsers }} user{{ pagination()?.totalUsers !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="users()" class="users-table">
|
||||||
|
<!-- Username Column -->
|
||||||
|
<ng-container matColumnDef="username">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Username</th>
|
||||||
|
<td mat-cell *matCellDef="let user">
|
||||||
|
<div class="username-cell">
|
||||||
|
<mat-icon class="user-icon">account_circle</mat-icon>
|
||||||
|
<span>{{ user.username }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Email Column -->
|
||||||
|
<ng-container matColumnDef="email">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Email</th>
|
||||||
|
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Role Column -->
|
||||||
|
<ng-container matColumnDef="role">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Role</th>
|
||||||
|
<td mat-cell *matCellDef="let user">
|
||||||
|
<mat-chip [color]="getRoleColor(user.role)" highlighted>
|
||||||
|
{{ user.role | uppercase }}
|
||||||
|
</mat-chip>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Status Column -->
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let user">
|
||||||
|
<mat-chip [color]="getStatusColor(user.isActive)" highlighted>
|
||||||
|
{{ getStatusText(user.isActive) }}
|
||||||
|
</mat-chip>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Joined Date Column -->
|
||||||
|
<ng-container matColumnDef="joinedDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Joined</th>
|
||||||
|
<td mat-cell *matCellDef="let user">{{ formatDate(user.createdAt) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Last Login Column -->
|
||||||
|
<ng-container matColumnDef="lastLogin">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Last Login</th>
|
||||||
|
<td mat-cell *matCellDef="let user">{{ formatDateTime(user.lastLoginAt) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let user">
|
||||||
|
<button mat-icon-button [matMenuTriggerFor]="actionMenu">
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #actionMenu="matMenu">
|
||||||
|
<button mat-menu-item (click)="viewUserDetails(user.id)">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
<span>View Details</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="editUserRole(user)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
<span>Edit Role</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="toggleUserStatus(user)">
|
||||||
|
<mat-icon>{{ user.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||||
|
<span>{{ user.isActive ? 'Deactivate' : 'Activate' }}</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Users Cards (Mobile) -->
|
||||||
|
<div class="mobile-cards">
|
||||||
|
@for (user of users(); track user.id) {
|
||||||
|
<mat-card class="user-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-icon mat-card-avatar class="card-avatar">account_circle</mat-icon>
|
||||||
|
<mat-card-title>{{ user.username }}</mat-card-title>
|
||||||
|
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Role:</span>
|
||||||
|
<mat-chip [color]="getRoleColor(user.role)" highlighted>
|
||||||
|
{{ user.role | uppercase }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Status:</span>
|
||||||
|
<mat-chip [color]="getStatusColor(user.isActive)" highlighted>
|
||||||
|
{{ getStatusText(user.isActive) }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Joined:</span>
|
||||||
|
<span>{{ formatDate(user.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Last Login:</span>
|
||||||
|
<span>{{ formatDateTime(user.lastLoginAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions>
|
||||||
|
<button mat-button (click)="viewUserDetails(user.id)">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button mat-button (click)="editUserRole(user)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
<button mat-button [color]="user.isActive ? 'warn' : 'primary'" (click)="toggleUserStatus(user)">
|
||||||
|
<mat-icon>{{ user.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||||
|
{{ user.isActive ? 'Deactivate' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
@if (paginationState()) {
|
||||||
|
<app-pagination
|
||||||
|
[state]="paginationState()"
|
||||||
|
[pageNumbers]="pageNumbers()"
|
||||||
|
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||||
|
[showFirstLast]="true"
|
||||||
|
[itemLabel]="'users'"
|
||||||
|
(pageChange)="goToPage($event)"
|
||||||
|
(pageSizeChange)="onPageSizeChange($event)">
|
||||||
|
</app-pagination>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
@if (!isLoading() && !error() && users().length === 0) {
|
||||||
|
<mat-card class="empty-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon>people_outline</mat-icon>
|
||||||
|
<h3>No Users Found</h3>
|
||||||
|
<p>No users match your current filters.</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="resetFilters()">
|
||||||
|
<mat-icon>clear</mat-icon>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PaginationState | null>(() => {
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,10 +166,18 @@
|
|||||||
[disabled]="categoryForm.invalid || isSubmitting()">
|
[disabled]="categoryForm.invalid || isSubmitting()">
|
||||||
@if (isSubmitting()) {
|
@if (isSubmitting()) {
|
||||||
<mat-spinner diameter="20"></mat-spinner>
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
<span>Saving...</span>
|
}
|
||||||
|
<span>
|
||||||
|
@if (isSubmitting()) {
|
||||||
|
Saving...
|
||||||
|
} @else if (isEditMode()) {
|
||||||
|
Save Changes
|
||||||
} @else {
|
} @else {
|
||||||
|
Create Category
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (!isSubmitting()) {
|
||||||
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
|
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
|
||||||
<span>{{ isEditMode() ? 'Save Changes' : 'Create Category' }}</span>
|
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="delete-dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
|
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<p class="dialog-message">{{ data.message }}</p>
|
||||||
|
|
||||||
|
@if (data.itemName) {
|
||||||
|
<div class="item-preview">
|
||||||
|
<strong>Item:</strong>
|
||||||
|
<p>{{ data.itemName }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="warning-box">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<span>This action cannot be undone.</span>
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()">
|
||||||
|
{{ data.cancelText || 'Cancel' }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" (click)="onConfirm()" cdkFocusInitial>
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
{{ data.confirmText || 'Delete' }}
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<DeleteConfirmDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: DeleteConfirmDialogData
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onCancel(): void {
|
||||||
|
this.dialogRef.close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm(): void {
|
||||||
|
this.dialogRef.close(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<div class="guest-analytics">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="analytics-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>
|
||||||
|
<mat-icon>people_outline</mat-icon>
|
||||||
|
Guest Analytics
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">Guest user behavior and conversion insights</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-raised-button color="accent" (click)="exportToCSV()" [disabled]="!analytics()">
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="refreshAnalytics()" [disabled]="isLoading()" matTooltip="Refresh analytics">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="60"></mat-spinner>
|
||||||
|
<p>Loading guest analytics...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading()) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon color="warn">error_outline</mat-icon>
|
||||||
|
<h3>Failed to Load Analytics</h3>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="refreshAnalytics()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Analytics Content -->
|
||||||
|
@if (analytics() && !isLoading()) {
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<mat-card class="stat-card sessions-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>group_add</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Total Guest Sessions</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(totalSessions()) }}</p>
|
||||||
|
@if (analytics() && analytics()!.stats.sessionsThisWeek) {
|
||||||
|
<p class="stat-detail">+{{ analytics()!.stats.sessionsThisWeek }} this week</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card active-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>online_prediction</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Active Sessions</h3>
|
||||||
|
<p class="stat-value">{{ formatNumber(activeSessions()) }}</p>
|
||||||
|
<p class="stat-detail">Currently active</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card conversion-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>trending_up</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Conversion Rate</h3>
|
||||||
|
<p class="stat-value">{{ formatPercentage(conversionRate()) }}</p>
|
||||||
|
@if (analytics() && analytics()!.totalConversions) {
|
||||||
|
<p class="stat-detail">{{ analytics()!.totalConversions }} conversions</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card quizzes-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Avg Quizzes per Guest</h3>
|
||||||
|
<p class="stat-value">{{ avgQuizzes().toFixed(1) }}</p>
|
||||||
|
<p class="stat-detail">Per guest session</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Timeline Chart -->
|
||||||
|
@if (timelineData().length > 0) {
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>show_chart</mat-icon>
|
||||||
|
Guest Session Timeline
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color active"></span>
|
||||||
|
<span>Active Sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color new"></span>
|
||||||
|
<span>New Sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color converted"></span>
|
||||||
|
<span>Converted Sessions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="timeline-chart">
|
||||||
|
<!-- Grid lines -->
|
||||||
|
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Axes -->
|
||||||
|
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Active Sessions Line -->
|
||||||
|
<path [attr.d]="getTimelinePath('activeSessions')" fill="none" stroke="#3f51b5" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- New Sessions Line -->
|
||||||
|
<path [attr.d]="getTimelinePath('newSessions')" fill="none" stroke="#4caf50" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Converted Sessions Line -->
|
||||||
|
<path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
@for (point of timelineData(); track point.date; let i = $index) {
|
||||||
|
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||||
|
[attr.cy]="calculateTimelineY(point.activeSessions)"
|
||||||
|
r="4" fill="#3f51b5"/>
|
||||||
|
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||||
|
[attr.cy]="calculateTimelineY(point.newSessions)"
|
||||||
|
r="4" fill="#4caf50"/>
|
||||||
|
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||||
|
[attr.cy]="calculateTimelineY(point.convertedSessions)"
|
||||||
|
r="4" fill="#ff9800"/>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Conversion Funnel Chart -->
|
||||||
|
@if (funnelData().length > 0) {
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>filter_alt</mat-icon>
|
||||||
|
Conversion Funnel
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="chart-container">
|
||||||
|
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart">
|
||||||
|
<!-- Funnel Bars -->
|
||||||
|
@for (bar of getFunnelBars(); track bar.label) {
|
||||||
|
<g>
|
||||||
|
<!-- Bar -->
|
||||||
|
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
|
||||||
|
[attr.height]="bar.height" [attr.fill]="$index === 0 ? '#4caf50' : $index === getFunnelBars().length - 1 ? '#ff9800' : '#2196f3'"
|
||||||
|
opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 - 5"
|
||||||
|
font-size="14" font-weight="600" fill="#fff">{{ bar.label }}</text>
|
||||||
|
|
||||||
|
<!-- Count and Percentage -->
|
||||||
|
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 + 15"
|
||||||
|
font-size="12" fill="#fff">{{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }})</text>
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="funnel-insights">
|
||||||
|
<p><strong>Conversion Insights:</strong></p>
|
||||||
|
<ul>
|
||||||
|
@for (stage of funnelData(); track stage.stage) {
|
||||||
|
@if (stage.dropoff !== undefined) {
|
||||||
|
<li>{{ formatPercentage(stage.dropoff) }} dropoff from {{ stage.stage }}</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h2>Guest Management</h2>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button mat-raised-button color="primary" (click)="goToSettings()">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
Guest Settings
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="refreshAnalytics()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="goBack()">
|
||||||
|
<mat-icon>dashboard</mat-icon>
|
||||||
|
Admin Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
@if (!analytics() && !isLoading() && !error()) {
|
||||||
|
<mat-card class="empty-state">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon>people_outline</mat-icon>
|
||||||
|
<h3>No Analytics Available</h3>
|
||||||
|
<p>Guest analytics will appear here once guests start using the platform</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>();
|
||||||
|
|
||||||
|
// 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<div class="guest-settings-edit-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="settings-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button mat-icon-button (click)="onCancel()" matTooltip="Back to Settings">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="header-title">
|
||||||
|
<h1>Edit Guest Settings</h1>
|
||||||
|
<p class="subtitle">Configure guest user access and limitations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading() && !settings()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="60"></mat-spinner>
|
||||||
|
<p>Loading settings...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading() && !settings()) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon color="warn">error_outline</mat-icon>
|
||||||
|
<div class="error-text">
|
||||||
|
<h3>Failed to Load Settings</h3>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="primary" (click)="onCancel()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Settings Form -->
|
||||||
|
@if (settings() || (!isLoading() && settingsForm)) {
|
||||||
|
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" class="settings-form">
|
||||||
|
<!-- Access Control Section -->
|
||||||
|
<mat-card class="form-section">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="section-icon access">
|
||||||
|
<mat-icon>lock_open</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Access Control</mat-card-title>
|
||||||
|
<mat-card-subtitle>Enable or disable guest access to the platform</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="toggle-field">
|
||||||
|
<div class="toggle-info">
|
||||||
|
<label>Guest Access Enabled</label>
|
||||||
|
<p class="field-description">Allow users to access the platform without registering</p>
|
||||||
|
</div>
|
||||||
|
<mat-slide-toggle formControlName="guestAccessEnabled" color="primary">
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
@if (!settingsForm.get('guestAccessEnabled')?.value) {
|
||||||
|
<div class="warning-banner">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<span>When disabled, all users must register and login to access the platform.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Quiz Limits Section -->
|
||||||
|
<mat-card class="form-section">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="section-icon limits">
|
||||||
|
<mat-icon>rule</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Quiz Limits</mat-card-title>
|
||||||
|
<mat-card-subtitle>Set daily and per-quiz restrictions for guests</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Max Quizzes Per Day</mat-label>
|
||||||
|
<input matInput type="number" formControlName="maxQuizzesPerDay" min="1" max="100">
|
||||||
|
<mat-icon matPrefix>calendar_today</mat-icon>
|
||||||
|
<mat-hint>Number of quizzes a guest can take per day (1-100)</mat-hint>
|
||||||
|
@if (hasError('maxQuizzesPerDay')) {
|
||||||
|
<mat-error>{{ getErrorMessage('maxQuizzesPerDay') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Max Questions Per Quiz</mat-label>
|
||||||
|
<input matInput type="number" formControlName="maxQuestionsPerQuiz" min="1" max="50">
|
||||||
|
<mat-icon matPrefix>quiz</mat-icon>
|
||||||
|
<mat-hint>Maximum questions allowed in a single quiz (1-50)</mat-hint>
|
||||||
|
@if (hasError('maxQuestionsPerQuiz')) {
|
||||||
|
<mat-error>{{ getErrorMessage('maxQuestionsPerQuiz') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Session Configuration Section -->
|
||||||
|
<mat-card class="form-section">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="section-icon session">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Session Configuration</mat-card-title>
|
||||||
|
<mat-card-subtitle>Configure guest session duration</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Session Expiry Hours</mat-label>
|
||||||
|
<input matInput type="number" formControlName="sessionExpiryHours" min="1" max="168">
|
||||||
|
<mat-icon matPrefix>timer</mat-icon>
|
||||||
|
<mat-hint>
|
||||||
|
How long guest sessions remain active (1-168 hours / 7 days)
|
||||||
|
@if (settingsForm.get('sessionExpiryHours')?.value) {
|
||||||
|
- {{ formatExpiryTime(settingsForm.get('sessionExpiryHours')?.value) }}
|
||||||
|
}
|
||||||
|
</mat-hint>
|
||||||
|
@if (hasError('sessionExpiryHours')) {
|
||||||
|
<mat-error>{{ getErrorMessage('sessionExpiryHours') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Upgrade Prompt Section -->
|
||||||
|
<mat-card class="form-section">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="section-icon message">
|
||||||
|
<mat-icon>message</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Upgrade Prompt</mat-card-title>
|
||||||
|
<mat-card-subtitle>Message shown when guests reach their limit</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Upgrade Prompt Message</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="upgradePromptMessage"
|
||||||
|
rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
<mat-icon matPrefix>format_quote</mat-icon>
|
||||||
|
<mat-hint align="end">
|
||||||
|
{{ settingsForm.get('upgradePromptMessage')?.value?.length || 0 }} / 500 characters
|
||||||
|
</mat-hint>
|
||||||
|
@if (hasError('upgradePromptMessage')) {
|
||||||
|
<mat-error>{{ getErrorMessage('upgradePromptMessage') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Preview -->
|
||||||
|
@if (settingsForm.get('upgradePromptMessage')?.value) {
|
||||||
|
<div class="message-preview">
|
||||||
|
<div class="preview-label">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
<span>Preview:</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-content">
|
||||||
|
{{ settingsForm.get('upgradePromptMessage')?.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Changes Preview -->
|
||||||
|
@if (hasUnsavedChanges()) {
|
||||||
|
<mat-card class="changes-preview">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="section-icon changes">
|
||||||
|
<mat-icon>pending_actions</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Pending Changes</mat-card-title>
|
||||||
|
<mat-card-subtitle>Review changes before saving</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="changes-list">
|
||||||
|
@for (change of getChangesPreview(); track change.label) {
|
||||||
|
<div class="change-item">
|
||||||
|
<div class="change-label">{{ change.label }}</div>
|
||||||
|
<div class="change-values">
|
||||||
|
<span class="old-value">{{ change.old }}</span>
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
<span class="new-value">{{ change.new }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<div class="actions-left">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
(click)="onReset()"
|
||||||
|
[disabled]="isSubmitting || !hasUnsavedChanges()"
|
||||||
|
>
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions-right">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="onCancel()"
|
||||||
|
[disabled]="isSubmitting"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
@if (isSubmitting) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
Saving...
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="settingsForm.invalid || !hasUnsavedChanges()"
|
||||||
|
>
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<div class="guest-settings-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="settings-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="header-title">
|
||||||
|
<h1>Guest Access Settings</h1>
|
||||||
|
<p class="subtitle">View and manage guest user access configuration</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-stroked-button (click)="refreshSettings()" [disabled]="isLoading()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="editSettings()" [disabled]="isLoading()">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="60"></mat-spinner>
|
||||||
|
<p>Loading guest settings...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading()) {
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-content">
|
||||||
|
<mat-icon color="warn">error_outline</mat-icon>
|
||||||
|
<div class="error-text">
|
||||||
|
<h3>Failed to Load Settings</h3>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="primary" (click)="loadSettings()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Settings Display -->
|
||||||
|
@if (settings() && !isLoading()) {
|
||||||
|
<div class="settings-content">
|
||||||
|
<!-- Access Control Section -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon" [class.enabled]="settings()?.guestAccessEnabled">
|
||||||
|
<mat-icon>lock_open</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Access Control</mat-card-title>
|
||||||
|
<mat-card-subtitle>Guest access configuration</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<mat-icon>toggle_on</mat-icon>
|
||||||
|
<span>Guest Access</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
<mat-chip [color]="getStatusColor(settings()?.guestAccessEnabled ?? false)" highlighted>
|
||||||
|
{{ getStatusText(settings()?.guestAccessEnabled ?? false) }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!settings()?.guestAccessEnabled) {
|
||||||
|
<div class="info-banner">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<span>Guest access is currently disabled. Users must register to access the platform.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Quiz Limits Section -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon limits">
|
||||||
|
<mat-icon>rule</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Quiz Limits</mat-card-title>
|
||||||
|
<mat-card-subtitle>Daily and per-quiz restrictions</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<mat-icon>calendar_today</mat-icon>
|
||||||
|
<span>Max Quizzes Per Day</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
<span class="value-number">{{ settings()?.maxQuizzesPerDay ?? 0 }}</span>
|
||||||
|
<span class="value-unit">quizzes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
<span>Max Questions Per Quiz</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
<span class="value-number">{{ settings()?.maxQuestionsPerQuiz ?? 0 }}</span>
|
||||||
|
<span class="value-unit">questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Session Configuration Section -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon session">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Session Configuration</mat-card-title>
|
||||||
|
<mat-card-subtitle>Session duration and expiry</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<mat-icon>timer</mat-icon>
|
||||||
|
<span>Session Expiry Time</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
<span class="value-number">{{ settings()?.sessionExpiryHours ?? 0 }}</span>
|
||||||
|
<span class="value-unit">hours</span>
|
||||||
|
<span class="value-formatted">({{ formatExpiryTime(settings()?.sessionExpiryHours ?? 0) }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Upgrade Prompt Section -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon message">
|
||||||
|
<mat-icon>message</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Upgrade Prompt</mat-card-title>
|
||||||
|
<mat-card-subtitle>Message shown to guests when limit reached</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="upgrade-message">
|
||||||
|
<mat-icon>format_quote</mat-icon>
|
||||||
|
<p>{{ settings()?.upgradePromptMessage ?? 'No message configured' }}</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Guest Features Section -->
|
||||||
|
@if (settings()?.features) {
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon features">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Guest Features</mat-card-title>
|
||||||
|
<mat-card-subtitle>Available features for guest users</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon [class.enabled]="settings()?.features?.canBookmark">bookmark</mat-icon>
|
||||||
|
<span>Bookmarking</span>
|
||||||
|
<mat-chip [color]="getFeatureColor(settings()?.features?.canBookmark ?? false)">
|
||||||
|
{{ getStatusText(settings()?.features?.canBookmark ?? false) }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon [class.enabled]="settings()?.features?.canViewHistory">history</mat-icon>
|
||||||
|
<span>View History</span>
|
||||||
|
<mat-chip [color]="getFeatureColor(settings()?.features?.canViewHistory ?? false)">
|
||||||
|
{{ getStatusText(settings()?.features?.canViewHistory ?? false) }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon [class.enabled]="settings()?.features?.canExportResults">download</mat-icon>
|
||||||
|
<span>Export Results</span>
|
||||||
|
<mat-chip [color]="getFeatureColor(settings()?.features?.canExportResults ?? false)">
|
||||||
|
{{ getStatusText(settings()?.features?.canExportResults ?? false) }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Allowed Categories Section -->
|
||||||
|
@if (settings()?.allowedCategories && settings()!.allowedCategories!.length > 0) {
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-icon categories">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Allowed Categories</mat-card-title>
|
||||||
|
<mat-card-subtitle>Categories accessible to guest users</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="categories-chips">
|
||||||
|
@for (category of settings()?.allowedCategories; track category) {
|
||||||
|
<mat-chip color="accent">{{ category }}</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button mat-button (click)="goToAnalytics()">
|
||||||
|
<mat-icon>analytics</mat-icon>
|
||||||
|
View Guest Analytics
|
||||||
|
</button>
|
||||||
|
<button mat-button (click)="goBack()">
|
||||||
|
<mat-icon>dashboard</mat-icon>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<div class="role-update-dialog">
|
||||||
|
<!-- Step 1: Role Selection -->
|
||||||
|
@if (!showConfirmation()) {
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<mat-icon class="header-icon">admin_panel_settings</mat-icon>
|
||||||
|
<h2 mat-dialog-title>Update User Role</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<mat-icon>account_circle</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<h3>{{ data.user.username }}</h3>
|
||||||
|
<p>{{ data.user.email }}</p>
|
||||||
|
<div class="current-role">
|
||||||
|
<span class="label">Current Role:</span>
|
||||||
|
<span [class]="'role-badge role-' + data.user.role">
|
||||||
|
{{ getRoleLabel(data.user.role) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-selector">
|
||||||
|
<h3 class="selector-title">Select New Role</h3>
|
||||||
|
<mat-radio-group [(ngModel)]="selectedRole" class="role-options">
|
||||||
|
<mat-radio-button value="user" class="role-option">
|
||||||
|
<div class="role-option-content">
|
||||||
|
<div class="role-option-header">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
<span class="role-name">Regular User</span>
|
||||||
|
</div>
|
||||||
|
<p class="role-description">{{ getRoleDescription('user') }}</p>
|
||||||
|
</div>
|
||||||
|
</mat-radio-button>
|
||||||
|
|
||||||
|
<mat-radio-button value="admin" class="role-option">
|
||||||
|
<div class="role-option-content">
|
||||||
|
<div class="role-option-header">
|
||||||
|
<mat-icon>admin_panel_settings</mat-icon>
|
||||||
|
<span class="role-name">Administrator</span>
|
||||||
|
</div>
|
||||||
|
<p class="role-description">{{ getRoleDescription('admin') }}</p>
|
||||||
|
</div>
|
||||||
|
</mat-radio-button>
|
||||||
|
</mat-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isDemotingAdmin) {
|
||||||
|
<div class="warning-box">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div class="warning-content">
|
||||||
|
<h4>Warning: Demoting Administrator</h4>
|
||||||
|
<p>This user will lose access to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Admin dashboard and analytics</li>
|
||||||
|
<li>User management capabilities</li>
|
||||||
|
<li>System settings and configuration</li>
|
||||||
|
<li>Question and category management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isPromotingToAdmin) {
|
||||||
|
<div class="info-box">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<div class="info-content">
|
||||||
|
<h4>Promoting to Administrator</h4>
|
||||||
|
<p>This user will gain access to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Full admin dashboard and analytics</li>
|
||||||
|
<li>Manage all users and their roles</li>
|
||||||
|
<li>Configure system settings</li>
|
||||||
|
<li>Create and manage questions/categories</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()" [disabled]="isLoading()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="onNext()"
|
||||||
|
[disabled]="!hasRoleChanged || isLoading()">
|
||||||
|
Next
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Step 2: Confirmation -->
|
||||||
|
@if (showConfirmation()) {
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<mat-icon class="header-icon confirm">check_circle</mat-icon>
|
||||||
|
<h2 mat-dialog-title>Confirm Role Change</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<div class="confirmation-message">
|
||||||
|
<div class="change-summary">
|
||||||
|
<div class="change-item">
|
||||||
|
<span class="change-label">User:</span>
|
||||||
|
<span class="change-value">{{ data.user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="change-arrow">
|
||||||
|
<mat-icon>arrow_downward</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="change-item">
|
||||||
|
<span class="change-label">Current Role:</span>
|
||||||
|
<span [class]="'role-badge role-' + data.user.role">
|
||||||
|
{{ getRoleLabel(data.user.role) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="change-arrow">
|
||||||
|
<mat-icon>arrow_downward</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="change-item">
|
||||||
|
<span class="change-label">New Role:</span>
|
||||||
|
<span [class]="'role-badge role-' + selectedRole">
|
||||||
|
{{ getRoleLabel(selectedRole) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isDemotingAdmin) {
|
||||||
|
<div class="final-warning">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<p><strong>Important:</strong> This action will immediately revoke all administrative privileges. The user will be logged out if currently in an admin session.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="confirmation-question">
|
||||||
|
Are you sure you want to change this user's role?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onBack()" [disabled]="isLoading()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
@if (isLoading()) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
[color]="isDemotingAdmin ? 'warn' : 'primary'"
|
||||||
|
[disabled]="true">
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
Updating...
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
[color]="isDemotingAdmin ? 'warn' : 'primary'"
|
||||||
|
(click)="onConfirm()">
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
Confirm Change
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean>(false);
|
||||||
|
readonly showConfirmation = signal<boolean>(false);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<RoleUpdateDialogComponent>,
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<div class="status-dialog">
|
||||||
|
<!-- Dialog Header -->
|
||||||
|
<div class="dialog-header" [class.activate-header]="data.action === 'activate'" [class.deactivate-header]="data.action === 'deactivate'">
|
||||||
|
<mat-icon class="dialog-icon">{{ dialogIcon }}</mat-icon>
|
||||||
|
<h2 mat-dialog-title>{{ actionVerb }} User Account</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dialog Content -->
|
||||||
|
<mat-dialog-content>
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
@if (data.user.profilePicture) {
|
||||||
|
<img [src]="data.user.profilePicture" [alt]="data.user.username">
|
||||||
|
} @else {
|
||||||
|
<div class="avatar-placeholder">
|
||||||
|
{{ data.user.username.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="username">{{ data.user.username }}</div>
|
||||||
|
<div class="email">{{ data.user.email }}</div>
|
||||||
|
<div class="role-badge" [class]="'role-' + data.user.role.toLowerCase()">
|
||||||
|
{{ data.user.role }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning Message -->
|
||||||
|
<div class="warning-box" [class.activate-warning]="data.action === 'activate'" [class.deactivate-warning]="data.action === 'deactivate'">
|
||||||
|
<mat-icon>{{ data.action === 'activate' ? 'info' : 'warning' }}</mat-icon>
|
||||||
|
<div class="warning-content">
|
||||||
|
<div class="warning-title">
|
||||||
|
@if (data.action === 'activate') {
|
||||||
|
<span>Reactivate Account</span>
|
||||||
|
} @else {
|
||||||
|
<span>Deactivate Account</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="warning-message">
|
||||||
|
@if (data.action === 'activate') {
|
||||||
|
<span>Are you sure you want to activate <strong>{{ data.user.username }}</strong>'s account?</span>
|
||||||
|
} @else {
|
||||||
|
<span>Are you sure you want to deactivate <strong>{{ data.user.username }}</strong>'s account?</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Consequences -->
|
||||||
|
<div class="consequences">
|
||||||
|
<div class="consequences-title">This action will:</div>
|
||||||
|
<ul class="consequences-list">
|
||||||
|
@for (consequence of consequences; track consequence) {
|
||||||
|
<li>{{ consequence }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Note -->
|
||||||
|
@if (data.action === 'deactivate') {
|
||||||
|
<div class="info-box">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<div class="info-content">
|
||||||
|
<strong>Note:</strong> This is a soft delete. User data is preserved and the account can be reactivated at any time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="info-box">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<div class="info-content">
|
||||||
|
<strong>Note:</strong> The user will be able to access their account immediately after activation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<!-- Dialog Actions -->
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-raised-button [color]="buttonColor" (click)="onConfirm()">
|
||||||
|
<mat-icon>{{ dialogIcon }}</mat-icon>
|
||||||
|
<span>{{ actionVerb }} User</span>
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StatusUpdateDialogComponent>,
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
frontend/src/app/features/bookmarks/bookmarks.component.html
Normal file
263
frontend/src/app/features/bookmarks/bookmarks.component.html
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<div class="bookmarks-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bookmarks-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<button mat-icon-button [routerLink]="['/dashboard']" class="back-button">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1>My Bookmarks</h1>
|
||||||
|
<p class="subtitle">{{ stats().total }} saved questions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filteredBookmarks().length > 0) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
class="practice-button"
|
||||||
|
(click)="practiceBookmarkedQuestions()"
|
||||||
|
>
|
||||||
|
<mat-icon>play_arrow</mat-icon>
|
||||||
|
Practice All
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="50"></mat-spinner>
|
||||||
|
<p>Loading your bookmarks...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@if (error() && !isLoading()) {
|
||||||
|
<div class="error-container">
|
||||||
|
<mat-icon class="error-icon">error_outline</mat-icon>
|
||||||
|
<h2>Failed to Load Bookmarks</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button mat-raised-button color="primary" (click)="loadBookmarks(true)">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
@if (!isLoading() && !error()) {
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon easy">
|
||||||
|
<mat-icon>sentiment_satisfied</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ stats().byDifficulty.easy }}</span>
|
||||||
|
<span class="stat-label">Easy</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon medium">
|
||||||
|
<mat-icon>sentiment_neutral</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ stats().byDifficulty.medium }}</span>
|
||||||
|
<span class="stat-label">Medium</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="stat-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="stat-icon hard">
|
||||||
|
<mat-icon>sentiment_dissatisfied</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ stats().byDifficulty.hard }}</span>
|
||||||
|
<span class="stat-label">Hard</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<mat-card class="filters-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="filters-row">
|
||||||
|
<!-- Search -->
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-label>Search bookmarks</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
(ngModelChange)="searchQuery.set($event)"
|
||||||
|
placeholder="Search by question or category"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
@if (searchQuery()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
(click)="searchQuery.set('')"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<mat-form-field appearance="outline" class="filter-field">
|
||||||
|
<mat-label>Category</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[(ngModel)]="selectedCategory"
|
||||||
|
(ngModelChange)="selectedCategory.set($event)"
|
||||||
|
>
|
||||||
|
<mat-option [value]="null">All Categories</mat-option>
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>category</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Difficulty Filter -->
|
||||||
|
<mat-form-field appearance="outline" class="filter-field">
|
||||||
|
<mat-label>Difficulty</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[(ngModel)]="selectedDifficulty"
|
||||||
|
(ngModelChange)="selectedDifficulty.set($event)"
|
||||||
|
>
|
||||||
|
<mat-option [value]="null">All Difficulties</mat-option>
|
||||||
|
@for (difficulty of difficulties; track difficulty) {
|
||||||
|
<mat-option [value]="difficulty">
|
||||||
|
{{ difficulty | titlecase }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-icon matPrefix>filter_list</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Reset Filters -->
|
||||||
|
@if (searchQuery() || selectedCategory() || selectedDifficulty()) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="reset-button"
|
||||||
|
(click)="resetFilters()"
|
||||||
|
>
|
||||||
|
<mat-icon>clear_all</mat-icon>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
@if (allBookmarks().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon class="empty-icon">bookmark_border</mat-icon>
|
||||||
|
<h2>No Bookmarks Yet</h2>
|
||||||
|
<p>Start bookmarking questions while taking quizzes to build your study collection.</p>
|
||||||
|
<button mat-raised-button color="primary" [routerLink]="['/categories']">
|
||||||
|
<mat-icon>explore</mat-icon>
|
||||||
|
Browse Categories
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- No Results After Filtering -->
|
||||||
|
@if (allBookmarks().length > 0 && filteredBookmarks().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon class="empty-icon">search_off</mat-icon>
|
||||||
|
<h2>No Matching Bookmarks</h2>
|
||||||
|
<p>Try adjusting your filters or search query.</p>
|
||||||
|
<button mat-stroked-button (click)="resetFilters()">
|
||||||
|
<mat-icon>clear_all</mat-icon>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bookmarks Grid -->
|
||||||
|
@if (filteredBookmarks().length > 0) {
|
||||||
|
<div class="bookmarks-grid">
|
||||||
|
@for (bookmark of filteredBookmarks(); track bookmark.id) {
|
||||||
|
<mat-card class="bookmark-card" (click)="viewQuestion(bookmark)">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="card-header-content">
|
||||||
|
<div class="difficulty-badge" [ngClass]="getDifficultyClass(bookmark.question.difficulty)">
|
||||||
|
<mat-icon>{{ getDifficultyIcon(bookmark.question.difficulty) }}</mat-icon>
|
||||||
|
<span>{{ bookmark.question.difficulty | titlecase }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="remove-button"
|
||||||
|
[disabled]="isRemovingBookmark(bookmark.questionId)"
|
||||||
|
(click)="removeBookmark(bookmark.questionId, $event)"
|
||||||
|
[matTooltip]="'Remove bookmark'"
|
||||||
|
>
|
||||||
|
@if (isRemovingBookmark(bookmark.questionId)) {
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>bookmark</mat-icon>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<p class="question-text">
|
||||||
|
{{ truncateText(bookmark.question.questionText, 200) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="question-meta">
|
||||||
|
<mat-chip class="category-chip">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
{{ bookmark.question.categoryName }}
|
||||||
|
</mat-chip>
|
||||||
|
|
||||||
|
@if (bookmark.question.tags && bookmark.question.tags.length > 0) {
|
||||||
|
<mat-chip class="tags-chip">
|
||||||
|
<mat-icon>label</mat-icon>
|
||||||
|
{{ bookmark.question.tags.slice(0, 2).join(', ') }}
|
||||||
|
@if (bookmark.question.tags.length > 2) {
|
||||||
|
<span>+{{ bookmark.question.tags.length - 2 }}</span>
|
||||||
|
}
|
||||||
|
</mat-chip>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-chip class="points-chip">
|
||||||
|
<mat-icon>stars</mat-icon>
|
||||||
|
{{ bookmark.question.points }} pts
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-date">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
<span>Bookmarked {{ formatDate(bookmark.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="viewQuestion(bookmark); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
561
frontend/src/app/features/bookmarks/bookmarks.component.scss
Normal file
561
frontend/src/app/features/bookmarks/bookmarks.component.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
275
frontend/src/app/features/bookmarks/bookmarks.component.ts
Normal file
275
frontend/src/app/features/bookmarks/bookmarks.component.ts
Normal file
@@ -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<void>();
|
||||||
|
|
||||||
|
// Signals
|
||||||
|
searchQuery = signal<string>('');
|
||||||
|
selectedCategory = signal<string | null>(null);
|
||||||
|
selectedDifficulty = signal<string | null>(null);
|
||||||
|
isRemoving = signal<Set<string>>(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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,16 @@
|
|||||||
<h1>Welcome back, {{ username() }}! 👋</h1>
|
<h1>Welcome back, {{ username() }}! 👋</h1>
|
||||||
<p class="subtitle">Ready to test your knowledge today?</p>
|
<p class="subtitle">Ready to test your knowledge today?</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="welcome-actions">
|
||||||
<button mat-raised-button color="primary" class="start-quiz-btn" (click)="startNewQuiz()">
|
<button mat-raised-button color="primary" class="start-quiz-btn" (click)="startNewQuiz()">
|
||||||
<mat-icon>play_arrow</mat-icon>
|
<mat-icon>play_arrow</mat-icon>
|
||||||
Start New Quiz
|
Start New Quiz
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-stroked-button [routerLink]="['/profile']" class="profile-btn">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
Profile Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
|
|||||||
@@ -78,9 +78,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-quiz-btn {
|
.welcome-actions {
|
||||||
background-color: white;
|
display: flex;
|
||||||
color: var(--color-primary);
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-quiz-btn,
|
||||||
|
.profile-btn {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -94,6 +99,36 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
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
|
// Empty State
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<div class="profile-settings-container">
|
||||||
|
<div class="settings-header">
|
||||||
|
<button mat-icon-button [routerLink]="['/dashboard']" class="back-button">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h1>Profile Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-tab-group class="settings-tabs" animationDuration="300ms">
|
||||||
|
<!-- Profile Information Tab -->
|
||||||
|
<mat-tab label="Profile Information">
|
||||||
|
<div class="tab-content">
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Update Your Profile</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="profileForm" (ngSubmit)="saveProfile()">
|
||||||
|
<!-- Username Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Username</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="username"
|
||||||
|
placeholder="Enter username"
|
||||||
|
autocomplete="username"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>person</mat-icon>
|
||||||
|
<mat-error>{{ getErrorMessage(profileForm, 'username') }}</mat-error>
|
||||||
|
<mat-hint>Letters, numbers, and underscores only (3-30 characters)</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Email Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Email Address</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="Enter email address"
|
||||||
|
autocomplete="email"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>email</mat-icon>
|
||||||
|
<mat-error>{{ getErrorMessage(profileForm, 'email') }}</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="profileForm.invalid || profileForm.pristine || isLoading()"
|
||||||
|
class="save-button"
|
||||||
|
>
|
||||||
|
@if (isLoading()) {
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
<span>Saving...</span>
|
||||||
|
} @else {
|
||||||
|
<ng-container>
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
<span>Save Changes</span>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="cancelProfile()"
|
||||||
|
[disabled]="profileForm.pristine || isLoading()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- User Info Card -->
|
||||||
|
@if (currentUser) {
|
||||||
|
<mat-card class="info-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Account Information</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Account ID:</span>
|
||||||
|
<span class="info-value">{{ currentUser.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Role:</span>
|
||||||
|
<span class="info-value role-badge" [class.admin]="currentUser.role === 'admin'">
|
||||||
|
{{ currentUser.role }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Member Since:</span>
|
||||||
|
<span class="info-value">{{ currentUser.createdAt | date:'medium' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ currentUser.updatedAt | date:'medium' }}</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Change Password Tab -->
|
||||||
|
<mat-tab label="Change Password">
|
||||||
|
<div class="tab-content">
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Change Your Password</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="passwordForm" (ngSubmit)="changePassword()">
|
||||||
|
<!-- Current Password -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Current Password</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[type]="showCurrentPassword() ? 'text' : 'password'"
|
||||||
|
formControlName="currentPassword"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>lock</mat-icon>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="togglePasswordVisibility('current')"
|
||||||
|
[attr.aria-label]="'Toggle password visibility'"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ showCurrentPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error>{{ getErrorMessage(passwordForm, 'currentPassword') }}</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- New Password -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>New Password</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[type]="showNewPassword() ? 'text' : 'password'"
|
||||||
|
formControlName="newPassword"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>lock_open</mat-icon>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="togglePasswordVisibility('new')"
|
||||||
|
[attr.aria-label]="'Toggle password visibility'"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ showNewPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error>{{ getErrorMessage(passwordForm, 'newPassword') }}</mat-error>
|
||||||
|
<mat-hint>At least 8 characters with uppercase, lowercase, number, and special character</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Password Strength Indicator -->
|
||||||
|
@if (passwordForm.get('newPassword')?.value) {
|
||||||
|
<div class="password-strength">
|
||||||
|
<div class="strength-bar">
|
||||||
|
<div
|
||||||
|
class="strength-fill"
|
||||||
|
[style.width.%]="getPasswordStrength(passwordForm.get('newPassword')?.value).strength"
|
||||||
|
[class]="getPasswordStrength(passwordForm.get('newPassword')?.value).color"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="strength-label" [class]="getPasswordStrength(passwordForm.get('newPassword')?.value).color">
|
||||||
|
{{ getPasswordStrength(passwordForm.get('newPassword')?.value).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Confirm Password -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Confirm New Password</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[type]="showConfirmPassword() ? 'text' : 'password'"
|
||||||
|
formControlName="confirmPassword"
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<mat-icon matPrefix>lock_outline</mat-icon>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="togglePasswordVisibility('confirm')"
|
||||||
|
[attr.aria-label]="'Toggle password visibility'"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ showConfirmPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error>{{ getErrorMessage(passwordForm, 'confirmPassword') }}</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Password Mismatch Error -->
|
||||||
|
@if (hasFormError(passwordForm, 'passwordMismatch')) {
|
||||||
|
<div class="form-error">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<span>Passwords do not match</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="passwordForm.invalid || isLoading()"
|
||||||
|
class="save-button"
|
||||||
|
>
|
||||||
|
@if (isLoading()) {
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
<span>Changing...</span>
|
||||||
|
} @else {
|
||||||
|
<ng-container>
|
||||||
|
<mat-icon>vpn_key</mat-icon>
|
||||||
|
<span>Change Password</span>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="cancelPassword()"
|
||||||
|
[disabled]="passwordForm.pristine || isLoading()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Security Tips -->
|
||||||
|
<mat-card class="tips-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>security</mat-icon>
|
||||||
|
Security Tips
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<ul class="tips-list">
|
||||||
|
<li>Use a strong, unique password that you don't use anywhere else</li>
|
||||||
|
<li>Include a mix of uppercase and lowercase letters, numbers, and symbols</li>
|
||||||
|
<li>Avoid using personal information like your name or birthday</li>
|
||||||
|
<li>Consider using a password manager to generate and store passwords</li>
|
||||||
|
<li>Change your password regularly, especially if you suspect unauthorized access</li>
|
||||||
|
</ul>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
347
frontend/src/app/features/profile/profile-settings.component.ts
Normal file
347
frontend/src/app/features/profile/profile-settings.component.ts
Normal file
@@ -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<void>();
|
||||||
|
|
||||||
|
// Signals
|
||||||
|
isLoading = signal<boolean>(false);
|
||||||
|
showCurrentPassword = signal<boolean>(false);
|
||||||
|
showNewPassword = signal<boolean>(false);
|
||||||
|
showConfirmPassword = signal<boolean>(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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,8 +153,10 @@
|
|||||||
<mat-spinner diameter="20"></mat-spinner>
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
<span>Submitting...</span>
|
<span>Submitting...</span>
|
||||||
} @else {
|
} @else {
|
||||||
|
<ng-container>
|
||||||
<mat-icon>send</mat-icon>
|
<mat-icon>send</mat-icon>
|
||||||
<span>Submit Answer</span>
|
<span>Submit Answer</span>
|
||||||
|
</ng-container>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -164,11 +166,15 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
(click)="nextQuestion()">
|
(click)="nextQuestion()">
|
||||||
@if (isLastQuestion()) {
|
@if (isLastQuestion()) {
|
||||||
|
<ng-container>
|
||||||
<mat-icon>flag</mat-icon>
|
<mat-icon>flag</mat-icon>
|
||||||
<span>Complete Quiz</span>
|
<span>Complete Quiz</span>
|
||||||
|
</ng-container>
|
||||||
} @else {
|
} @else {
|
||||||
|
<ng-container>
|
||||||
<mat-icon>arrow_forward</mat-icon>
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
<span>Next Question</span>
|
<span>Next Question</span>
|
||||||
|
</ng-container>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,8 +189,10 @@
|
|||||||
<mat-spinner diameter="20"></mat-spinner>
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
<span>Starting...</span>
|
<span>Starting...</span>
|
||||||
} @else {
|
} @else {
|
||||||
|
<ng-container>
|
||||||
<mat-icon>play_arrow</mat-icon>
|
<mat-icon>play_arrow</mat-icon>
|
||||||
<span>Start Quiz</span>
|
<span>Start Quiz</span>
|
||||||
|
</ng-container>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
* <!-- Simple back button -->
|
||||||
|
* <app-back-button></app-back-button>
|
||||||
|
*
|
||||||
|
* <!-- With custom fallback -->
|
||||||
|
* <app-back-button [fallbackRoute]="'/dashboard'"></app-back-button>
|
||||||
|
*
|
||||||
|
* <!-- Text button -->
|
||||||
|
* <app-back-button [showText]="true" [label]="'Back to List'"></app-back-button>
|
||||||
|
*
|
||||||
|
* <!-- Icon only -->
|
||||||
|
* <app-back-button [showText]="false"></app-back-button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-back-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
[class.icon-only]="!showText()"
|
||||||
|
(click)="goBack()"
|
||||||
|
[matTooltip]="tooltip()"
|
||||||
|
[attr.aria-label]="label() || 'Go back'">
|
||||||
|
<mat-icon>{{ icon() }}</mat-icon>
|
||||||
|
@if (showText()) {
|
||||||
|
<span>{{ label() }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
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<string>('/');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show text label
|
||||||
|
*/
|
||||||
|
showText = input<boolean>(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button label text
|
||||||
|
*/
|
||||||
|
label = input<string>('Back');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon to display
|
||||||
|
*/
|
||||||
|
icon = input<string>('arrow_back');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip text
|
||||||
|
*/
|
||||||
|
tooltip = input<string>('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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<nav class="breadcrumb-container" aria-label="Breadcrumb navigation">
|
||||||
|
<ol class="breadcrumb-list">
|
||||||
|
@for (breadcrumb of breadcrumbs; track breadcrumb.url; let isLast = $last) {
|
||||||
|
<li class="breadcrumb-item" [class.active]="isLast">
|
||||||
|
@if (!isLast) {
|
||||||
|
<a [routerLink]="breadcrumb.url" class="breadcrumb-link">
|
||||||
|
@if (breadcrumb.icon) {
|
||||||
|
<mat-icon>{{ breadcrumb.icon }}</mat-icon>
|
||||||
|
}
|
||||||
|
<span>{{ breadcrumb.label }}</span>
|
||||||
|
</a>
|
||||||
|
<mat-icon class="separator">chevron_right</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span class="breadcrumb-current">
|
||||||
|
@if (breadcrumb.icon) {
|
||||||
|
<mat-icon>{{ breadcrumb.icon }}</mat-icon>
|
||||||
|
}
|
||||||
|
<span>{{ breadcrumb.label }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
`,
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
379
frontend/src/app/shared/components/error/error.component.ts
Normal file
379
frontend/src/app/shared/components/error/error.component.ts
Normal file
@@ -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: `
|
||||||
|
<div class="error-container">
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<!-- Error Icon -->
|
||||||
|
<div class="error-icon">
|
||||||
|
<mat-icon [class]="'error-icon-' + errorType()">
|
||||||
|
{{ getIcon() }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Title -->
|
||||||
|
<h1 class="error-title">{{ title() }}</h1>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<p class="error-message">{{ message() }}</p>
|
||||||
|
|
||||||
|
<!-- Error Code (if provided) -->
|
||||||
|
@if (errorCode()) {
|
||||||
|
<p class="error-code">Error Code: {{ errorCode() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="error-actions">
|
||||||
|
@if (showRetry()) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="onRetry()"
|
||||||
|
aria-label="Retry the operation">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showReload()) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="reloadPage()"
|
||||||
|
aria-label="Reload the page">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
(click)="goHome()"
|
||||||
|
aria-label="Go to home page">
|
||||||
|
<mat-icon>home</mat-icon>
|
||||||
|
Go to Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (showBack()) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="goBack()"
|
||||||
|
aria-label="Go back">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Details (Expandable) -->
|
||||||
|
@if (showDetails() && errorDetails()) {
|
||||||
|
<div class="error-details">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
(click)="toggleDetails()"
|
||||||
|
class="details-toggle"
|
||||||
|
aria-label="Toggle error details">
|
||||||
|
<mat-icon>{{ detailsExpanded ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{ detailsExpanded ? 'Hide' : 'Show' }} Details
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (detailsExpanded) {
|
||||||
|
<div class="details-content">
|
||||||
|
<pre>{{ errorDetails() }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Support Message -->
|
||||||
|
@if (showSupport()) {
|
||||||
|
<div class="support-message">
|
||||||
|
<p>If the problem persists, please contact support.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<string>('Something Went Wrong');
|
||||||
|
message = input<string>('An unexpected error occurred. Please try again or contact support if the problem persists.');
|
||||||
|
errorCode = input<string | null>(null);
|
||||||
|
errorType = input<'500' | '404' | '403' | '401' | 'network' | 'default'>('default');
|
||||||
|
errorDetails = input<string | null>(null);
|
||||||
|
showRetry = input<boolean>(true);
|
||||||
|
showReload = input<boolean>(false);
|
||||||
|
showBack = input<boolean>(true);
|
||||||
|
showDetails = input<boolean>(false);
|
||||||
|
showSupport = input<boolean>(true);
|
||||||
|
|
||||||
|
// Output events
|
||||||
|
retry = output<void>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@
|
|||||||
<span class="logo-text">Interview Quiz</span>
|
<span class="logo-text">Interview Quiz</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search (Desktop Only) -->
|
||||||
|
<div class="search-wrapper desktop-only">
|
||||||
|
<app-search></app-search>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
.spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { GuestService } from '../../../core/services/guest.service';
|
|||||||
import { QuizService } from '../../../core/services/quiz.service';
|
import { QuizService } from '../../../core/services/quiz.service';
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
||||||
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
|
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
|
||||||
|
import { SearchComponent } from '../search/search.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@@ -31,7 +32,8 @@ import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dia
|
|||||||
MatDividerModule,
|
MatDividerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatChipsModule
|
MatChipsModule,
|
||||||
|
SearchComponent
|
||||||
],
|
],
|
||||||
templateUrl: './header.html',
|
templateUrl: './header.html',
|
||||||
styleUrl: './header.scss'
|
styleUrl: './header.scss'
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<div class="loading-spinner-container" [class.overlay]="overlay()">
|
<div
|
||||||
|
class="loading-spinner-container"
|
||||||
|
[class.overlay]="overlay()"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
[attr.aria-label]="message()">
|
||||||
<div class="spinner-wrapper">
|
<div class="spinner-wrapper">
|
||||||
<mat-spinner [diameter]="size()"></mat-spinner>
|
<mat-spinner [diameter]="size()"></mat-spinner>
|
||||||
<p class="loading-message">{{ message() }}</p>
|
<p class="loading-message" aria-hidden="true">{{ message() }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
* <app-pagination
|
||||||
|
* [state]="paginationState()"
|
||||||
|
* [pageSizeOptions]="[10, 25, 50, 100]"
|
||||||
|
* [showPageSizeSelector]="true"
|
||||||
|
* [showFirstLast]="true"
|
||||||
|
* [maxVisiblePages]="5"
|
||||||
|
* (pageChange)="onPageChange($event)"
|
||||||
|
* (pageSizeChange)="onPageSizeChange($event)">
|
||||||
|
* </app-pagination>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-pagination',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatFormFieldModule
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="pagination-container" *ngIf="state()">
|
||||||
|
<!-- Results info -->
|
||||||
|
<div class="pagination-info">
|
||||||
|
<span class="info-text">
|
||||||
|
Showing <strong>{{ state()!.startIndex }}</strong>
|
||||||
|
to <strong>{{ state()!.endIndex }}</strong>
|
||||||
|
of <strong>{{ state()!.totalItems }}</strong> {{ itemLabel() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-actions">
|
||||||
|
<!-- Page size selector -->
|
||||||
|
@if (showPageSizeSelector()) {
|
||||||
|
<mat-form-field class="page-size-selector" appearance="outline">
|
||||||
|
<mat-select
|
||||||
|
[value]="state()!.pageSize"
|
||||||
|
(selectionChange)="onPageSizeChange($event.value)"
|
||||||
|
aria-label="Items per page">
|
||||||
|
@for (option of pageSizeOptions(); track option) {
|
||||||
|
<mat-option [value]="option">{{ option }} per page</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Pagination controls -->
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<!-- First page -->
|
||||||
|
@if (showFirstLast()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="onPageChange(1)"
|
||||||
|
[disabled]="!state()!.hasPrev"
|
||||||
|
matTooltip="First page"
|
||||||
|
aria-label="Go to first page">
|
||||||
|
<mat-icon>first_page</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Previous page -->
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="onPageChange(state()!.currentPage - 1)"
|
||||||
|
[disabled]="!state()!.hasPrev"
|
||||||
|
matTooltip="Previous page"
|
||||||
|
aria-label="Go to previous page">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
<div class="page-numbers">
|
||||||
|
@for (page of pageNumbers(); track page) {
|
||||||
|
@if (page === '...') {
|
||||||
|
<span class="ellipsis">{{ page }}</span>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
class="page-button"
|
||||||
|
[class.active]="page === state()!.currentPage"
|
||||||
|
(click)="handlePageClick(page)"
|
||||||
|
[attr.aria-label]="'Go to page ' + page"
|
||||||
|
[attr.aria-current]="page === state()!.currentPage ? 'page' : null">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next page -->
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="onPageChange(state()!.currentPage + 1)"
|
||||||
|
[disabled]="!state()!.hasNext"
|
||||||
|
matTooltip="Next page"
|
||||||
|
aria-label="Go to next page">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
@if (showFirstLast()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="onPageChange(state()!.totalPages)"
|
||||||
|
[disabled]="!state()!.hasNext"
|
||||||
|
matTooltip="Last page"
|
||||||
|
aria-label="Go to last page">
|
||||||
|
<mat-icon>last_page</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<PaginationState | null>();
|
||||||
|
pageNumbers = input<(number | string)[]>([]);
|
||||||
|
pageSizeOptions = input<number[]>([10, 25, 50, 100]);
|
||||||
|
showPageSizeSelector = input<boolean>(true);
|
||||||
|
showFirstLast = input<boolean>(true);
|
||||||
|
maxVisiblePages = input<number>(5);
|
||||||
|
itemLabel = input<string>('results');
|
||||||
|
|
||||||
|
// Output events
|
||||||
|
pageChange = output<number>();
|
||||||
|
pageSizeChange = output<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
frontend/src/app/shared/components/search/search.component.html
Normal file
166
frontend/src/app/shared/components/search/search.component.html
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<div class="search-container">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
<input
|
||||||
|
#searchInput
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Search questions, categories, quizzes..."
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
(ngModelChange)="onSearchInput($event)"
|
||||||
|
(keydown)="onKeyDown($event)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (searchQuery()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
(click)="clearSearch()"
|
||||||
|
aria-label="Clear search">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results Dropdown -->
|
||||||
|
@if (showDropdown()) {
|
||||||
|
<div class="search-dropdown" role="listbox">
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isSearching()) {
|
||||||
|
<div class="search-loading">
|
||||||
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
|
<p>Searching...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
@else if (isEmptySearch()) {
|
||||||
|
<div class="search-empty">
|
||||||
|
<mat-icon>search_off</mat-icon>
|
||||||
|
<p>No results found for "<strong>{{ searchQuery() }}</strong>"</p>
|
||||||
|
<span class="hint">Try different keywords or check your spelling</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
@else if (hasResults()) {
|
||||||
|
<div class="search-results">
|
||||||
|
<!-- Categories Section -->
|
||||||
|
@if (searchResults().categories.length > 0) {
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
<span>Categories</span>
|
||||||
|
<span class="count">{{ searchResults().categories.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (category of searchResults().categories; track category.id) {
|
||||||
|
<div
|
||||||
|
class="result-item"
|
||||||
|
[class.selected]="isSelected(category)"
|
||||||
|
(click)="navigateToResult(category)"
|
||||||
|
role="option">
|
||||||
|
<mat-icon class="result-icon">{{ category.icon || 'category' }}</mat-icon>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-title" [innerHTML]="category.highlight || category.title"></div>
|
||||||
|
@if (category.description) {
|
||||||
|
<div class="result-description">{{ category.description }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-icon class="navigate-icon">chevron_right</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Questions Section -->
|
||||||
|
@if (searchResults().questions.length > 0) {
|
||||||
|
@if (searchResults().categories.length > 0) {
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<mat-icon>quiz</mat-icon>
|
||||||
|
<span>Questions</span>
|
||||||
|
<span class="count">{{ searchResults().questions.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (question of searchResults().questions; track question.id) {
|
||||||
|
<div
|
||||||
|
class="result-item"
|
||||||
|
[class.selected]="isSelected(question)"
|
||||||
|
(click)="navigateToResult(question)"
|
||||||
|
role="option">
|
||||||
|
<mat-icon class="result-icon">quiz</mat-icon>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-title" [innerHTML]="question.highlight || question.title"></div>
|
||||||
|
<div class="result-meta">
|
||||||
|
@if (question.category) {
|
||||||
|
<span class="meta-item">
|
||||||
|
<mat-icon>category</mat-icon>
|
||||||
|
{{ question.category }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (question.difficulty) {
|
||||||
|
<mat-chip [color]="getDifficultyColor(question.difficulty)" class="difficulty-chip">
|
||||||
|
{{ question.difficulty }}
|
||||||
|
</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-icon class="navigate-icon">chevron_right</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Quizzes Section -->
|
||||||
|
@if (searchResults().quizzes.length > 0) {
|
||||||
|
@if (searchResults().categories.length > 0 || searchResults().questions.length > 0) {
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<mat-icon>assessment</mat-icon>
|
||||||
|
<span>Quiz History</span>
|
||||||
|
<span class="count">{{ searchResults().quizzes.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (quiz of searchResults().quizzes; track quiz.id) {
|
||||||
|
<div
|
||||||
|
class="result-item"
|
||||||
|
[class.selected]="isSelected(quiz)"
|
||||||
|
(click)="navigateToResult(quiz)"
|
||||||
|
role="option">
|
||||||
|
<mat-icon class="result-icon">assessment</mat-icon>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-title">{{ quiz.title }}</div>
|
||||||
|
@if (quiz.description) {
|
||||||
|
<div class="result-description">{{ quiz.description }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-icon class="navigate-icon">chevron_right</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- See All Results Link -->
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div class="see-all-link">
|
||||||
|
<button mat-button color="primary" (click)="viewAllResults()">
|
||||||
|
<mat-icon>open_in_new</mat-icon>
|
||||||
|
See all {{ searchResults().totalResults }} results
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
352
frontend/src/app/shared/components/search/search.component.scss
Normal file
352
frontend/src/app/shared/components/search/search.component.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
313
frontend/src/app/shared/components/search/search.component.ts
Normal file
313
frontend/src/app/shared/components/search/search.component.ts
Normal file
@@ -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<HTMLInputElement>;
|
||||||
|
|
||||||
|
// Service signals
|
||||||
|
readonly searchResults = this.searchService.searchResults;
|
||||||
|
readonly isSearching = this.searchService.isSearching;
|
||||||
|
readonly hasSearched = this.searchService.hasSearched;
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
readonly searchQuery = signal<string>('');
|
||||||
|
readonly showDropdown = signal<boolean>(false);
|
||||||
|
readonly selectedIndex = signal<number>(-1);
|
||||||
|
|
||||||
|
// Search input subject for debouncing
|
||||||
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user