add changes

This commit is contained in:
AD2025
2025-11-14 02:04:33 +02:00
parent 501de0103f
commit 6f23890407
48 changed files with 10759 additions and 213 deletions

View File

@@ -1,6 +1,6 @@
import { Component, inject, output, signal } from '@angular/core';
import { Component, inject, output, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { Router, RouterModule, NavigationEnd } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -10,10 +10,13 @@ import { MatDividerModule } from '@angular/material/divider';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips';
import { filter } from 'rxjs';
import { ThemeService } from '../../../core/services/theme.service';
import { AuthService } from '../../../core/services/auth.service';
import { GuestService } from '../../../core/services/guest.service';
import { QuizService } from '../../../core/services/quiz.service';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
@Component({
selector: 'app-header',
@@ -33,13 +36,16 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
templateUrl: './header.html',
styleUrl: './header.scss'
})
export class HeaderComponent {
export class HeaderComponent implements OnInit {
private themeService = inject(ThemeService);
private authService = inject(AuthService);
private guestService = inject(GuestService);
private quizService = inject(QuizService);
private router = inject(Router);
private dialog = inject(MatDialog);
private hasCheckedForIncompleteSession = false;
// Output event for mobile menu toggle
menuToggle = output<void>();
@@ -67,6 +73,68 @@ export class HeaderComponent {
get isGuest() {
return this.guestState().isGuest && !this.isAuthenticated;
}
ngOnInit(): void {
// Check for incomplete session on navigation
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
// Only check once and not when already on a quiz page
if (!this.hasCheckedForIncompleteSession && !this.router.url.includes('/quiz/')) {
this.checkForIncompleteSession();
this.hasCheckedForIncompleteSession = true;
}
});
// Initial check
if (!this.router.url.includes('/quiz/')) {
this.checkForIncompleteSession();
this.hasCheckedForIncompleteSession = true;
}
}
/**
* Check for incomplete quiz session and show resume dialog
*/
private checkForIncompleteSession(): void {
const sessionId = this.quizService.checkIncompleteSession();
if (sessionId) {
// Restore session from server to get details
this.quizService.restoreSession(sessionId).subscribe({
next: ({ session }) => {
if (session.status === 'in_progress') {
// Show resume dialog
this.showResumeDialog(session);
}
},
error: () => {
// Session no longer exists, ignore
console.log('Incomplete session check: Session not found or expired');
}
});
}
}
/**
* Show resume quiz dialog
*/
private showResumeDialog(session: any): void {
const dialogRef = this.dialog.open(ResumeQuizDialogComponent, {
width: '600px',
maxWidth: '95vw',
disableClose: false,
data: { session }
});
dialogRef.afterClosed().subscribe(result => {
if (result?.action === 'new') {
// User wants to start a new quiz, clear the old session
this.quizService.clearSession();
}
// If 'resume', the dialog already navigated to the quiz page
// If 'cancel', do nothing
});
}
/**
* Toggle between light and dark theme

View File

@@ -0,0 +1,107 @@
<div class="resume-quiz-dialog">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>history</mat-icon>
</div>
<h2 mat-dialog-title>Resume Quiz?</h2>
<button mat-icon-button class="close-btn" (click)="cancel()">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<div class="incomplete-session-info">
<p class="message">
You have an incomplete quiz session. Would you like to continue where you left off?
</p>
<div class="session-details">
<div class="detail-row">
<mat-icon>quiz</mat-icon>
<span class="label">Progress:</span>
<span class="value">
Question {{ session().currentQuestionIndex + 1 }} of {{ session().totalQuestions }}
</span>
</div>
<div class="progress-container">
<mat-progress-bar
mode="determinate"
[value]="progress()"
[color]="progress() > 66 ? 'primary' : progress() > 33 ? 'accent' : 'warn'"
></mat-progress-bar>
<span class="progress-text">{{ progress() }}% Complete</span>
</div>
<div class="detail-row">
<mat-icon>category</mat-icon>
<span class="label">Category:</span>
<span class="value">{{ session().categoryName || 'Quiz' }}</span>
</div>
<div class="detail-row">
<mat-icon>tune</mat-icon>
<span class="label">Difficulty:</span>
<span class="value">{{ formatDifficulty(session().difficulty) }}</span>
</div>
<div class="detail-row">
<mat-icon>timer</mat-icon>
<span class="label">Quiz Type:</span>
<span class="value">{{ getQuizTypeText(session().quizType) }}</span>
</div>
<div class="detail-row">
<mat-icon>schedule</mat-icon>
<span class="label">Started:</span>
<span class="value">{{ formatTimeElapsed() }}</span>
</div>
<div class="stats-row">
<div class="stat-item success">
<mat-icon>check_circle</mat-icon>
<span class="stat-value">{{ session().correctAnswers }}</span>
<span class="stat-label">Correct</span>
</div>
<div class="stat-item error">
<mat-icon>cancel</mat-icon>
<span class="stat-value">{{ session().incorrectAnswers }}</span>
<span class="stat-label">Incorrect</span>
</div>
<div class="stat-item">
<mat-icon>help_outline</mat-icon>
<span class="stat-value">{{ questionsRemaining() }}</span>
<span class="stat-label">Remaining</span>
</div>
</div>
@if (session().score > 0) {
<div class="current-score">
<mat-icon>emoji_events</mat-icon>
<span>Current Score: <strong>{{ session().score }} points</strong></span>
</div>
}
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
(click)="startNewQuiz()"
class="action-btn secondary"
>
<mat-icon>add</mat-icon>
Start New Quiz
</button>
<button
mat-raised-button
color="primary"
(click)="resumeQuiz()"
class="action-btn primary"
>
<mat-icon>play_arrow</mat-icon>
Continue Quiz
</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,259 @@
.resume-quiz-dialog {
display: flex;
flex-direction: column;
min-width: 500px;
max-width: 600px;
@media (max-width: 768px) {
min-width: unset;
max-width: unset;
width: 100%;
}
}
.dialog-header {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid var(--border-color);
.header-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
animation: pulse 2s infinite;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(var(--primary-rgb), 0.7);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(var(--primary-rgb), 0);
}
}
mat-dialog-content {
padding: 1.5rem;
overflow-y: auto;
max-height: 70vh;
}
.incomplete-session-info {
.message {
font-size: 1rem;
color: var(--text-secondary);
text-align: center;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.session-details {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
.detail-row {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9375rem;
mat-icon {
color: var(--primary-color);
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.label {
color: var(--text-secondary);
font-weight: 500;
min-width: 80px;
}
.value {
color: var(--text-primary);
font-weight: 600;
}
}
.progress-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.5rem 0;
mat-progress-bar {
height: 8px;
border-radius: 4px;
}
.progress-text {
font-size: 0.875rem;
color: var(--text-secondary);
text-align: right;
font-weight: 600;
}
}
.stats-row {
display: flex;
justify-content: space-around;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--text-secondary);
}
&.success mat-icon {
color: #4caf50;
}
&.error mat-icon {
color: #f44336;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.current-score {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--accent-rgb), 0.1));
border-radius: 6px;
margin-top: 0.5rem;
mat-icon {
color: #ffc107;
font-size: 24px;
width: 24px;
height: 24px;
}
span {
font-size: 1rem;
color: var(--text-primary);
strong {
color: var(--primary-color);
}
}
}
}
}
mat-dialog-actions {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
gap: 0.75rem;
display: flex;
justify-content: flex-end;
@media (max-width: 768px) {
flex-direction: column-reverse;
button {
width: 100%;
}
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&.primary {
min-width: 160px;
}
&.secondary {
color: var(--text-secondary);
&:hover {
background-color: var(--bg-hover);
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.session-details {
background-color: rgba(255, 255, 255, 0.05);
}
.current-score {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.2), rgba(var(--accent-rgb), 0.2));
}
}

View File

@@ -0,0 +1,116 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { QuizSession } from '../../../core/models/quiz.model';
export interface ResumeQuizDialogData {
session: QuizSession;
}
@Component({
selector: 'app-resume-quiz-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressBarModule
],
templateUrl: './resume-quiz-dialog.html',
styleUrls: ['./resume-quiz-dialog.scss']
})
export class ResumeQuizDialogComponent {
private readonly dialogRef = inject(MatDialogRef<ResumeQuizDialogComponent>);
readonly data = inject<ResumeQuizDialogData>(MAT_DIALOG_DATA);
private readonly router = inject(Router);
readonly session = signal<QuizSession>(this.data.session);
readonly progress = computed(() => {
const sess = this.session();
if (!sess) return 0;
return Math.round((sess.currentQuestionIndex / sess.totalQuestions) * 100);
});
readonly questionsRemaining = computed(() => {
const sess = this.session();
if (!sess) return 0;
return sess.totalQuestions - sess.currentQuestionIndex;
});
/**
* Resume the quiz
*/
resumeQuiz(): void {
const sess = this.session();
if (sess) {
this.dialogRef.close({ action: 'resume' });
this.router.navigate(['/quiz', sess.id]);
}
}
/**
* Start a new quiz
*/
startNewQuiz(): void {
this.dialogRef.close({ action: 'new' });
this.router.navigate(['/quiz/setup']);
}
/**
* Close dialog
*/
cancel(): void {
this.dialogRef.close({ action: 'cancel' });
}
/**
* Format difficulty
*/
formatDifficulty(difficulty: string): string {
return difficulty.charAt(0).toUpperCase() + difficulty.slice(1);
}
/**
* Get quiz type display text
*/
getQuizTypeText(type: string): string {
switch (type) {
case 'practice':
return 'Practice';
case 'timed':
return 'Timed';
case 'exam':
return 'Exam';
default:
return type;
}
}
/**
* Format time elapsed
*/
formatTimeElapsed(): string {
const sess = this.session();
if (!sess?.startedAt) return 'Just now';
const startTime = new Date(sess.startedAt).getTime();
const now = Date.now();
const diff = now - startTime;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days > 1 ? 's' : ''} ago`;
}
}

View File

@@ -109,6 +109,12 @@ export class SidebarComponent {
route: '/admin',
requiresAdmin: true
},
{
label: 'Manage Categories',
icon: 'category',
route: '/admin/categories',
requiresAdmin: true
},
{
label: 'User Management',
icon: 'people',