first commit

This commit is contained in:
AD2025
2025-12-27 22:00:37 +02:00
commit 41e3d43129
179 changed files with 46444 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
<div class="admin-category-list-container">
<mat-card>
<mat-card-header>
<mat-card-title>
<div class="header-title">
<h1>Manage Categories</h1>
<button
mat-raised-button
color="primary"
(click)="createCategory()">
<mat-icon>add</mat-icon>
Create Category
</button>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="60"></mat-spinner>
<p>Loading categories...</p>
</div>
}
<!-- Error State -->
@if (error() && !isLoading()) {
<div class="error-container">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2>Failed to load categories</h2>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="retry()">
<mat-icon>refresh</mat-icon>
Try Again
</button>
</div>
}
<!-- Categories Table -->
@if (!isLoading() && !error()) {
@if (categories().length === 0) {
<div class="empty-container">
<mat-icon class="empty-icon">folder_open</mat-icon>
<h2>No Categories Yet</h2>
<p>Create your first category to get started.</p>
<button mat-raised-button color="primary" (click)="createCategory()">
<mat-icon>add</mat-icon>
Create Category
</button>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="categories()" class="categories-table">
<!-- Icon Column -->
<ng-container matColumnDef="icon">
<th mat-header-cell *matHeaderCellDef>Icon</th>
<td mat-cell *matCellDef="let category">
<div
class="category-icon-cell"
[style.background-color]="category.color || '#2196F3'">
<mat-icon>{{ category.icon || 'category' }}</mat-icon>
</div>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let category">
<div class="category-name">
<strong>{{ category.name }}</strong>
<span class="category-description">{{ category.description }}</span>
</div>
</td>
</ng-container>
<!-- Slug Column -->
<ng-container matColumnDef="slug">
<th mat-header-cell *matHeaderCellDef>Slug</th>
<td mat-cell *matCellDef="let category">
<code>{{ category.slug }}</code>
</td>
</ng-container>
<!-- Question Count Column -->
<ng-container matColumnDef="questionCount">
<th mat-header-cell *matHeaderCellDef>Questions</th>
<td mat-cell *matCellDef="let category">
<mat-chip>{{ category.questionCount || 0 }}</mat-chip>
</td>
</ng-container>
<!-- Guest Accessible Column -->
<ng-container matColumnDef="guestAccessible">
<th mat-header-cell *matHeaderCellDef>Access</th>
<td mat-cell *matCellDef="let category">
@if (category.guestAccessible) {
<mat-chip class="access-chip guest">
<mat-icon>public</mat-icon>
Guest
</mat-chip>
} @else {
<mat-chip class="access-chip auth">
<mat-icon>lock</mat-icon>
Auth
</mat-chip>
}
</td>
</ng-container>
<!-- Display Order Column -->
<ng-container matColumnDef="displayOrder">
<th mat-header-cell *matHeaderCellDef>Order</th>
<td mat-cell *matCellDef="let category">
{{ category.displayOrder ?? '-' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let category">
<div class="action-buttons">
<button
mat-icon-button
color="primary"
(click)="editCategory(category)"
matTooltip="Edit category"
aria-label="Edit category">
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="deleteCategory(category)"
matTooltip="Delete category"
aria-label="Delete category">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
}
}
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,236 @@
.admin-category-list-container {
max-width: 1400px;
margin: 24px auto;
padding: 0 16px;
mat-card {
mat-card-header {
margin-bottom: 24px;
.header-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
gap: 16px;
h1 {
margin: 0;
font-size: 28px;
font-weight: 500;
}
@media (max-width: 600px) {
h1 {
font-size: 24px;
}
}
}
}
mat-card-content {
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 20px;
p {
font-size: 16px;
color: rgba(0, 0, 0, 0.6);
}
}
// Error State
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
text-align: center;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #f44336;
}
h2 {
margin: 0;
font-size: 24px;
}
p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
}
// Empty State
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
text-align: center;
.empty-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: rgba(0, 0, 0, 0.3);
}
h2 {
margin: 0;
font-size: 24px;
}
p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
}
// Table Container
.table-container {
overflow-x: auto;
.categories-table {
width: 100%;
th {
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td, th {
padding: 16px 12px;
}
.category-icon-cell {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
mat-icon {
color: white;
font-size: 24px;
width: 24px;
height: 24px;
}
}
.category-name {
display: flex;
flex-direction: column;
gap: 4px;
strong {
font-size: 16px;
}
.category-description {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
code {
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
mat-chip {
&.access-chip {
display: inline-flex;
align-items: center;
gap: 4px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&.guest {
background-color: rgba(76, 175, 80, 0.1) !important;
color: #4CAF50 !important;
}
&.auth {
background-color: rgba(255, 152, 0, 0.1) !important;
color: #FF9800 !important;
}
}
}
.action-buttons {
display: flex;
gap: 4px;
}
// Responsive table
@media (max-width: 960px) {
// Hide less important columns on smaller screens
th:nth-child(3),
td:nth-child(3),
th:nth-child(6),
td:nth-child(6) {
display: none;
}
}
@media (max-width: 600px) {
th:nth-child(4),
td:nth-child(4) {
display: none;
}
}
}
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.admin-category-list-container {
.loading-container p,
.error-container p,
.empty-container p {
color: rgba(255, 255, 255, 0.7);
}
.categories-table {
.category-name .category-description {
color: rgba(255, 255, 255, 0.7);
}
code {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}

View File

@@ -0,0 +1,111 @@
import { Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CategoryService } from '../../../core/services/category.service';
import { Category } from '../../../core/models/category.model';
import { Subject, takeUntil } from 'rxjs';
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog';
@Component({
selector: 'app-admin-category-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatTableModule,
MatChipsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatTooltipModule
],
templateUrl: './admin-category-list.html',
styleUrls: ['./admin-category-list.scss']
})
export class AdminCategoryListComponent implements OnInit, OnDestroy {
private categoryService = inject(CategoryService);
private router = inject(Router);
private dialog = inject(MatDialog);
private destroy$ = new Subject<void>();
categories = this.categoryService.categories;
isLoading = this.categoryService.isLoading;
error = this.categoryService.error;
displayedColumns = ['icon', 'name', 'slug', 'questionCount', 'guestAccessible', 'displayOrder', 'actions'];
ngOnInit(): void {
this.loadCategories();
}
loadCategories(): void {
this.categoryService.getCategories(true)
.pipe(takeUntil(this.destroy$))
.subscribe();
}
createCategory(): void {
this.router.navigate(['/admin/categories/new']);
}
editCategory(category: Category): void {
this.router.navigate(['/admin/categories/edit', category.id]);
}
deleteCategory(category: Category): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '450px',
data: {
title: 'Delete Category',
message: `Are you sure you want to delete "${category.name}"?`,
warning: category.questionCount > 0
? `This category has ${category.questionCount} question(s). Deleting it may affect existing quizzes.`
: null,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmColor: 'warn'
}
});
dialogRef.afterClosed()
.pipe(takeUntil(this.destroy$))
.subscribe(confirmed => {
if (confirmed) {
this.performDelete(category);
}
});
}
private performDelete(category: Category): void {
this.categoryService.deleteCategory(category.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
// Category is automatically removed from state by the service
// Toast notification is also handled by the service
},
error: () => {
// Error toast is handled by the service
}
});
}
retry(): void {
this.loadCategories();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,424 @@
<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()!.users.inactiveLast7Days) {
<p class="stat-detail">
+{{ stats()!.users.inactiveLast7Days }} 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()!.quizzes) {
<p class="stat-detail">{{ stats()!.quizzes.averageScore }} Average score</p>
<p class="stat-detail">{{ stats()!.quizzes.averageScorePercentage }} Average score percentage</p>
<p class="stat-detail">{{ stats()!.quizzes.failedQuizzes }} Failed quizzes</p>
<p class="stat-detail">{{ stats()!.quizzes.passRate }} Pass rate</p>
<p class="stat-detail">{{ stats()!.quizzes.passedQuizzes }} Passed quizzes</p>
<p class="stat-detail">{{ stats()!.quizzes.totalSessions }} Total sessions</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.newUsers, 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.newUsers), 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.newUsers), 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.newUsers / 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.name,
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,394 @@
<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,474 @@
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 { 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', label: 'Multiple Choice' },
{ value: 'trueFalse', 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';
});
readonly showTrueFalse = computed(() => {
const type = this.selectedQuestionType();
return type === 'trueFalse';
});
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.subscribe(params => {
const id = params['id'];
if (id) {
// Defer signal updates to avoid ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
this.isEditMode.set(true);
this.questionId.set(id);
this.loadQuestion(id);
});
}
});
// Watch for question type changes
this.questionForm.get('questionType')?.valueChanges.subscribe((type: QuestionType) => {
this.onQuestionTypeChange(type);
});
}
/**
* Load existing question data
*/
private loadQuestion(id: string): void {
this.isLoadingQuestion.set(true);
this.adminService.getQuestion(id).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' && question.options) {
question.options.forEach((option: string | { text: string, id: 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', 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 | { text: string, id: 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') {
// Ensure at least 2 options
while (this.optionsArray.length < 2) {
this.addOption();
}
correctAnswerControl?.setValidators([Validators.required]);
} else if (type === 'trueFalse') {
// 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' && 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') {
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.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">Multiple Choice</mat-option>
<mat-option value="trueFalse">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') {
<mat-icon>radio_button_checked</mat-icon>
<span class="px-5"> MCQ</span>
} @else if (question.questionType === 'trueFalse') {
<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) }}
</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,327 @@
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,
category: filters.category !== 'all' ? filters.category : undefined,
difficulty: filters.difficulty !== 'all' ? filters.difficulty : undefined,
sortBy: filters.sortBy,
order: filters.sortOrder
};
// Remove undefined values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
this.adminService.getAllQuestions(params)
.pipe(finalize(() => this.isLoading.set(false)))
.subscribe({
next: (response) => {
this.questions.set(response.data);
this.totalQuestions.set(response.total);
this.currentPage.set(response.page);
},
error: (error) => {
this.error.set(error.message || 'Failed to load questions');
this.questions.set([]);
this.totalQuestions.set(0);
console.error('Load questions error:', error);
}
});
}
/**
* 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 from question
* The API returns a nested category object with the question
*/
getCategoryName(question: Question): string {
// First try to get from nested category object (API response)
if (question.category?.name) {
return question.category.name;
}
// Fallback: try to find by categoryId in loaded categories
if (question.categoryId) {
const category = this.categories().find(
c => c.id === question.categoryId || c.id === question.categoryId.toString()
);
if (category) {
return category.name;
}
}
// Last fallback: use categoryName property if available
return question.categoryName || '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()?.totalItems }} user{{ pagination()?.totalItems !== 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.itemsPerPage,
totalItems: pag.totalItems
});
});
// 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

