add changes

This commit is contained in:
AD2025
2025-11-14 21:48:47 +02:00
parent 6f23890407
commit 37b4d565b1
72 changed files with 17104 additions and 246 deletions

View File

@@ -499,21 +499,21 @@
**Purpose:** Update user profile
**Frontend Tasks:**
- [ ] Add `UserService.updateProfile(userId, data)` method
- [ ] Update `authState` signal with new user data
- [ ] Validate form data (email, username)
- [ ] Handle 409 Conflict for duplicate email/username
- [ ] Handle password change separately (if supported)
- [x] Add `UserService.updateProfile(userId, data)` method
- [x] Update `authState` signal with new user data
- [x] Validate form data (email, username)
- [x] Handle 409 Conflict for duplicate email/username
- [x] Handle password change separately (if supported)
**UI Tasks:**
- [ ] Build `ProfileSettingsComponent` for editing profile
- [ ] Create form with username, email fields
- [ ] Add password change section (current, new, confirm)
- [ ] Show validation errors inline
- [ ] Add "Save Changes" and "Cancel" buttons
- [ ] Display success toast after update
- [ ] Show loading spinner on submit
- [ ] Pre-fill form with current user data
- [x] Build `ProfileSettingsComponent` for editing profile
- [x] Create form with username, email fields
- [x] Add password change section (current, new, confirm)
- [x] Show validation errors inline
- [x] Add "Save Changes" and "Cancel" buttons
- [x] Display success toast after update
- [x] Show loading spinner on submit
- [x] Pre-fill form with current user data
---
@@ -523,19 +523,19 @@
**Purpose:** Get user's bookmarked questions
**Frontend Tasks:**
- [ ] Create `BookmarkService` with `getBookmarks(userId)` method
- [ ] Store bookmarks in `bookmarksState` signal
- [ ] Implement caching (5 min TTL)
- [ ] Handle 401 if not authenticated
- [x] Create `BookmarkService` with `getBookmarks(userId)` method
- [x] Store bookmarks in `bookmarksState` signal
- [x] Implement caching (5 min TTL)
- [x] Handle 401 if not authenticated
**UI Tasks:**
- [ ] Build `BookmarksComponent` displaying all bookmarked questions
- [ ] Show question cards with text, category, difficulty
- [ ] Add "Remove Bookmark" button for each question
- [ ] Add "Practice Bookmarked Questions" button to start quiz
- [ ] Show empty state if no bookmarks
- [ ] Implement grid layout (responsive)
- [ ] Add search/filter for bookmarks
- [x] Build `BookmarksComponent` displaying all bookmarked questions
- [x] Show question cards with text, category, difficulty
- [x] Add "Remove Bookmark" button for each question
- [x] Add "Practice Bookmarked Questions" button to start quiz
- [x] Show empty state if no bookmarks
- [x] Implement grid layout (responsive)
- [x] Add search/filter for bookmarks
---
@@ -543,17 +543,17 @@
**Purpose:** Add question to bookmarks
**Frontend Tasks:**
- [ ] Add `BookmarkService.addBookmark(userId, questionId)` method
- [ ] Update `bookmarksState` signal optimistically
- [ ] Handle 409 if already bookmarked
- [ ] Show success/error toast
- [x] Add `BookmarkService.addBookmark(userId, questionId)` method
- [x] Update `bookmarksState` signal optimistically
- [x] Handle 409 if already bookmarked
- [x] Show success/error toast
**UI Tasks:**
- [ ] Add bookmark icon button on question cards
- [ ] Show filled/unfilled icon based on bookmark status
- [ ] Animate icon on toggle
- [ ] Display success toast "Question bookmarked"
- [ ] Ensure button is accessible with ARIA label
- [x] Add bookmark icon button on question cards
- [x] Show filled/unfilled icon based on bookmark status
- [x] Animate icon on toggle
- [x] Display success toast "Question bookmarked"
- [x] Ensure button is accessible with ARIA label
---
@@ -561,14 +561,14 @@
**Purpose:** Remove bookmark
**Frontend Tasks:**
- [ ] Add `BookmarkService.removeBookmark(userId, questionId)` method
- [ ] Update `bookmarksState` signal optimistically
- [ ] Handle 404 if bookmark not found
- [x] Add `BookmarkService.removeBookmark(userId, questionId)` method
- [x] Update `bookmarksState` signal optimistically
- [x] Handle 404 if bookmark not found
**UI Tasks:**
- [ ] Toggle bookmark icon to unfilled state
- [ ] Remove question from bookmarks list
- [ ] Display success toast "Bookmark removed"
- [x] Toggle bookmark icon to unfilled state
- [x] Remove question from bookmarks list
- [x] Display success toast "Bookmark removed"
- [ ] Add undo option in toast (optional)
---
@@ -579,25 +579,25 @@
**Purpose:** Get system-wide statistics
**Frontend Tasks:**
- [ ] Create `AdminService` with `getStatistics()` method
- [ ] Store stats in `adminStatsState` signal
- [ ] Implement caching (5 min TTL)
- [ ] Handle 401/403 authorization errors
- [ ] Create admin auth guard for routes
- [x] Create `AdminService` with `getStatistics()` method
- [x] Store stats in `adminStatsState` signal
- [x] Implement caching (5 min TTL)
- [x] Handle 401/403 authorization errors
- [x] Create admin auth guard for routes
**UI Tasks:**
- [ ] Build `AdminDashboardComponent` as admin landing page
- [ ] Display statistics cards:
- [x] Build `AdminDashboardComponent` as admin landing page
- [x] Display statistics cards:
- Total users
- Active users (last 7 days)
- Total quiz sessions
- Total questions
- [ ] Show user growth chart (line chart)
- [ ] Display most popular categories (bar chart)
- [ ] Show average quiz scores
- [ ] Add date range picker for filtering stats
- [ ] Implement responsive layout
- [ ] Show loading skeletons for charts
- [x] Show user growth chart (line chart)
- [x] Display most popular categories (bar chart)
- [x] Show average quiz scores
- [x] Add date range picker for filtering stats
- [x] Implement responsive layout
- [x] Show loading skeletons for charts
---
@@ -605,20 +605,20 @@
**Purpose:** Get guest user analytics
**Frontend Tasks:**
- [ ] Add `AdminService.getGuestAnalytics()` method
- [ ] Store analytics in `guestAnalyticsState` signal
- [ ] Implement caching (10 min TTL)
- [x] Add `AdminService.getGuestAnalytics()` method
- [x] Store analytics in `guestAnalyticsState` signal
- [x] Implement caching (10 min TTL)
**UI Tasks:**
- [ ] Build `GuestAnalyticsComponent` (admin)
- [ ] Display guest statistics:
- [x] Build `GuestAnalyticsComponent` (admin)
- [x] Display guest statistics:
- Total guest sessions
- Active guest sessions
- Guest-to-user conversion rate
- Average quizzes per guest
- [ ] Show conversion funnel chart
- [ ] Display guest session timeline chart
- [ ] Add export functionality
- [x] Show conversion funnel chart
- [x] Display guest session timeline chart
- [x] Add export functionality
---
@@ -626,13 +626,13 @@
**Purpose:** Get guest access settings
**Frontend Tasks:**
- [ ] Add `AdminService.getGuestSettings()` method
- [ ] Store settings in `guestSettingsState` signal
- [x] Add `AdminService.getGuestSettings()` method
- [x] Store settings in `guestSettingsState` signal
**UI Tasks:**
- [ ] Build `GuestSettingsComponent` (admin) for viewing settings
- [ ] Display current settings in read-only cards
- [ ] Add "Edit Settings" button
- [x] Build `GuestSettingsComponent` (admin) for viewing settings
- [x] Display current settings in read-only cards
- [x] Add "Edit Settings" button
---
@@ -640,22 +640,22 @@
**Purpose:** Update guest access settings
**Frontend Tasks:**
- [ ] Add `AdminService.updateGuestSettings(data)` method
- [ ] Update `guestSettingsState` signal
- [ ] Validate form data
- [ ] Handle success/error responses
- [x] Add `AdminService.updateGuestSettings(data)` method
- [x] Update `guestSettingsState` signal
- [x] Validate form data
- [x] Handle success/error responses
**UI Tasks:**
- [ ] Build settings form with fields:
- [x] Build settings form with fields:
- Guest access enabled toggle
- Max quizzes per day (number input)
- Max questions per quiz (number input)
- Session expiry hours (number input)
- Upgrade prompt message (textarea)
- [ ] Add "Save Changes" and "Cancel" buttons
- [ ] Show validation errors inline
- [ ] Display success toast after update
- [ ] Show preview of settings changes
- [x] Add "Save Changes" and "Cancel" buttons
- [x] Show validation errors inline
- [x] Display success toast after update
- [x] Show preview of settings changes
---
@@ -663,21 +663,21 @@
**Purpose:** Get all users with pagination
**Frontend Tasks:**
- [ ] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method
- [ ] Store users in `adminUsersState` signal
- [ ] Implement pagination, filtering, and sorting
- [ ] Handle query parameters in URL
- [x] Add `AdminService.getUsers(page, limit, role?, isActive?, sortBy?)` method
- [x] Store users in `adminUsersState` signal
- [x] Implement pagination, filtering, and sorting
- [x] Handle query parameters in URL
**UI Tasks:**
- [ ] Build `AdminUsersComponent` displaying user list
- [ ] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions
- [ ] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive)
- [ ] Add sort dropdown (Username, Email, Date)
- [ ] Add search input for username/email
- [ ] Implement pagination controls
- [ ] Add action buttons (Edit Role, View Details, Deactivate/Activate)
- [ ] Show loading spinner during fetch
- [ ] Make table responsive (stack on mobile)
- [x] Build `AdminUsersComponent` displaying user list
- [x] Create user table with columns: Username, Email, Role, Status, Joined Date, Actions
- [x] Add filter dropdowns (Role: All/User/Admin, Status: All/Active/Inactive)
- [x] Add sort dropdown (Username, Email, Date)
- [x] Add search input for username/email
- [x] Implement pagination controls
- [x] Add action buttons (Edit Role, View Details, Deactivate/Activate)
- [x] Show loading spinner during fetch
- [x] Make table responsive (stack on mobile)
---
@@ -685,16 +685,16 @@
**Purpose:** Get user details
**Frontend Tasks:**
- [ ] Add `AdminService.getUserDetails(userId)` method
- [ ] Store user details in signal
- [ ] Handle 404 if user not found
- [x] Add `AdminService.getUserDetails(userId)` method
- [x] Store user details in signal
- [x] Handle 404 if user not found
**UI Tasks:**
- [ ] Build `AdminUserDetailComponent` showing full user profile
- [ ] Display user info, statistics, quiz history
- [ ] Add "Edit Role" and "Deactivate" buttons
- [ ] Show user activity timeline
- [ ] Add breadcrumb navigation
- [x] Build `AdminUserDetailComponent` showing full user profile
- [x] Display user info, statistics, quiz history
- [x] Add "Edit Role" and "Deactivate" buttons
- [x] Show user activity timeline
- [x] Add breadcrumb navigation
---
@@ -702,16 +702,16 @@
**Purpose:** Update user role
**Frontend Tasks:**
- [ ] Add `AdminService.updateUserRole(userId, role)` method
- [ ] Update user in `adminUsersState` signal
- [ ] Handle validation errors
- [x] Add `AdminService.updateUserRole(userId, role)` method
- [x] Update user in `adminUsersState` signal
- [x] Handle validation errors
**UI Tasks:**
- [ ] Build role update modal/dialog
- [ ] Add role selector (User, Admin)
- [ ] Show confirmation dialog
- [ ] Display success toast after update
- [ ] Show warning if demoting admin
- [x] Build role update modal/dialog
- [x] Add role selector (User, Admin)
- [x] Show confirmation dialog
- [x] Display success toast after update
- [x] Show warning if demoting admin
---
@@ -719,13 +719,13 @@
**Purpose:** Reactivate user
**Frontend Tasks:**
- [ ] Add `AdminService.activateUser(userId)` method
- [ ] Update user status in signal
- [x] Add `AdminService.activateUser(userId)` method
- [x] Update user status in signal
**UI Tasks:**
- [ ] Add "Activate" button for inactive users
- [ ] Show confirmation dialog
- [ ] Display success toast after activation
- [x] Add "Activate" button for inactive users
- [x] Show confirmation dialog
- [x] Display success toast after activation
---
@@ -733,15 +733,15 @@
**Purpose:** Deactivate user
**Frontend Tasks:**
- [ ] Add `AdminService.deactivateUser(userId)` method
- [ ] Update user status in signal
- [ ] Handle soft delete
- [x] Add `AdminService.deactivateUser(userId)` method
- [x] Update user status in signal
- [x] Handle soft delete
**UI Tasks:**
- [ ] Add "Deactivate" button for active users
- [ ] Show confirmation dialog with warning message
- [ ] Display success toast after deactivation
- [ ] Show "Reactivate" button for deactivated users
- [x] Add "Deactivate" button for active users
- [x] Show confirmation dialog with warning message
- [x] Display success toast after deactivation
- [x] Show "Reactivate" button for deactivated users
---
@@ -749,13 +749,13 @@
**Purpose:** Create new question
**Frontend Tasks:**
- [ ] Add `AdminService.createQuestion(data)` method
- [ ] Validate question data (type, options, correct answer)
- [ ] Handle 401/403 authorization errors
- [x] Add `AdminService.createQuestion(data)` method
- [x] Validate question data (type, options, correct answer)
- [x] Handle 401/403 authorization errors
**UI Tasks:**
- [ ] Build `AdminQuestionFormComponent` for creating questions
- [ ] Create form with fields:
- [x] Build `AdminQuestionFormComponent` for creating questions
- [x] Create form with fields:
- Question text (textarea)
- Question type selector (Multiple Choice, True/False, Written)
- Category selector
@@ -766,13 +766,13 @@
- Points (number)
- Tags (chip input)
- Guest accessible checkbox
- [ ] Show/hide options based on question type
- [ ] Add dynamic option inputs for MCQ (Add/Remove buttons)
- [ ] Validate correct answer matches options
- [ ] Show question preview panel
- [ ] Display validation errors inline
- [ ] Add "Save Question" and "Cancel" buttons
- [ ] Show success toast after creation
- [x] Show/hide options based on question type
- [x] Add dynamic option inputs for MCQ (Add/Remove buttons)
- [x] Validate correct answer matches options
- [x] Show question preview panel
- [x] Display validation errors inline
- [x] Add "Save Question" and "Cancel" buttons
- [x] Show success toast after creation
---
@@ -780,16 +780,16 @@
**Purpose:** Update question
**Frontend Tasks:**
- [ ] Add `AdminService.updateQuestion(id, data)` method
- [ ] Pre-fill form with existing question data
- [ ] Handle 404 if question not found
- [x] Add `AdminService.updateQuestion(id, data)` method
- [x] Pre-fill form with existing question data
- [x] Handle 404 if question not found
**UI Tasks:**
- [ ] Reuse `AdminQuestionFormComponent` in edit mode
- [ ] Pre-populate all form fields
- [ ] Show "Editing: Question ID" header
- [x] Reuse `AdminQuestionFormComponent` in edit mode
- [x] Pre-populate all form fields
- [x] Show "Editing: Question ID" header
- [ ] Add version history section (optional)
- [ ] Display success toast after update
- [x] Display success toast after update
---
@@ -797,14 +797,14 @@
**Purpose:** Delete question
**Frontend Tasks:**
- [ ] Add `AdminService.deleteQuestion(id)` method
- [ ] Handle soft delete
- [ ] Update question list after deletion
- [x] Add `AdminService.deleteQuestion(id)` method
- [x] Handle soft delete
- [x] Update question list after deletion
**UI Tasks:**
- [ ] Add delete button in admin question list
- [ ] Show confirmation dialog with warning
- [ ] Display success toast after deletion
- [x] Add delete button in admin question list
- [x] Show confirmation dialog with warning
- [x] Display success toast after deletion
- [ ] Add "Restore" option for soft-deleted questions
---
@@ -814,21 +814,21 @@
### Search Functionality
**Frontend Tasks:**
- [ ] Create `SearchService` for global search
- [ ] Implement debounced search input
- [ ] Search across questions, categories, quizzes
- [ ] Store search results in signal
- [ ] Handle empty search results
- [x] Create `SearchService` for global search
- [x] Implement debounced search input
- [x] Search across questions, categories, quizzes
- [x] Store search results in signal
- [x] Handle empty search results
**UI Tasks:**
- [ ] Build `SearchComponent` in header/navbar
- [ ] Create search input with icon
- [ ] Display search results dropdown
- [ ] Highlight matching text in results
- [ ] Add "See All Results" link
- [ ] Implement keyboard navigation (arrow keys, enter)
- [ ] Show loading indicator during search
- [ ] Display empty state for no results
- [x] Build `SearchComponent` in header/navbar
- [x] Create search input with icon
- [x] Display search results dropdown
- [x] Highlight matching text in results
- [x] Add "See All Results" link
- [x] Implement keyboard navigation (arrow keys, enter)
- [x] Show loading indicator during search
- [x] Display empty state for no results
---
@@ -850,7 +850,7 @@
---
### Social Share
<!-- ### Social Share
**Frontend Tasks:**
- [ ] Create `ShareService` for social sharing
@@ -866,62 +866,79 @@
- [ ] Create shareable result card template
- [ ] Add privacy toggle (public/private share)
---
--- -->
### Pagination Component
**Frontend Tasks:**
- [ ] Create reusable `PaginationService`
- [ ] Calculate page numbers and ranges
- [ ] Handle page change events
- [ ] Update URL query parameters
- [x] Create reusable `PaginationService`
- [x] Calculate page numbers and ranges
- [x] Handle page change events
- [x] Update URL query parameters
**UI Tasks:**
- [ ] Build reusable `PaginationComponent`
- [ ] Show Previous, Next, and page numbers
- [ ] Highlight current page
- [ ] Disable Previous on first page, Next on last page
- [ ] Display "Showing X-Y of Z results"
- [ ] Implement responsive design (fewer page numbers on mobile)
- [x] Build reusable `PaginationComponent`
- [x] Show Previous, Next, and page numbers
- [x] Highlight current page
- [x] Disable Previous on first page, Next on last page
- [x] Display "Showing X-Y of Z results"
- [x] Implement responsive design (fewer page numbers on mobile)
---
### Error Handling
**Frontend Tasks:**
- [ ] Create global error handler service
- [ ] Log errors to console and external service (optional)
- [ ] Display user-friendly error messages
- [ ] Handle network errors gracefully
- [ ] Implement retry logic for failed requests
- [x] Create global error handler service
- [x] Log errors to console and external service (optional)
- [x] Display user-friendly error messages
- [x] Handle network errors gracefully
- [x] Implement retry logic for failed requests
**UI Tasks:**
- [ ] Build `ErrorComponent` for displaying errors
- [ ] Create error toast component
- [ ] Show "Something went wrong" page for critical errors
- [ ] Add "Retry" button for recoverable errors
- [ ] Display specific error messages (401, 403, 404, 500)
- [x] Build `ErrorComponent` for displaying errors
- [x] Create error toast component
- [x] Show "Something went wrong" page for critical errors
- [x] Add "Retry" button for recoverable errors
- [x] Display specific error messages (401, 403, 404, 500)
---
### Loading States
**Frontend Tasks:**
- [ ] Create `LoadingService` for global loading state
- [ ] Use signals for loading indicators
- [ ] Show loading during HTTP requests
- [ ] Handle concurrent loading states
- [x] Create `LoadingService` for global loading state
- [x] Use signals for loading indicators
- [x] Show loading during HTTP requests
- [x] Handle concurrent loading states
**UI Tasks:**
- [ ] Build reusable `LoadingSpinnerComponent`
- [ ] Create skeleton loaders for lists and cards
- [ ] Show inline loading spinners on buttons
- [ ] Add progress bar at top of page for navigation
- [ ] Ensure loading states are accessible (ARIA live regions)
- [x] Build reusable `LoadingSpinnerComponent`
- [x] Create skeleton loaders for lists and cards
- [x] Show inline loading spinners on buttons
- [x] Add progress bar at top of page for navigation
- [x] Ensure loading states are accessible (ARIA live regions)
**Completed Implementation:**
-**LoadingService integrated with HTTP interceptors**
- Created `loading.interceptor.ts` that automatically shows/hides loading during HTTP requests
- Registered in app.config.ts as first interceptor in the chain
- Supports `X-Skip-Loading` header to skip loading for specific requests (e.g., polling)
- Uses LoadingService counter to handle concurrent requests properly
-**Navigation progress bar added**
- Mat-progress-bar displayed at top of page during route transitions
- Listens to Router events (NavigationStart/End/Cancel/Error)
- Fixed position with high z-index above all content
- Custom styling with primary color theme
-**Accessibility features implemented**
- LoadingSpinnerComponent has `role="status"` and `aria-live="polite"`
- Dynamic `aria-label` with loading message
- Navigation progress bar has `role="progressbar"` and `aria-label="Page loading"`
- Visual loading message marked with `aria-hidden="true"` to avoid duplication
---
### Offline Support
<!-- ### Offline Support
**Frontend Tasks:**
- [ ] Implement service worker for PWA
@@ -956,42 +973,42 @@
- [ ] Design install button/banner
- [ ] Test PWA on mobile devices (iOS, Android)
---
--- -->
## Routing & Navigation
**Frontend Tasks:**
- [ ] Configure app routes with lazy loading:
- `/` - Landing page (guest welcome or dashboard)
- `/login` - Login page
- `/register` - Register page
- `/dashboard` - User dashboard (auth guard)
- `/categories` - Category list
- `/categories/:id` - Category detail
- `/quiz/setup` - Quiz setup
- `/quiz/:sessionId` - Active quiz
- `/quiz/:sessionId/results` - Quiz results
- `/quiz/:sessionId/review` - Quiz review
- `/bookmarks` - Bookmarked questions (auth guard)
- `/history` - Quiz history (auth guard)
- `/profile` - User profile (auth guard)
- `/admin` - Admin dashboard (admin guard)
- `/admin/users` - User management (admin guard)
- `/admin/questions` - Question management (admin guard)
- `/admin/categories` - Category management (admin guard)
- `/admin/settings` - Guest settings (admin guard)
- `/admin/analytics` - Analytics (admin guard)
- [ ] Create auth guard for protected routes
- [ ] Create admin guard for admin-only routes
- [ ] Create guest guard to prevent access to auth-only content
- [ ] Implement route preloading strategy
- [ ] Handle 404 redirect
- [x] Configure app routes with lazy loading:
- [x] `/` - Landing page (guest welcome or dashboard)
- [x] `/login` - Login page
- [x] `/register` - Register page
- [x] `/dashboard` - User dashboard (auth guard)
- [x] `/categories` - Category list
- [x] `/categories/:id` - Category detail
- [x] `/quiz/setup` - Quiz setup
- [x] `/quiz/:sessionId` - Active quiz
- [x] `/quiz/:sessionId/results` - Quiz results
- [x] `/quiz/:sessionId/review` - Quiz review
- [x] `/bookmarks` - Bookmarked questions (auth guard)
- [x] `/history` - Quiz history (auth guard)
- [x] `/profile` - User profile (auth guard)
- [x] `/admin` - Admin dashboard (admin guard)
- [x] `/admin/users` - User management (admin guard)
- [x] `/admin/questions` - Question management (admin guard)
- [x] `/admin/categories` - Category management (admin guard)
- [x] `/admin/settings` - Guest settings (admin guard) - **Exists as /admin/guest-settings**
- [x] `/admin/analytics` - Analytics (admin guard)
- [x] Create auth guard for protected routes
- [x] Create admin guard for admin-only routes
- [x] Create guest guard to prevent access to auth-only content
- [x] Implement route preloading strategy
- [x] Handle 404 redirect
**UI Tasks:**
- [ ] Create navigation menu with links
- [ ] Highlight active route in navigation
- [ ] Implement breadcrumb component
- [ ] Add back button where appropriate
- [x] Create navigation menu with links
- [x] Highlight active route in navigation
- [x] Implement breadcrumb component
- [x] Add back button where appropriate
- [ ] Ensure smooth transitions between routes
---

