add changes

This commit is contained in:
AD2025
2025-12-19 21:18:47 +02:00
parent b2c564225e
commit 665919c1e2
20 changed files with 841 additions and 879 deletions

View File

@@ -30,6 +30,8 @@ const authenticateUserOrGuest = async (req, res, next) => {
// Try to verify guest token // Try to verify guest token
const guestToken = req.headers['x-guest-token']; const guestToken = req.headers['x-guest-token'];
console.log(guestToken);
if (guestToken) { if (guestToken) {
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {

View File

@@ -88,7 +88,7 @@ export class App implements OnInit {
this.authService.verifyToken().subscribe({ this.authService.verifyToken().subscribe({
next: (response) => { next: (response) => {
this.isInitializing.set(false); this.isInitializing.set(false);
if (!response.valid) { if (!response.success) {
this.toastService.warning('Session expired. Please login again.'); this.toastService.warning('Session expired. Please login again.');
this.router.navigate(['/login']); this.router.navigate(['/login']);
} }

View File

@@ -61,7 +61,7 @@ export interface QuestionPreview {
/** /**
* Question Types * Question Types
*/ */
export type QuestionType = 'multiple_choice' | 'true_false' | 'written'; export type QuestionType = 'multiple' | 'trueFalse' | 'written';
/** /**
* Difficulty Levels * Difficulty Levels

View File

@@ -1,5 +1,5 @@
import { User } from './user.model'; import { User } from './user.model';
import { QuizSession } from './quiz.model'; import { QuizSession, QuizSessionHistory } from './quiz.model';
/** /**
* User Dashboard Response * User Dashboard Response
@@ -33,8 +33,20 @@ export interface CategoryPerformance {
*/ */
export interface QuizHistoryResponse { export interface QuizHistoryResponse {
success: boolean; success: boolean;
sessions: QuizSession[]; data: {
sessions: QuizSessionHistory[];
pagination: PaginationInfo; pagination: PaginationInfo;
filters: {
"category": null,
"status": null,
"startDate": null,
"endDate": null
}
"sorting": {
"sortBy": string
"sortOrder": string
}
};
} }
/** /**

View File

@@ -19,7 +19,7 @@ export interface Question {
color?: string; color?: string;
guestAccessible?: boolean; guestAccessible?: boolean;
}; };
options?: string[]; // For multiple choice options?: string[] | { id: string; text: string }[]; // For multiple choice
correctAnswer: string | string[]; correctAnswer: string | string[];
explanation: string; explanation: string;
points: number; points: number;

View File

@@ -1,5 +1,42 @@
import { Category } from './category.model';
import { Question } from './question.model'; import { Question } from './question.model';
export interface QuizSessionHistory {
"time": {
"spent": number,
"limit": number | null,
"percentage": number
},
"createdAt": "2025-12-19T18:49:58.000Z"
id: string;
category?: {
id: string;
name: string;
slug: string;
icon: string;
color: string;
};
quizType: QuizType;
difficulty: string;
questions: {
answered: number,
total: number,
correct: number,
accuracy: number
};
score: {
earned: number
total: number
percentage: number
};
status: QuizStatus;
startedAt: string;
completedAt?: string;
isPassed?: boolean;
}
/** /**
* Quiz Session Interface * Quiz Session Interface
* Represents an active or completed quiz session * Represents an active or completed quiz session
@@ -40,6 +77,16 @@ export type QuizStatus = 'in_progress' | 'completed' | 'abandoned';
* Quiz Start Request * Quiz Start Request
*/ */
export interface QuizStartRequest { export interface QuizStartRequest {
success: true;
data: {
categoryId: string;
questionCount: number;
difficulty?: string; // 'easy', 'medium', 'hard', 'mixed'
quizType?: QuizType;
};
}
export interface QuizStartFormRequest {
categoryId: string; categoryId: string;
questionCount: number; questionCount: number;
difficulty?: string; // 'easy', 'medium', 'hard', 'mixed' difficulty?: string; // 'easy', 'medium', 'hard', 'mixed'
@@ -51,10 +98,12 @@ export interface QuizStartRequest {
*/ */
export interface QuizStartResponse { export interface QuizStartResponse {
success: boolean; success: boolean;
data: {
sessionId: string; sessionId: string;
questions: Question[]; questions: Question[];
totalQuestions: number; totalQuestions: number;
message?: string; message?: string;
};
} }
/** /**

View File

@@ -162,7 +162,7 @@ export class AuthService {
/** /**
* Verify JWT token validity * Verify JWT token validity
*/ */
verifyToken(): Observable<{ valid: boolean; user?: User }> { verifyToken(): Observable<{ success: boolean; data: { user?: User }, message: string }> {
const token = this.storageService.getToken(); const token = this.storageService.getToken();
if (!token) { if (!token) {
@@ -176,12 +176,12 @@ export class AuthService {
this.setLoading(true); this.setLoading(true);
return this.http.get<{ valid: boolean; user?: User }>(`${this.API_URL}/verify`).pipe( return this.http.get<{ success: boolean; data: { user?: User }, message: string }>(`${this.API_URL}/verify`).pipe(
tap((response) => { tap((response) => {
if (response.valid && response.user) { if (response.success && response.data.user) {
// Update user data // Update user data
this.storageService.setUserData(response.user); this.storageService.setUserData(response.data.user);
this.updateAuthState(response.user, null); this.updateAuthState(response.data.user, null);
} else { } else {
// Token invalid, clear auth // Token invalid, clear auth
this.clearAuth(); this.clearAuth();

View File

@@ -38,21 +38,21 @@ export class GuestService {
* Start a new guest session * Start a new guest session
* Generates device ID and creates session on backend * Generates device ID and creates session on backend
*/ */
startSession(): Observable<GuestSession> { startSession(): Observable<{ success: boolean, message: string, data: GuestSession }> {
this.setLoading(true); this.setLoading(true);
const deviceId = this.getOrCreateDeviceId(); const deviceId = this.getOrCreateDeviceId();
return this.http.post<GuestSession>(`${this.API_URL}/start-session`, { deviceId }).pipe( return this.http.post<{ success: boolean, message: string, data: GuestSession }>(`${this.API_URL}/start-session`, { deviceId }).pipe(
tap((session: GuestSession) => { tap((session: { success: boolean, message: string, data: GuestSession }) => {
// Store guest session data // Store guest session data
this.storageService.setItem(this.GUEST_TOKEN_KEY, session.sessionToken); this.storageService.setItem(this.GUEST_ID_KEY, session.data.guestId);
this.storageService.setItem(this.GUEST_ID_KEY, session.guestId); this.storageService.setGuestToken(session.data.sessionToken);
// Update guest state // Update guest state
this.guestStateSignal.update(state => ({ this.guestStateSignal.update(state => ({
...state, ...state,
session, session: session.data,
isGuest: true, isGuest: true,
isLoading: false, isLoading: false,
error: null error: null

View File

@@ -9,7 +9,8 @@ import {
QuizStartResponse, QuizStartResponse,
QuizAnswerSubmission, QuizAnswerSubmission,
QuizAnswerResponse, QuizAnswerResponse,
QuizResults QuizResults,
QuizStartFormRequest
} from '../models/quiz.model'; } from '../models/quiz.model';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
@@ -62,7 +63,7 @@ export class QuizService {
/** /**
* Start a new quiz session * Start a new quiz session
*/ */
startQuiz(request: QuizStartRequest): Observable<QuizStartResponse> { startQuiz(request: QuizStartFormRequest): Observable<QuizStartResponse> {
// Validate category accessibility // Validate category accessibility
if (!this.canAccessCategory(request.categoryId)) { if (!this.canAccessCategory(request.categoryId)) {
this.toastService.error('You do not have access to this category'); this.toastService.error('You do not have access to this category');
@@ -87,13 +88,13 @@ export class QuizService {
if (response.success) { if (response.success) {
// Store session data // Store session data
const session: QuizSession = { const session: QuizSession = {
id: response.sessionId, id: response.data.sessionId,
userId: this.storageService.getUserData()?.id, userId: this.storageService.getUserData()?.id,
guestSessionId: this.guestService.guestState().session?.guestId, guestSessionId: this.guestService.guestState().session?.guestId,
categoryId: request.categoryId, categoryId: request.categoryId,
quizType: request.quizType || 'practice', quizType: request.quizType || 'practice',
difficulty: request.difficulty || 'mixed', difficulty: request.difficulty || 'mixed',
totalQuestions: response.totalQuestions, totalQuestions: response.data.totalQuestions,
currentQuestionIndex: 0, currentQuestionIndex: 0,
score: 0, score: 0,
correctAnswers: 0, correctAnswers: 0,
@@ -106,12 +107,12 @@ export class QuizService {
this._activeSession.set(session); this._activeSession.set(session);
// Store questions from response // Store questions from response
if (response.questions) { if (response.data.questions) {
this._questions.set(response.questions); this._questions.set(response.data.questions);
} }
// Store session ID for restoration // Store session ID for restoration
this.storeSessionId(response.sessionId); this.storeSessionId(response.data.sessionId);
this.toastService.success('Quiz started successfully!'); this.toastService.success('Quiz started successfully!');
} }

View File

@@ -14,13 +14,13 @@ export class StorageService {
private readonly THEME_KEY = 'app_theme'; private readonly THEME_KEY = 'app_theme';
private readonly REMEMBER_ME_KEY = 'remember_me'; private readonly REMEMBER_ME_KEY = 'remember_me';
constructor() {} constructor() { }
/** /**
* Get item from storage (checks localStorage first, then sessionStorage) * Get item from storage (checks localStorage first, then sessionStorage)
*/ */
getItem(key: string): string | null { getItem(key: string): string | null {
return localStorage.getItem(key) || sessionStorage.getItem(key); return localStorage.getItem(key);
} }
/** /**
@@ -28,11 +28,7 @@ export class StorageService {
* Uses localStorage if rememberMe is true, otherwise sessionStorage * Uses localStorage if rememberMe is true, otherwise sessionStorage
*/ */
setItem(key: string, value: string, persistent: boolean = true): void { setItem(key: string, value: string, persistent: boolean = true): void {
if (persistent) {
localStorage.setItem(key, value); localStorage.setItem(key, value);
} else {
sessionStorage.setItem(key, value);
}
} }
// Auth Token Methods // Auth Token Methods
@@ -55,7 +51,7 @@ export class StorageService {
} }
setGuestToken(token: string): void { setGuestToken(token: string): void {
this.setItem(this.GUEST_TOKEN_KEY, token, true); this.setItem(this.GUEST_TOKEN_KEY, token);
} }
clearGuestToken(): void { clearGuestToken(): void {
@@ -118,6 +114,5 @@ export class StorageService {
// Remove a specific item from storage // Remove a specific item from storage
removeItem(key: string): void { removeItem(key: string): void {
localStorage.removeItem(key); localStorage.removeItem(key);
sessionStorage.removeItem(key);
} }
} }

View File

@@ -46,11 +46,7 @@
<!-- Question Text --> <!-- Question Text -->
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Question Text</mat-label> <mat-label>Question Text</mat-label>
<textarea <textarea matInput formControlName="questionText" placeholder="Enter your question here..." rows="4"
matInput
formControlName="questionText"
placeholder="Enter your question here..."
rows="4"
required> required>
</textarea> </textarea>
<mat-hint>Minimum 10 characters</mat-hint> <mat-hint>Minimum 10 characters</mat-hint>
@@ -112,14 +108,7 @@
<mat-form-field appearance="outline" class="half-width"> <mat-form-field appearance="outline" class="half-width">
<mat-label>Points</mat-label> <mat-label>Points</mat-label>
<input <input matInput type="number" formControlName="points" min="1" max="100" placeholder="10" required>
matInput
type="number"
formControlName="points"
min="1"
max="100"
placeholder="10"
required>
<mat-hint>Between 1 and 100</mat-hint> <mat-hint>Between 1 and 100</mat-hint>
@if (getErrorMessage('points')) { @if (getErrorMessage('points')) {
<mat-error>{{ getErrorMessage('points') }}</mat-error> <mat-error>{{ getErrorMessage('points') }}</mat-error>
@@ -142,18 +131,10 @@
<div [formGroupName]="$index" class="option-row"> <div [formGroupName]="$index" class="option-row">
<span class="option-label">Option {{ $index + 1 }}</span> <span class="option-label">Option {{ $index + 1 }}</span>
<mat-form-field appearance="outline" class="option-input"> <mat-form-field appearance="outline" class="option-input">
<input <input matInput formControlName="text" [placeholder]="'Enter option ' + ($index + 1)" required>
matInput
formControlName="text"
[placeholder]="'Enter option ' + ($index + 1)"
required>
</mat-form-field> </mat-form-field>
@if (optionsArray.length > 2) { @if (optionsArray.length > 2) {
<button <button mat-icon-button type="button" color="warn" (click)="removeOption($index)"
mat-icon-button
type="button"
color="warn"
(click)="removeOption($index)"
matTooltip="Remove option"> matTooltip="Remove option">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
</button> </button>
@@ -162,11 +143,7 @@
} }
</div> </div>
@if (optionsArray.length < 10) { @if (optionsArray.length < 10) { <button mat-stroked-button type="button" (click)="addOption()"
<button
mat-stroked-button
type="button"
(click)="addOption()"
class="add-option-btn"> class="add-option-btn">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
Add Option Add Option
@@ -221,11 +198,7 @@
</h3> </h3>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Expected Answer</mat-label> <mat-label>Expected Answer</mat-label>
<textarea <textarea matInput formControlName="correctAnswer" placeholder="Enter a sample correct answer..." rows="3"
matInput
formControlName="correctAnswer"
placeholder="Enter a sample correct answer..."
rows="3"
required> required>
</textarea> </textarea>
<mat-hint>This is a reference answer for grading</mat-hint> <mat-hint>This is a reference answer for grading</mat-hint>
@@ -241,12 +214,8 @@
<!-- Explanation --> <!-- Explanation -->
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Explanation</mat-label> <mat-label>Explanation</mat-label>
<textarea <textarea matInput formControlName="explanation" placeholder="Explain why this is the correct answer..."
matInput rows="4" required>
formControlName="explanation"
placeholder="Explain why this is the correct answer..."
rows="4"
required>
</textarea> </textarea>
<mat-hint>Minimum 10 characters</mat-hint> <mat-hint>Minimum 10 characters</mat-hint>
@if (getErrorMessage('explanation')) { @if (getErrorMessage('explanation')) {
@@ -272,11 +241,8 @@
</mat-chip-row> </mat-chip-row>
} }
</mat-chip-grid> </mat-chip-grid>
<input <input placeholder="Type tag and press Enter..." [matChipInputFor]="chipGrid"
placeholder="Type tag and press Enter..." [matChipInputSeparatorKeyCodes]="separatorKeysCodes" (matChipInputTokenEnd)="addTag($event)">
[matChipInputFor]="chipGrid"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addTag($event)">
<mat-hint>Press Enter or comma to add tags</mat-hint> <mat-hint>Press Enter or comma to add tags</mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
@@ -293,18 +259,12 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="form-actions"> <div class="form-actions">
<button <button mat-button type="button" (click)="onCancel()">
mat-button
type="button"
(click)="onCancel()">
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
Cancel Cancel
</button> </button>
<button <button mat-raised-button color="primary" type="submit"
mat-raised-button
color="primary"
type="submit"
[disabled]="!isFormValid() || isSubmitting() || isLoadingQuestion()"> [disabled]="!isFormValid() || isSubmitting() || isLoadingQuestion()">
@if (isSubmitting()) { @if (isSubmitting()) {
<ng-container> <ng-container>
@@ -347,7 +307,8 @@
<span class="preview-badge type-badge"> <span class="preview-badge type-badge">
{{ questionForm.get('questionType')?.value | titlecase }} {{ questionForm.get('questionType')?.value | titlecase }}
</span> </span>
<span class="preview-badge difficulty-badge" [class]="'difficulty-' + questionForm.get('difficulty')?.value"> <span class="preview-badge difficulty-badge"
[class]="'difficulty-' + questionForm.get('difficulty')?.value">
{{ questionForm.get('difficulty')?.value | titlecase }} {{ questionForm.get('difficulty')?.value | titlecase }}
</span> </span>
<span class="preview-badge points-badge"> <span class="preview-badge points-badge">
@@ -362,7 +323,8 @@
<div class="preview-options"> <div class="preview-options">
@for (optionText of getOptionTexts(); track $index) { @for (optionText of getOptionTexts(); track $index) {
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === optionText"> <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> <mat-icon>{{ questionForm.get('correctAnswer')?.value === optionText ? 'check_circle' :
'radio_button_unchecked' }}</mat-icon>
<span>{{ optionText }}</span> <span>{{ optionText }}</span>
</div> </div>
} }
@@ -376,11 +338,13 @@
<div class="preview-label">Options:</div> <div class="preview-label">Options:</div>
<div class="preview-options"> <div class="preview-options">
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'true'"> <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> <mat-icon>{{ questionForm.get('correctAnswer')?.value === 'true' ? 'check_circle' :
'radio_button_unchecked' }}</mat-icon>
<span>True</span> <span>True</span>
</div> </div>
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'false'"> <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> <mat-icon>{{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' :
'radio_button_unchecked' }}</mat-icon>
<span>False</span> <span>False</span>
</div> </div>
</div> </div>

View File

@@ -102,12 +102,12 @@ export class AdminQuestionFormComponent implements OnInit {
readonly showOptions = computed(() => { readonly showOptions = computed(() => {
const type = this.selectedQuestionType(); const type = this.selectedQuestionType();
return type === 'multiple_choice'; return type === 'multiple';
}); });
readonly showTrueFalse = computed(() => { readonly showTrueFalse = computed(() => {
const type = this.selectedQuestionType(); const type = this.selectedQuestionType();
return type === 'true_false'; return type === 'trueFalse';
}); });
readonly isFormValid = computed(() => { readonly isFormValid = computed(() => {
@@ -182,8 +182,8 @@ export class AdminQuestionFormComponent implements OnInit {
}); });
// Populate options for multiple choice // Populate options for multiple choice
if (question.questionType === 'multiple_choice' && question.options) { if (question.questionType === 'multiple' && question.options) {
question.options.forEach((option: string) => { question.options.forEach((option: string | { text: string, id: string }) => {
this.optionsArray.push(this.createOption(option)); this.optionsArray.push(this.createOption(option));
}); });
} }
@@ -222,7 +222,7 @@ export class AdminQuestionFormComponent implements OnInit {
/** /**
* Create option form control * Create option form control
*/ */
private createOption(value: string = ''): FormGroup { private createOption(value: string | { text: string, id: string } = ''): FormGroup {
return this.fb.group({ return this.fb.group({
text: [value, Validators.required] text: [value, Validators.required]
}); });
@@ -248,13 +248,13 @@ export class AdminQuestionFormComponent implements OnInit {
private onQuestionTypeChange(type: QuestionType): void { private onQuestionTypeChange(type: QuestionType): void {
const correctAnswerControl = this.questionForm.get('correctAnswer'); const correctAnswerControl = this.questionForm.get('correctAnswer');
if (type === 'multiple_choice') { if (type === 'multiple') {
// Ensure at least 2 options // Ensure at least 2 options
while (this.optionsArray.length < 2) { while (this.optionsArray.length < 2) {
this.addOption(); this.addOption();
} }
correctAnswerControl?.setValidators([Validators.required]); correctAnswerControl?.setValidators([Validators.required]);
} else if (type === 'true_false') { } else if (type === 'trueFalse') {
// Clear options for True/False // Clear options for True/False
this.optionsArray.clear(); this.optionsArray.clear();
correctAnswerControl?.setValidators([Validators.required]); correctAnswerControl?.setValidators([Validators.required]);

View File

@@ -13,6 +13,7 @@ import { MatDividerModule } from '@angular/material/divider';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { GuestService } from '../../../core/services/guest.service'; import { GuestService } from '../../../core/services/guest.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { StorageService } from '../../../core/services';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -36,6 +37,7 @@ export class LoginComponent implements OnDestroy {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private authService = inject(AuthService); private authService = inject(AuthService);
private guestService = inject(GuestService); private guestService = inject(GuestService);
private storageService = inject(StorageService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -148,7 +150,7 @@ export class LoginComponent implements OnDestroy {
this.guestService.startSession() this.guestService.startSession()
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: () => { next: (res: {}) => {
this.isStartingGuestSession.set(false); this.isStartingGuestSession.set(false);
this.router.navigate(['/guest-welcome']); this.router.navigate(['/guest-welcome']);
}, },

View File

@@ -89,7 +89,7 @@
<ng-container matColumnDef="category"> <ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th> <th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let session"> <td mat-cell *matCellDef="let session">
{{ session.categoryName || 'Unknown' }} {{ session.category.name || 'Unknown' }}
</td> </td>
</ng-container> </ng-container>
@@ -98,8 +98,8 @@
<th mat-header-cell *matHeaderCellDef>Score</th> <th mat-header-cell *matHeaderCellDef>Score</th>
<td mat-cell *matCellDef="let session"> <td mat-cell *matCellDef="let session">
<span class="score-badge" [ngClass]="getScoreColor(session.score, session.totalQuestions)"> <span class="score-badge" [ngClass]="getScoreColor(session.score, session.totalQuestions)">
{{ session.score }}/{{ session.totalQuestions }} {{ session.score.earned }}/{{ session.questions.total }}
<span class="percentage">({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%)</span> <span class="percentage">({{ ((session.score.earned / session.questions.total) * 100).toFixed(0) }}%)</span>
</span> </span>
</td> </td>
</ng-container> </ng-container>
@@ -108,7 +108,7 @@
<ng-container matColumnDef="time"> <ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time Spent</th> <th mat-header-cell *matHeaderCellDef>Time Spent</th>
<td mat-cell *matCellDef="let session"> <td mat-cell *matCellDef="let session">
{{ formatDuration(session.timeSpent) }} {{ formatDuration(session.time.spent) }}
</td> </td>
</ng-container> </ng-container>
@@ -128,20 +128,12 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th> <th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let session"> <td mat-cell *matCellDef="let session">
<button <button mat-icon-button (click)="viewResults(session.id)" matTooltip="View Results"
mat-icon-button *ngIf="session.status === 'completed'">
(click)="viewResults(session.id)"
matTooltip="View Results"
*ngIf="session.status === 'completed'"
>
<mat-icon>visibility</mat-icon> <mat-icon>visibility</mat-icon>
</button> </button>
<button <button mat-icon-button (click)="reviewQuiz(session.id)" matTooltip="Review Quiz"
mat-icon-button *ngIf="session.status === 'completed'">
(click)="reviewQuiz(session.id)"
matTooltip="Review Quiz"
*ngIf="session.status === 'completed'"
>
<mat-icon>rate_review</mat-icon> <mat-icon>rate_review</mat-icon>
</button> </button>
</td> </td>
@@ -159,7 +151,7 @@
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
<mat-icon>quiz</mat-icon> <mat-icon>quiz</mat-icon>
<span>{{ session.categoryName || 'Unknown' }}</span> <span>{{ session.category?.name || 'Unknown' }}</span>
</div> </div>
<mat-chip [ngClass]="getStatusClass(session.status)"> <mat-chip [ngClass]="getStatusClass(session.status)">
{{ session.status === 'in_progress' ? 'In Progress' : {{ session.status === 'in_progress' ? 'In Progress' :
@@ -175,13 +167,13 @@
</div> </div>
<div class="detail-row"> <div class="detail-row">
<mat-icon>timer</mat-icon> <mat-icon>timer</mat-icon>
<span>{{ formatDuration(session.timeSpent) }}</span> <span>{{ formatDuration(session.time.spent) }}</span>
</div> </div>
<div class="detail-row score-row"> <div class="detail-row score-row">
<span class="score-label">Score:</span> <span class="score-label">Score:</span>
<span class="score-value" [ngClass]="getScoreColor(session.score, session.totalQuestions)"> <span class="score-value" [ngClass]="getScoreColor(session.score.earned, session.questions.total)">
{{ session.score }}/{{ session.totalQuestions }} {{ session.score.earned }}/{{ session.questions.total }}
({{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%) ({{ ((session.score.earned / session.questions.total) * 100).toFixed(0) }}%)
</span> </span>
</div> </div>
</div> </div>
@@ -201,14 +193,7 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<mat-paginator <mat-paginator *ngIf="!isEmpty()" [length]="totalItems()" [pageSize]="pageSize()" [pageIndex]="currentPage() - 1"
*ngIf="!isEmpty()" [pageSizeOptions]="[5, 10, 20, 50]" (page)="onPageChange($event)" showFirstLastButtons>
[length]="totalItems()"
[pageSize]="pageSize()"
[pageIndex]="currentPage() - 1"
[pageSizeOptions]="[5, 10, 20, 50]"
(page)="onPageChange($event)"
showFirstLastButtons
>
</mat-paginator> </mat-paginator>
</div> </div>

View File

@@ -15,7 +15,7 @@ import { UserService } from '../../core/services/user.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { CategoryService } from '../../core/services/category.service'; import { CategoryService } from '../../core/services/category.service';
import { QuizHistoryResponse, PaginationInfo } from '../../core/models/dashboard.model'; import { QuizHistoryResponse, PaginationInfo } from '../../core/models/dashboard.model';
import { QuizSession } from '../../core/models/quiz.model'; import { QuizSession, QuizSessionHistory } from '../../core/models/quiz.model';
import { Category } from '../../core/models/category.model'; import { Category } from '../../core/models/category.model';
@Component({ @Component({
@@ -47,7 +47,7 @@ export class QuizHistoryComponent implements OnInit {
// Signals // Signals
isLoading = signal<boolean>(true); isLoading = signal<boolean>(true);
history = signal<QuizSession[]>([]); history = signal<QuizSessionHistory[]>([]);
pagination = signal<PaginationInfo | null>(null); pagination = signal<PaginationInfo | null>(null);
categories = signal<Category[]>([]); categories = signal<Category[]>([]);
error = signal<string | null>(null); error = signal<string | null>(null);
@@ -126,8 +126,8 @@ export class QuizHistoryComponent implements OnInit {
this.sortBy() this.sortBy()
).subscribe({ ).subscribe({
next: (response: QuizHistoryResponse) => { next: (response: QuizHistoryResponse) => {
this.history.set(response.sessions || []); this.history.set(response.data.sessions || []);
this.pagination.set(response.pagination); this.pagination.set(response.data.pagination);
this.isLoading.set(false); this.isLoading.set(false);
}, },
error: (err: any) => { error: (err: any) => {
@@ -276,14 +276,14 @@ export class QuizHistoryComponent implements OnInit {
// Add data rows // Add data rows
this.history().forEach(session => { this.history().forEach(session => {
const percentage = ((session.score / session.totalQuestions) * 100).toFixed(2); const percentage = ((session.score.earned / session.questions.total) * 100).toFixed(2);
const row = [ const row = [
this.formatDate(session.completedAt || session.startedAt), this.formatDate(session.completedAt || session.startedAt),
session.categoryName || 'Unknown', session.category?.name || 'Unknown',
session.score.toString(), session.score.earned.toString(),
session.totalQuestions.toString(), session.questions.total.toString(),
`${percentage}%`, `${percentage}%`,
this.formatDuration(session.timeSpent), this.formatDuration(session.time.spent),
session.status session.status
]; ];
csvRows.push(row.join(',')); csvRows.push(row.join(','));

View File

@@ -16,10 +16,7 @@
<span>Score: {{ currentScore() }}</span> <span>Score: {{ currentScore() }}</span>
</div> </div>
</div> </div>
<mat-progress-bar <mat-progress-bar mode="determinate" [value]="progress()" class="progress-bar">
mode="determinate"
[value]="progress()"
class="progress-bar">
</mat-progress-bar> </mat-progress-bar>
</div> </div>
@@ -31,9 +28,7 @@
<div class="question-header"> <div class="question-header">
<div class="question-meta"> <div class="question-meta">
<mat-chip class="type-chip">{{ questionTypeLabel() }}</mat-chip> <mat-chip class="type-chip">{{ questionTypeLabel() }}</mat-chip>
<mat-chip <mat-chip class="difficulty-chip" [style.background-color]="getDifficultyColor(question.difficulty) + '20'"
class="difficulty-chip"
[style.background-color]="getDifficultyColor(question.difficulty) + '20'"
[style.color]="getDifficultyColor(question.difficulty)"> [style.color]="getDifficultyColor(question.difficulty)">
{{ question.difficulty | titlecase }} {{ question.difficulty | titlecase }}
</mat-chip> </mat-chip>
@@ -54,14 +49,12 @@
<form [formGroup]="answerForm" (ngSubmit)="submitAnswer()" class="answer-form"> <form [formGroup]="answerForm" (ngSubmit)="submitAnswer()" class="answer-form">
<!-- Multiple Choice --> <!-- Multiple Choice -->
@if (isMultipleChoice() && question.options) { @if (question.questionType === 'multiple' && question.options) {
<mat-radio-group formControlName="answer" class="radio-group"> <mat-radio-group formControlName="answer" class="radio-group">
@for (option of question.options; track option) { @for (option of question.options; track option) {
<mat-radio-button <mat-radio-button [value]="typeof option === 'string' ? option : option.id" [disabled]="answerSubmitted()"
[value]="option"
[disabled]="answerSubmitted()"
class="radio-option"> class="radio-option">
{{ option }} {{ typeof option === 'string' ? option : option.text }}
</mat-radio-button> </mat-radio-button>
} }
</mat-radio-group> </mat-radio-group>
@@ -70,22 +63,14 @@
<!-- True/False --> <!-- True/False -->
@if (isTrueFalse()) { @if (isTrueFalse()) {
<div class="true-false-buttons"> <div class="true-false-buttons">
<button <button type="button" mat-raised-button [class.selected]="answerForm.get('answer')?.value === 'true'"
type="button" [disabled]="answerSubmitted()" (click)="answerForm.patchValue({ answer: 'true' })"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'true'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'true' })"
class="tf-button true-button"> class="tf-button true-button">
<mat-icon>check_circle</mat-icon> <mat-icon>check_circle</mat-icon>
<span>True</span> <span>True</span>
</button> </button>
<button <button type="button" mat-raised-button [class.selected]="answerForm.get('answer')?.value === 'false'"
type="button" [disabled]="answerSubmitted()" (click)="answerForm.patchValue({ answer: 'false' })"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'false'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'false' })"
class="tf-button false-button"> class="tf-button false-button">
<mat-icon>cancel</mat-icon> <mat-icon>cancel</mat-icon>
<span>False</span> <span>False</span>
@@ -97,11 +82,7 @@
@if (isWritten()) { @if (isWritten()) {
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Your Answer</mat-label> <mat-label>Your Answer</mat-label>
<textarea <textarea matInput formControlName="answer" [disabled]="answerSubmitted()" rows="6"
matInput
formControlName="answer"
[disabled]="answerSubmitted()"
rows="6"
placeholder="Type your answer here..."> placeholder="Type your answer here...">
</textarea> </textarea>
<mat-hint>Be as detailed as possible</mat-hint> <mat-hint>Be as detailed as possible</mat-hint>
@@ -110,7 +91,8 @@
<!-- Answer Feedback --> <!-- Answer Feedback -->
@if (answerSubmitted() && answerResult()) { @if (answerSubmitted() && answerResult()) {
<div class="answer-feedback" [class.correct]="answerResult()?.isCorrect" [class.incorrect]="!answerResult()?.isCorrect"> <div class="answer-feedback" [class.correct]="answerResult()?.isCorrect"
[class.incorrect]="!answerResult()?.isCorrect">
<div class="feedback-header"> <div class="feedback-header">
<mat-icon [style.color]="getFeedbackColor()"> <mat-icon [style.color]="getFeedbackColor()">
{{ getFeedbackIcon() }} {{ getFeedbackIcon() }}
@@ -141,14 +123,13 @@
</div> </div>
} }
{{this.answerForm?.valid }}
{{ !this.answerSubmitted()}}
{{ !this.isSubmittingAnswer()}}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
@if (!answerSubmitted()) { @if (!answerSubmitted()) {
<button <button type="submit" mat-raised-button color="primary" [disabled]="!answerForm?.valid || !canSubmitAnswer()">
type="submit"
mat-raised-button
color="primary"
[disabled]="!canSubmitAnswer()">
@if (isSubmittingAnswer()) { @if (isSubmittingAnswer()) {
<mat-spinner diameter="20"></mat-spinner> <mat-spinner diameter="20"></mat-spinner>
<span>Submitting...</span> <span>Submitting...</span>
@@ -160,11 +141,7 @@
} }
</button> </button>
} @else { } @else {
<button <button type="button" mat-raised-button color="primary" (click)="nextQuestion()">
type="button"
mat-raised-button
color="primary"
(click)="nextQuestion()">
@if (isLastQuestion()) { @if (isLastQuestion()) {
<ng-container> <ng-container>
<mat-icon>flag</mat-icon> <mat-icon>flag</mat-icon>

View File

@@ -93,8 +93,8 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
readonly questionTypeLabel = computed(() => { readonly questionTypeLabel = computed(() => {
const type = this.currentQuestion()?.questionType; const type = this.currentQuestion()?.questionType;
switch (type) { switch (type) {
case 'multiple_choice': return 'Multiple Choice'; case 'multiple': return 'Multiple Choice';
case 'true_false': return 'True/False'; case 'trueFalse': return 'True/False';
case 'written': return 'Written Answer'; case 'written': return 'Written Answer';
default: return ''; default: return '';
} }
@@ -110,6 +110,8 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
this.initForm(); this.initForm();
this.loadQuizSession(); this.loadQuizSession();
console.log(this.questions());
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -359,14 +361,14 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
* Check if answer is multiple choice * Check if answer is multiple choice
*/ */
isMultipleChoice(): boolean { isMultipleChoice(): boolean {
return this.currentQuestion()?.questionType === 'multiple_choice'; return this.currentQuestion()?.questionType === 'multiple';
} }
/** /**
* Check if answer is true/false * Check if answer is true/false
*/ */
isTrueFalse(): boolean { isTrueFalse(): boolean {
return this.currentQuestion()?.questionType === 'true_false'; return this.currentQuestion()?.questionType === 'trueFalse';
} }
/** /**

View File

@@ -82,10 +82,7 @@
</h2> </h2>
<div class="question-count-selector"> <div class="question-count-selector">
@for (count of questionCountOptions; track count) { @for (count of questionCountOptions; track count) {
<button <button type="button" mat-stroked-button [class.selected]="setupForm.get('questionCount')?.value === count"
type="button"
mat-stroked-button
[class.selected]="setupForm.get('questionCount')?.value === count"
(click)="setupForm.patchValue({ questionCount: count })"> (click)="setupForm.patchValue({ questionCount: count })">
{{ count }} {{ count }}
</button> </button>
@@ -102,10 +99,7 @@
</h2> </h2>
<div class="difficulty-selector"> <div class="difficulty-selector">
@for (difficulty of difficultyOptions; track difficulty.value) { @for (difficulty of difficultyOptions; track difficulty.value) {
<button <button type="button" mat-stroked-button class="difficulty-option"
type="button"
mat-stroked-button
class="difficulty-option"
[class.selected]="setupForm.get('difficulty')?.value === difficulty.value" [class.selected]="setupForm.get('difficulty')?.value === difficulty.value"
(click)="setupForm.patchValue({ difficulty: difficulty.value })"> (click)="setupForm.patchValue({ difficulty: difficulty.value })">
<mat-icon [style.color]="difficulty.color">{{ difficulty.icon }}</mat-icon> <mat-icon [style.color]="difficulty.color">{{ difficulty.icon }}</mat-icon>
@@ -123,9 +117,7 @@
</h2> </h2>
<div class="quiz-type-selector"> <div class="quiz-type-selector">
@for (type of quizTypeOptions; track type.value) { @for (type of quizTypeOptions; track type.value) {
<mat-card <mat-card class="quiz-type-card" [class.selected]="setupForm.get('quizType')?.value === type.value"
class="quiz-type-card"
[class.selected]="setupForm.get('quizType')?.value === type.value"
(click)="setupForm.patchValue({ quizType: type.value })"> (click)="setupForm.patchValue({ quizType: type.value })">
<mat-icon class="type-icon">{{ type.icon }}</mat-icon> <mat-icon class="type-icon">{{ type.icon }}</mat-icon>
<h3>{{ type.label }}</h3> <h3>{{ type.label }}</h3>
@@ -173,18 +165,11 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<button <button type="button" mat-stroked-button routerLink="/categories">
type="button"
mat-stroked-button
routerLink="/categories">
<mat-icon>arrow_back</mat-icon> <mat-icon>arrow_back</mat-icon>
Back to Categories Back to Categories
</button> </button>
<button <button type="submit" mat-raised-button color="primary" [disabled]="(setupForm.invalid && !isStartingQuiz())">
type="submit"
mat-raised-button
color="primary"
[disabled]="!canStartQuiz()">
@if (isStartingQuiz()) { @if (isStartingQuiz()) {
<mat-spinner diameter="20"></mat-spinner> <mat-spinner diameter="20"></mat-spinner>
<span>Starting...</span> <span>Starting...</span>

View File

@@ -17,7 +17,7 @@ import { CategoryService } from '../../../core/services/category.service';
import { GuestService } from '../../../core/services/guest.service'; import { GuestService } from '../../../core/services/guest.service';
import { StorageService } from '../../../core/services/storage.service'; import { StorageService } from '../../../core/services/storage.service';
import { Category } from '../../../core/models/category.model'; import { Category } from '../../../core/models/category.model';
import { QuizStartRequest } from '../../../core/models/quiz.model'; import { QuizStartFormRequest, QuizStartRequest } from '../../../core/models/quiz.model';
@Component({ @Component({
selector: 'app-quiz-setup', selector: 'app-quiz-setup',
@@ -125,7 +125,7 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
*/ */
private initForm(): void { private initForm(): void {
this.setupForm = this.fb.group({ this.setupForm = this.fb.group({
categoryId: ['', Validators.required], categoryId: [null, Validators.required],
questionCount: [10, [Validators.required, Validators.min(5), Validators.max(20)]], questionCount: [10, [Validators.required, Validators.min(5), Validators.max(20)]],
difficulty: ['mixed', Validators.required], difficulty: ['mixed', Validators.required],
quizType: ['practice', Validators.required] quizType: ['practice', Validators.required]
@@ -160,7 +160,7 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
} }
const formValue = this.setupForm.value; const formValue = this.setupForm.value;
const request: QuizStartRequest = { const request: QuizStartFormRequest = {
categoryId: formValue.categoryId, categoryId: formValue.categoryId,
questionCount: formValue.questionCount, questionCount: formValue.questionCount,
difficulty: formValue.difficulty, difficulty: formValue.difficulty,
@@ -173,7 +173,7 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
next: (response) => { next: (response) => {
if (response.success) { if (response.success) {
// Navigate to quiz page // Navigate to quiz page
this.router.navigate(['/quiz', response.sessionId]); this.router.navigate(['/quiz', response.data.sessionId]);
} }
}, },
error: (error) => { error: (error) => {

View File

@@ -25,11 +25,8 @@
</div> </div>
<div class="progress-container"> <div class="progress-container">
<mat-progress-bar <mat-progress-bar mode="determinate" [value]="progress()"
mode="determinate" [color]="progress() > 66 ? 'primary' : progress() > 33 ? 'accent' : 'warn'"></mat-progress-bar>
[value]="progress()"
[color]="progress() > 66 ? 'primary' : progress() > 33 ? 'accent' : 'warn'"
></mat-progress-bar>
<span class="progress-text">{{ progress() }}% Complete</span> <span class="progress-text">{{ progress() }}% Complete</span>
</div> </div>
@@ -86,20 +83,11 @@
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions align="end"> <mat-dialog-actions align="end">
<button <button mat-button (click)="startNewQuiz()" class="action-btn secondary">
mat-button
(click)="startNewQuiz()"
class="action-btn secondary"
>
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
Start New Quiz Start New Quiz
</button> </button>
<button <button mat-raised-button color="primary" (click)="resumeQuiz()" class="action-btn primary">
mat-raised-button
color="primary"
(click)="resumeQuiz()"
class="action-btn primary"
>
<mat-icon>play_arrow</mat-icon> <mat-icon>play_arrow</mat-icon>
Continue Quiz Continue Quiz
</button> </button>