@@ -0,0 +1,187 @@
<div class="category-form-container">
<mat-card>
<mat-card-header>
<mat-card-title>
<div class="header-title">
<button mat-icon-button (click)="cancel()" aria-label="Go back">
<mat-icon>arrow_back</mat-icon>
</button>
<h1>{{ pageTitle() }}</h1>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="categoryForm" (ngSubmit)="onSubmit()">
<!-- Name Field -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Category Name</mat-label>
<input
matInput
formControlName="name"
placeholder="e.g., JavaScript Fundamentals"
required>
<mat-icon matPrefix>label</mat-icon>
<mat-error>{{ getErrorMessage('name') }}</mat-error>
</mat-form-field>
<!-- Slug Field with Preview -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Slug (URL-friendly)</mat-label>
<input
matInput
formControlName="slug"
placeholder="e.g., javascript-fundamentals"
required>
<mat-icon matPrefix>link</mat-icon>
<mat-hint>Preview: /categories/{{ slugPreview() }}</mat-hint>
<mat-error>{{ getErrorMessage('slug') }}</mat-error>
</mat-form-field>
<!-- Description Field -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
matInput
formControlName="description"
rows="4"
placeholder="Brief description of the category..."
required>
</textarea>
<mat-icon matPrefix>description</mat-icon>
<mat-hint align="end">
{{ categoryForm.get('description')?.value?.length || 0 }} / 500
</mat-hint>
<mat-error>{{ getErrorMessage('description') }}</mat-error>
</mat-form-field>
<!-- Icon and Color Row -->
<div class="form-row">
<!-- Icon Selector -->
<mat-form-field appearance="outline" class="half-width">
<mat-label>Icon</mat-label>
<mat-select formControlName="icon" required>
@for (icon of iconOptions; track icon.value) {
<mat-option [value]="icon.value">
<mat-icon>{{ icon.value }}</mat-icon>
<span>{{ icon.label }}</span>
</mat-option>
}
</mat-select>
<mat-icon matPrefix>{{ categoryForm.get('icon')?.value }}</mat-icon>
<mat-error>{{ getErrorMessage('icon') }}</mat-error>
</mat-form-field>
<!-- Color Picker -->
<mat-form-field appearance="outline" class="half-width">
<mat-label>Color</mat-label>
<mat-select formControlName="color" required>
@for (color of colorOptions; track color.value) {
<mat-option [value]="color.value">
<span class="color-option">
<span
class="color-preview"
[style.background-color]="color.value">
</span>
{{ color.label }}
</span>
</mat-option>
}
</mat-select>
<span
matPrefix
class="color-preview"
[style.background-color]="categoryForm.get('color')?.value">
</span>
<mat-error>{{ getErrorMessage('color') }}</mat-error>
</mat-form-field>
</div>
<!-- Display Order -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Display Order</mat-label>
<input
matInput
type="number"
formControlName="displayOrder"
placeholder="0"
min="0">
<mat-icon matPrefix>sort</mat-icon>
<mat-hint>Lower numbers appear first in the category list</mat-hint>
<mat-error>{{ getErrorMessage('displayOrder') }}</mat-error>
</mat-form-field>
<!-- Guest Accessible Checkbox -->
<div class="checkbox-field">
<mat-checkbox formControlName="guestAccessible">
<strong>Guest Accessible</strong>
</mat-checkbox>
<p class="checkbox-hint">
Allow guest users to access this category without authentication
</p>
</div>
<!-- Preview Card -->
<div class="preview-section">
<h3>Preview</h3>
<div class="preview-card">
<div
class="preview-icon"
[style.background-color]="categoryForm.get('color')?.value">
<mat-icon>{{ categoryForm.get('icon')?.value }}</mat-icon>
</div>
<div class="preview-content">
<h4>{{ categoryForm.get('name')?.value || 'Category Name' }}</h4>
<p>{{ categoryForm.get('description')?.value || 'Category description will appear here...' }}</p>
@if (categoryForm.get('guestAccessible')?.value) {
<span class="preview-badge">
<mat-icon>public</mat-icon>
Guest Accessible
</span>
} @else {
<span class="preview-badge locked">
<mat-icon>lock</mat-icon>
Login Required
</span>
}
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button
mat-stroked-button
type="button"
(click)="cancel()"
[disabled]="isSubmitting()">
<mat-icon>close</mat-icon>
Cancel
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="categoryForm.invalid || isSubmitting()">
@if (isSubmitting()) {
<mat-spinner diameter="20"></mat-spinner>
}
<span>
@if (isSubmitting()) {
Saving...
} @else if (isEditMode()) {
Save Changes
} @else {
Create Category
}
</span>
@if (!isSubmitting()) {
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,243 @@
.category-form-container {
max-width: 800px;
margin: 24px auto;
padding: 0 16px;
mat-card {
mat-card-header {
margin-bottom: 24px;
.header-title {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
h1 {
margin: 0;
font-size: 28px;
font-weight: 500;
}
}
}
mat-card-content {
form {
display: flex;
flex-direction: column;
gap: 16px;
.full-width {
width: 100%;
}
.form-row {
display: flex;
gap: 16px;
@media (max-width: 600px) {
flex-direction: column;
}
.half-width {
flex: 1;
min-width: 0;
}
}
// Icon prefix styling
mat-form-field {
mat-icon[matPrefix] {
margin-right: 8px;
color: rgba(0, 0, 0, 0.54);
}
.color-preview {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.12);
margin-right: 8px;
vertical-align: middle;
}
}
// Color option styling
.color-option {
display: flex;
align-items: center;
gap: 8px;
}
// Checkbox field
.checkbox-field {
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
mat-checkbox {
display: block;
margin-bottom: 8px;
}
.checkbox-hint {
margin: 0;
padding-left: 32px;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
}
}
// Preview section
.preview-section {
margin-top: 24px;
padding: 20px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 8px;
h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
}
.preview-card {
display: flex;
gap: 16px;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@media (max-width: 480px) {
flex-direction: column;
align-items: center;
text-align: center;
}
.preview-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 8px;
flex-shrink: 0;
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: white;
}
}
.preview-content {
flex: 1;
h4 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 500;
}
p {
margin: 0 0 12px 0;
font-size: 14px;
line-height: 1.5;
color: rgba(0, 0, 0, 0.6);
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background-color: rgba(76, 175, 80, 0.1);
color: #4CAF50;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&.locked {
background-color: rgba(255, 152, 0, 0.1);
color: #FF9800;
}
}
}
}
}
// Form actions
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.12);
@media (max-width: 480px) {
flex-direction: column-reverse;
button {
width: 100%;
}
}
button {
display: flex;
align-items: center;
gap: 8px;
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}
}
}
}
}
// Select option with icon styling
::ng-deep .mat-mdc-option {
mat-icon {
vertical-align: middle;
margin-right: 8px;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.category-form-container {
.checkbox-field,
.preview-section {
background-color: rgba(255, 255, 255, 0.05);
}
.preview-section .preview-card {
background-color: rgba(255, 255, 255, 0.08);
}
mat-form-field mat-icon[matPrefix] {
color: rgba(255, 255, 255, 0.7);
}
.checkbox-field .checkbox-hint {
color: rgba(255, 255, 255, 0.7);
}
.preview-section .preview-card .preview-content p {
color: rgba(255, 255, 255, 0.7);
}
}
}

View File

@@ -0,0 +1,230 @@
import { Component, inject, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CategoryService } from '../../../core/services/category.service';
import { CategoryFormData } from '../../../core/models/category.model';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-category-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule,
MatCheckboxModule,
MatSelectModule,
MatProgressSpinnerModule
],
templateUrl: './category-form.html',
styleUrls: ['./category-form.scss']
})
export class CategoryFormComponent implements OnInit, OnDestroy {
private fb = inject(FormBuilder);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>();
categoryForm!: FormGroup;
isEditMode = signal<boolean>(false);
categoryId = signal<string | null>(null);
isSubmitting = signal<boolean>(false);
// Icon options for dropdown
iconOptions = [
{ value: 'code', label: 'Code' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'language', label: 'Language' },
{ value: 'web', label: 'Web' },
{ value: 'storage', label: 'Storage' },
{ value: 'cloud', label: 'Cloud' },
{ value: 'category', label: 'Category' },
{ value: 'folder', label: 'Folder' },
{ value: 'description', label: 'Description' },
{ value: 'psychology', label: 'Psychology' },
{ value: 'science', label: 'Science' },
{ value: 'school', label: 'School' }
];
// Color options
colorOptions = [
{ value: '#2196F3', label: 'Blue' },
{ value: '#4CAF50', label: 'Green' },
{ value: '#FF9800', label: 'Orange' },
{ value: '#F44336', label: 'Red' },
{ value: '#9C27B0', label: 'Purple' },
{ value: '#00BCD4', label: 'Cyan' },
{ value: '#FFEB3B', label: 'Yellow' },
{ value: '#607D8B', label: 'Blue Grey' }
];
// Computed slug preview
slugPreview = computed(() => {
const name = this.categoryForm?.get('name')?.value || '';
return this.generateSlug(name);
});
pageTitle = computed(() => {
return this.isEditMode() ? 'Edit Category' : 'Create New Category';
});
ngOnInit(): void {
this.initializeForm();
// Check if we're in edit mode
this.route.params
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
if (params['id']) {
this.isEditMode.set(true);
this.categoryId.set(params['id']);
this.loadCategoryData(params['id']);
}
});
// Auto-generate slug from name
this.categoryForm.get('name')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(name => {
if (!this.isEditMode() && !this.categoryForm.get('slug')?.touched) {
this.categoryForm.patchValue({ slug: this.generateSlug(name) }, { emitEvent: false });
}
});
}
private initializeForm(): void {
this.categoryForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
slug: ['', [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)]],
description: ['', [Validators.required, Validators.minLength(10), Validators.maxLength(500)]],
icon: ['category', Validators.required],
color: ['#2196F3', Validators.required],
displayOrder: [0, [Validators.min(0)]],
guestAccessible: [false]
});
}
private loadCategoryData(id: string): void {
this.categoryService.getCategoryById(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (category) => {
this.categoryForm.patchValue({
name: category.name,
slug: category.slug,
description: category.description,
icon: category.icon || 'category',
color: category.color || '#2196F3',
displayOrder: category.displayOrder || 0,
guestAccessible: category.guestAccessible
});
},
error: () => {
this.router.navigate(['/admin/categories']);
}
});
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
onSubmit(): void {
if (this.categoryForm.invalid || this.isSubmitting()) {
this.categoryForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
const formData: CategoryFormData = this.categoryForm.value;
const request$ = this.isEditMode()
? this.categoryService.updateCategory(this.categoryId()!, formData)
: this.categoryService.createCategory(formData);
request$
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.isSubmitting.set(false);
this.router.navigate(['/admin/categories']);
},
error: () => {
this.isSubmitting.set(false);
}
});
}
cancel(): void {
this.router.navigate(['/admin/categories']);
}
getErrorMessage(controlName: string): string {
const control = this.categoryForm.get(controlName);
if (!control || !control.touched) {
return '';
}
if (control.hasError('required')) {
return `${this.getFieldLabel(controlName)} is required`;
}
if (control.hasError('minlength')) {
const minLength = control.getError('minlength').requiredLength;
return `Must be at least ${minLength} characters`;
}
if (control.hasError('maxlength')) {
const maxLength = control.getError('maxlength').requiredLength;
return `Must not exceed ${maxLength} characters`;
}
if (control.hasError('pattern') && controlName === 'slug') {
return 'Slug must contain only lowercase letters, numbers, and hyphens';
}
if (control.hasError('min')) {
return 'Must be a positive number';
}
return '';
}
private getFieldLabel(controlName: string): string {
const labels: { [key: string]: string } = {
name: 'Category name',
slug: 'Slug',
description: 'Description',
icon: 'Icon',
color: 'Color',
displayOrder: 'Display order'
};
return labels[controlName] || controlName;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

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()!.recentActivity.last30Days) {
<p class="stat-detail">+{{ analytics()!.recentActivity.last30Days }} this 30 days</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()!.overview.conversionRate) {
<p class="stat-detail">{{ analytics()!.overview.conversionRate }} 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.overview.activeGuestSessions}\n`;
csvContent += `Active Guest Sessions,${analytics.overview.activeGuestSessions}\n`;
csvContent += `Conversion Rate,${analytics.overview.conversionRate}%\n`;
csvContent += `Average Quizzes per Guest,${analytics.quizActivity.avgQuizzesPerGuest}\n`;
csvContent += `Total Conversions,${analytics.overview.conversionRate}\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);
}
}