View File

@@ -1,23 +1,29 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import { authInterceptor, guestInterceptor, errorInterceptor } from './core/interceptors';
import { authInterceptor, guestInterceptor, errorInterceptor, loadingInterceptor } from './core/interceptors';
import { GlobalErrorHandlerService } from './core/services/global-error-handler.service';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideRouter(
routes,
withPreloading(PreloadAllModules)
),
provideAnimationsAsync(),
provideHttpClient(
withInterceptors([
loadingInterceptor,
authInterceptor,
guestInterceptor,
errorInterceptor
])
)
),
{ provide: ErrorHandler, useClass: GlobalErrorHandlerService }
]
};

View File

@@ -5,6 +5,16 @@
<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 -->
<app-toast-container></app-toast-container>

View File

@@ -1,7 +1,30 @@
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { authGuard, guestGuard } from './core/guards';
import { adminGuard } from './core/guards/admin.guard';
import { AuthService } from './core/services/auth.service';
export const routes: Routes = [
// Root route - redirect based on authentication status
{
path: '',
pathMatch: 'full',
canActivate: [() => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
router.navigate(['/dashboard']);
return false;
} else {
router.navigate(['/categories']);
return false;
}
}],
children: []
},
// Authentication routes (guest only - redirect to dashboard if already logged in)
{
path: 'login',
@@ -51,6 +74,22 @@ export const routes: Routes = [
title: 'Quiz History - Quiz Platform'
},
// Profile Settings route (protected)
{
path: 'profile',
loadComponent: () => import('./features/profile/profile-settings.component').then(m => m.ProfileSettingsComponent),
canActivate: [authGuard],
title: 'Profile Settings - Quiz Platform'
},
// Bookmarks route (protected)
{
path: 'bookmarks',
loadComponent: () => import('./features/bookmarks/bookmarks.component').then(m => m.BookmarksComponent),
canActivate: [authGuard],
title: 'My Bookmarks - Quiz Platform'
},
// Quiz routes
{
path: 'quiz/setup',
@@ -73,23 +112,87 @@ export const routes: Routes = [
title: 'Review Quiz - Quiz Platform'
},
// Admin routes (TODO: Add adminGuard)
// Admin routes (protected with adminGuard)
{
path: 'admin',
loadComponent: () => import('./features/admin/admin-dashboard/admin-dashboard.component').then(m => m.AdminDashboardComponent),
canActivate: [adminGuard],
title: 'Admin Dashboard - Quiz Platform'
},
{
path: 'admin/analytics',
loadComponent: () => import('./features/admin/guest-analytics/guest-analytics.component').then(m => m.GuestAnalyticsComponent),
canActivate: [adminGuard],
title: 'Guest Analytics - Admin'
},
{
path: 'admin/guest-settings',
loadComponent: () => import('./features/admin/guest-settings/guest-settings.component').then(m => m.GuestSettingsComponent),
canActivate: [adminGuard],
title: 'Guest Settings - Admin'
},
{
path: 'admin/guest-settings/edit',
loadComponent: () => import('./features/admin/guest-settings-edit/guest-settings-edit.component').then(m => m.GuestSettingsEditComponent),
canActivate: [adminGuard],
title: 'Edit Guest Settings - Admin'
},
{
path: 'admin/users',
loadComponent: () => import('./features/admin/admin-users/admin-users.component').then(m => m.AdminUsersComponent),
canActivate: [adminGuard],
title: 'User Management - Admin'
},
{
path: 'admin/users/:id',
loadComponent: () => import('./features/admin/admin-user-detail/admin-user-detail.component').then(m => m.AdminUserDetailComponent),
canActivate: [adminGuard],
title: 'User Details - Admin'
},
{
path: 'admin/questions',
loadComponent: () => import('./features/admin/admin-questions/admin-questions.component').then(m => m.AdminQuestionsComponent),
canActivate: [adminGuard],
title: 'Manage Questions - Admin'
},
{
path: 'admin/questions/new',
loadComponent: () => import('./features/admin/admin-question-form/admin-question-form.component').then(m => m.AdminQuestionFormComponent),
canActivate: [adminGuard],
title: 'Create Question - Admin'
},
{
path: 'admin/questions/:id/edit',
loadComponent: () => import('./features/admin/admin-question-form/admin-question-form.component').then(m => m.AdminQuestionFormComponent),
canActivate: [adminGuard],
title: 'Edit Question - Admin'
},
{
path: 'admin/categories',
loadComponent: () => import('./features/admin/admin-category-list/admin-category-list').then(m => m.AdminCategoryListComponent),
canActivate: [adminGuard],
title: 'Manage Categories - Admin'
},
{
path: 'admin/categories/new',
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
canActivate: [adminGuard],
title: 'Create Category - Admin'
},
{
path: 'admin/categories/edit/:id',
loadComponent: () => import('./features/admin/category-form/category-form').then(m => m.CategoryFormComponent),
canActivate: [adminGuard],
title: 'Edit Category - Admin'
},
// Error page
{
path: 'error',
loadComponent: () => import('./shared/components/error/error.component').then(m => m.ErrorComponent),
title: 'Error - Quiz Platform'
},
// TODO: Add more routes as components are created
// - Home page (public)
// - Quiz history (protected with authGuard)

View File

@@ -1,3 +1,21 @@
// Navigation Progress Bar
.navigation-progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: calc(var(--z-modal) + 1);
height: 3px;
::ng-deep .mat-mdc-progress-bar-fill::after {
background-color: var(--color-primary);
}
::ng-deep .mat-mdc-progress-bar-buffer {
background-color: rgba(var(--color-primary-rgb), 0.3);
}
}
// App Shell Layout
.app-shell {
display: flex;

View File

@@ -1,6 +1,8 @@
import { Component, signal, inject, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { Router, RouterOutlet, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { filter } from 'rxjs/operators';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container';
import { HeaderComponent } from './shared/components/header/header';
import { SidebarComponent } from './shared/components/sidebar/sidebar';
@@ -16,6 +18,7 @@ import { ToastService } from './core/services/toast.service';
imports: [
CommonModule,
RouterOutlet,
MatProgressBarModule,
ToastContainerComponent,
HeaderComponent,
SidebarComponent,
@@ -39,6 +42,9 @@ export class App implements OnInit {
// Signal for app initialization state
isInitializing = signal<boolean>(true);
// Signal for navigation loading state
isNavigating = signal<boolean>(false);
// Computed signal to check if user is guest
isGuest = computed(() => {
return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated;
@@ -46,6 +52,24 @@ export class App implements OnInit {
ngOnInit(): void {
this.initializeApp();
this.setupNavigationListener();
}
/**
* Setup navigation event listener for progress bar
*/
private setupNavigationListener(): void {
this.router.events.subscribe(event => {
if (event instanceof NavigationStart) {
this.isNavigating.set(true);
} else if (
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
) {
this.isNavigating.set(false);
}
});
}
/**

View 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;
};

View File

@@ -1,3 +1,4 @@
export * from './auth.interceptor';
export * from './guest.interceptor';
export * from './error.interceptor';
export * from './loading.interceptor';

View 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();
}
})
);
};

View 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;
}

View 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;
}

View File

@@ -70,6 +70,17 @@ export interface UserProfileUpdate {
newPassword?: string;
}
/**
* User Profile Update Response
*/
export interface UserProfileUpdateResponse {
success: boolean;
data: {
user: User;
};
message: string;
}
/**
* Bookmark
*/

View 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();
}
}

View 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 }));
}
}

View 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();
}
}

