add changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user