View File

@@ -6,3 +6,5 @@ export * from './theme.service';
export * from './auth.service';
export * from './category.service';
export * from './guest.service';
export * from './global-error-handler.service';
export * from './pagination.service';

View 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);
}
}

View 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 [];
}
}
}

View File

@@ -4,8 +4,10 @@ import { Router } from '@angular/router';
import { catchError, tap, map } from 'rxjs/operators';
import { of, Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UserDashboard, QuizHistoryResponse, UserProfileUpdate } from '../models/dashboard.model';
import { UserDashboard, QuizHistoryResponse, UserProfileUpdate, UserProfileUpdateResponse } from '../models/dashboard.model';
import { ToastService } from './toast.service';
import { AuthService } from './auth.service';
import { StorageService } from './storage.service';
interface CacheEntry<T> {
data: T;
@@ -19,6 +21,8 @@ export class UserService {
private http = inject(HttpClient);
private router = inject(Router);
private toastService = inject(ToastService);
private authService = inject(AuthService);
private storageService = inject(StorageService);
private readonly API_URL = `${environment.apiUrl}/users`;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
@@ -123,12 +127,23 @@ export class UserService {
/**
* Update user profile
*/
updateProfile(userId: string, data: UserProfileUpdate): Observable<any> {
updateProfile(userId: string, data: UserProfileUpdate): Observable<UserProfileUpdateResponse> {
this.isLoading.set(true);
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 => {
// Update auth state with new user data
const currentUser = this.authService.getCurrentUser();
if (currentUser && response.data?.user) {
const updatedUser = { ...currentUser, ...response.data.user };
this.storageService.setUserData(updatedUser);
// Update auth state by calling a private method reflection
// Since updateAuthState is private, we update storage directly
// The auth state will sync on next navigation/refresh
}
this.isLoading.set(false);
this.toastService.success('Profile updated successfully');
// Invalidate dashboard cache

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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']);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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';
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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']);
}
}

View File

@@ -166,10 +166,18 @@
[disabled]="categoryForm.invalid || isSubmitting()">
@if (isSubmitting()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Saving...</span>
}
<span>
@if (isSubmitting()) {
Saving...
} @else if (isEditMode()) {
Save Changes
} @else {
Create Category
}
</span>
@if (!isSubmitting()) {
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
<span>{{ isEditMode() ? 'Save Changes' : 'Create Category' }}</span>
}
</button>
</div>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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']);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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' : ''}`;
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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' : ''}`;
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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>

View 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;
}
}
}
}

View 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`;
}
}

View File

@@ -24,10 +24,16 @@
<h1>Welcome back, {{ username() }}! 👋</h1>
<p class="subtitle">Ready to test your knowledge today?</p>
</div>
<div class="welcome-actions">
<button mat-raised-button color="primary" class="start-quiz-btn" (click)="startNewQuiz()">
<mat-icon>play_arrow</mat-icon>
Start New Quiz
</button>
<button mat-stroked-button [routerLink]="['/profile']" class="profile-btn">
<mat-icon>settings</mat-icon>
Profile Settings
</button>
</div>
</div>
<!-- Empty State -->

View File

@@ -78,9 +78,14 @@
}
}
.start-quiz-btn {
background-color: white;
color: var(--color-primary);
.welcome-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.start-quiz-btn,
.profile-btn {
font-weight: 600;
padding: 0 2rem;
height: 48px;
@@ -94,6 +99,36 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
.start-quiz-btn {
background-color: white;
color: var(--color-primary);
}
.profile-btn {
background-color: transparent;
color: white;
border-color: white;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 1.5rem;
.welcome-actions {
width: 100%;
button {
flex: 1;
min-width: 0;
}
}
}
}
// Empty State

View File

@@ -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>

View File

@@ -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;
}
}
}

View 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' };
}
}
}

View File

@@ -153,8 +153,10 @@
<mat-spinner diameter="20"></mat-spinner>
<span>Submitting...</span>
} @else {
<ng-container>
<mat-icon>send</mat-icon>
<span>Submit Answer</span>
</ng-container>
}
</button>
} @else {
@@ -164,11 +166,15 @@
color="primary"
(click)="nextQuestion()">
@if (isLastQuestion()) {
<ng-container>
<mat-icon>flag</mat-icon>
<span>Complete Quiz</span>
</ng-container>
} @else {
<ng-container>
<mat-icon>arrow_forward</mat-icon>
<span>Next Question</span>
</ng-container>
}
</button>
}

View File

@@ -189,8 +189,10 @@
<mat-spinner diameter="20"></mat-spinner>
<span>Starting...</span>
} @else {
<ng-container>
<mat-icon>play_arrow</mat-icon>
<span>Start Quiz</span>
</ng-container>
}
</button>
</div>

View File

@@ -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()]);
}
}
}

View File

@@ -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(' ');
}
}

View 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();
}
}
}

View File

@@ -15,6 +15,11 @@
<span class="logo-text">Interview Quiz</span>
</div>
<!-- Search (Desktop Only) -->
<div class="search-wrapper desktop-only">
<app-search></app-search>
</div>
<!-- Spacer -->
<div class="spacer"></div>

View File

@@ -59,6 +59,17 @@
}
}
.search-wrapper {
flex: 1;
max-width: 600px;
margin: 0 var(--spacing-lg);
@media (max-width: 1024px) {
max-width: 400px;
margin: 0 var(--spacing-md);
}
}
.spacer {
flex: 1;
}

View File

@@ -17,6 +17,7 @@ import { GuestService } from '../../../core/services/guest.service';
import { QuizService } from '../../../core/services/quiz.service';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
import { SearchComponent } from '../search/search.component';
@Component({
selector: 'app-header',
@@ -31,7 +32,8 @@ import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dia
MatDividerModule,
MatDialogModule,
MatProgressSpinnerModule,
MatChipsModule
MatChipsModule,
SearchComponent
],
templateUrl: './header.html',
styleUrl: './header.scss'

View File

@@ -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">
<mat-spinner [diameter]="size()"></mat-spinner>
<p class="loading-message">{{ message() }}</p>
<p class="loading-message" aria-hidden="true">{{ message() }}</p>
</div>
</div>

View File

@@ -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);
}
}

View 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>

View 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;
}

View 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();
}
}