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

@@ -35,26 +35,26 @@ export class App implements OnInit {
private toastService = inject(ToastService); private toastService = inject(ToastService);
private router = inject(Router); private router = inject(Router);
protected title = 'Interview Quiz Application'; protected title = 'Interview Quiz Application';
// Signal for mobile sidebar state // Signal for mobile sidebar state
isSidebarOpen = signal<boolean>(false); isSidebarOpen = signal<boolean>(false);
// Signal for app initialization state // Signal for app initialization state
isInitializing = signal<boolean>(true); isInitializing = signal<boolean>(true);
// Signal for navigation loading state // Signal for navigation loading state
isNavigating = signal<boolean>(false); isNavigating = signal<boolean>(false);
// Computed signal to check if user is guest // Computed signal to check if user is guest
isGuest = computed(() => { isGuest = computed(() => {
return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated; return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated;
}); });
ngOnInit(): void { ngOnInit(): void {
this.initializeApp(); this.initializeApp();
this.setupNavigationListener(); this.setupNavigationListener();
} }
/** /**
* Setup navigation event listener for progress bar * Setup navigation event listener for progress bar
*/ */
@@ -71,24 +71,24 @@ export class App implements OnInit {
} }
}); });
} }
/** /**
* Initialize application and verify token * Initialize application and verify token
*/ */
private initializeApp(): void { private initializeApp(): void {
const token = this.authService.authState().isAuthenticated; const token = this.authService.authState().isAuthenticated;
// If no token, skip verification // If no token, skip verification
if (!token) { if (!token) {
this.isInitializing.set(false); this.isInitializing.set(false);
return; return;
} }
// Verify token on app load // Verify token on app load
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']);
} }
@@ -100,14 +100,14 @@ export class App implements OnInit {
} }
}); });
} }
/** /**
* Toggle mobile sidebar * Toggle mobile sidebar
*/ */
toggleSidebar(): void { toggleSidebar(): void {
this.isSidebarOpen.update(value => !value); this.isSidebarOpen.update(value => !value);
} }
/** /**
* Close sidebar (for mobile) * Close sidebar (for mobile)
*/ */

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: {
pagination: PaginationInfo; sessions: QuizSessionHistory[];
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;
sessionId: string; data: {
questions: Question[]; sessionId: string;
totalQuestions: number; questions: Question[];
message?: string; totalQuestions: number;
message?: string;
};
} }
/** /**

View File

@@ -5,12 +5,12 @@ import { Observable, throwError, tap, catchError } from 'rxjs';
import { environment } from '../../../environments/environment.development'; import { environment } from '../../../environments/environment.development';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
import { import {
User, User,
UserRegistration, UserRegistration,
UserLogin, UserLogin,
AuthResponse, AuthResponse,
AuthState AuthState
} from '../models/user.model'; } from '../models/user.model';
@Injectable({ @Injectable({
@@ -21,9 +21,9 @@ export class AuthService {
private storageService = inject(StorageService); private storageService = inject(StorageService);
private toastService = inject(ToastService); private toastService = inject(ToastService);
private router = inject(Router); private router = inject(Router);
private readonly API_URL = `${environment.apiUrl}/auth`; private readonly API_URL = `${environment.apiUrl}/auth`;
// Auth state signal // Auth state signal
private authStateSignal = signal<AuthState>({ private authStateSignal = signal<AuthState>({
user: this.storageService.getUserData(), user: this.storageService.getUserData(),
@@ -31,10 +31,10 @@ export class AuthService {
isLoading: false, isLoading: false,
error: null error: null
}); });
// Public readonly auth state // Public readonly auth state
public readonly authState = this.authStateSignal.asReadonly(); public readonly authState = this.authStateSignal.asReadonly();
/** /**
* Register a new user account * Register a new user account
* Handles guest-to-user conversion if guestSessionId provided * Handles guest-to-user conversion if guestSessionId provided
@@ -46,34 +46,34 @@ export class AuthService {
guestSessionId?: string guestSessionId?: string
): Observable<AuthResponse> { ): Observable<AuthResponse> {
this.setLoading(true); this.setLoading(true);
const registrationData: UserRegistration = { const registrationData: UserRegistration = {
username, username,
email, email,
password, password,
guestSessionId guestSessionId
}; };
return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe( return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe(
tap((response) => { tap((response) => {
// Store token and user data // Store token and user data
this.storageService.setToken(response.data.token, true); // Remember me by default this.storageService.setToken(response.data.token, true); // Remember me by default
this.storageService.setUserData(response.data.user); this.storageService.setUserData(response.data.user);
// Clear guest token if converting // Clear guest token if converting
if (guestSessionId) { if (guestSessionId) {
this.storageService.clearGuestToken(); this.storageService.clearGuestToken();
} }
// Update auth state // Update auth state
this.updateAuthState(response.data.user, null); this.updateAuthState(response.data.user, null);
// Show success message // Show success message
const message = response.migratedStats const message = response.migratedStats
? `Welcome ${response.data.user.username}! Your guest progress has been saved.` ? `Welcome ${response.data.user.username}! Your guest progress has been saved.`
: `Welcome ${response.data.user.username}! Your account has been created.`; : `Welcome ${response.data.user.username}! Your account has been created.`;
this.toastService.success(message); this.toastService.success(message);
// Auto-login: redirect to categories // Auto-login: redirect to categories
this.router.navigate(['/categories']); this.router.navigate(['/categories']);
}), }),
@@ -83,32 +83,32 @@ export class AuthService {
}) })
); );
} }
/** /**
* Login user * Login user
*/ */
login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/categories'): Observable<AuthResponse> { login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/categories'): Observable<AuthResponse> {
this.setLoading(true); this.setLoading(true);
const loginData: UserLogin = { email, password }; const loginData: UserLogin = { email, password };
return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe( return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe(
tap((response) => { tap((response) => {
// Store token and user data // Store token and user data
console.log(response.data.user); console.log(response.data.user);
this.storageService.setToken(response.data.token, rememberMe); this.storageService.setToken(response.data.token, rememberMe);
this.storageService.setUserData(response.data.user); this.storageService.setUserData(response.data.user);
// Clear guest token // Clear guest token
this.storageService.clearGuestToken(); this.storageService.clearGuestToken();
// Update auth state // Update auth state
this.updateAuthState(response.data.user, null); this.updateAuthState(response.data.user, null);
// Show success message // Show success message
this.toastService.success(`Welcome back, ${response.data.user.username}!`); this.toastService.success(`Welcome back, ${response.data.user.username}!`);
// Redirect to requested URL // Redirect to requested URL
this.router.navigate([redirectUrl]); this.router.navigate([redirectUrl]);
}), }),
@@ -118,18 +118,18 @@ export class AuthService {
}) })
); );
} }
/** /**
* Logout user * Logout user
*/ */
logout(): Observable<void> { logout(): Observable<void> {
this.setLoading(true); this.setLoading(true);
return this.http.post<void>(`${this.API_URL}/logout`, {}).pipe( return this.http.post<void>(`${this.API_URL}/logout`, {}).pipe(
tap(() => { tap(() => {
// Clear all auth data // Clear all auth data
this.storageService.clearAll(); this.storageService.clearAll();
// Reset auth state // Reset auth state
this.authStateSignal.set({ this.authStateSignal.set({
user: null, user: null,
@@ -137,10 +137,10 @@ export class AuthService {
isLoading: false, isLoading: false,
error: null error: null
}); });
// Show success message // Show success message
this.toastService.success('You have been logged out successfully.'); this.toastService.success('You have been logged out successfully.');
// Redirect to login // Redirect to login
this.router.navigate(['/login']); this.router.navigate(['/login']);
}), }),
@@ -158,13 +158,13 @@ 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) {
this.authStateSignal.update(state => ({ this.authStateSignal.update(state => ({
...state, ...state,
@@ -173,15 +173,15 @@ export class AuthService {
})); }));
return throwError(() => new Error('No token found')); return throwError(() => new Error('No token found'));
} }
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();
@@ -194,7 +194,7 @@ export class AuthService {
}) })
); );
} }
/** /**
* Clear authentication data * Clear authentication data
*/ */
@@ -208,7 +208,7 @@ export class AuthService {
error: null error: null
}); });
} }
/** /**
* Update auth state signal * Update auth state signal
*/ */
@@ -220,20 +220,20 @@ export class AuthService {
error error
}); });
} }
/** /**
* Set loading state * Set loading state
*/ */
private setLoading(isLoading: boolean): void { private setLoading(isLoading: boolean): void {
this.authStateSignal.update(state => ({ ...state, isLoading })); this.authStateSignal.update(state => ({ ...state, isLoading }));
} }
/** /**
* Handle authentication errors * Handle authentication errors
*/ */
private handleAuthError(error: HttpErrorResponse): void { private handleAuthError(error: HttpErrorResponse): void {
let errorMessage = 'An error occurred. Please try again.'; let errorMessage = 'An error occurred. Please try again.';
if (error.status === 400) { if (error.status === 400) {
errorMessage = 'Invalid input. Please check your information.'; errorMessage = 'Invalid input. Please check your information.';
} else if (error.status === 401) { } else if (error.status === 401) {
@@ -245,25 +245,25 @@ export class AuthService {
} else if (error.status === 0) { } else if (error.status === 0) {
errorMessage = 'Unable to connect to server. Please check your internet connection.'; errorMessage = 'Unable to connect to server. Please check your internet connection.';
} }
this.updateAuthState(null, errorMessage); this.updateAuthState(null, errorMessage);
this.toastService.error(errorMessage); this.toastService.error(errorMessage);
} }
/** /**
* Get current user * Get current user
*/ */
getCurrentUser(): User | null { getCurrentUser(): User | null {
return this.authStateSignal().user; return this.authStateSignal().user;
} }
/** /**
* Check if user is authenticated * Check if user is authenticated
*/ */
isAuthenticated(): boolean { isAuthenticated(): boolean {
return this.authStateSignal().isAuthenticated; return this.authStateSignal().isAuthenticated;
} }
/** /**
* Check if user is admin * Check if user is admin
*/ */

View File

@@ -15,13 +15,13 @@ export class GuestService {
private storageService = inject(StorageService); private storageService = inject(StorageService);
private toastService = inject(ToastService); private toastService = inject(ToastService);
private router = inject(Router); private router = inject(Router);
private readonly API_URL = `${environment.apiUrl}/guest`; private readonly API_URL = `${environment.apiUrl}/guest`;
private readonly GUEST_TOKEN_KEY = 'guest_token'; private readonly GUEST_TOKEN_KEY = 'guest_token';
private readonly GUEST_ID_KEY = 'guest_id'; private readonly GUEST_ID_KEY = 'guest_id';
private readonly DEVICE_ID_KEY = 'device_id'; private readonly DEVICE_ID_KEY = 'device_id';
private readonly SESSION_EXPIRY_HOURS = 24; private readonly SESSION_EXPIRY_HOURS = 24;
// Guest state signal // Guest state signal
private guestStateSignal = signal<GuestState>({ private guestStateSignal = signal<GuestState>({
session: null, session: null,
@@ -30,34 +30,34 @@ export class GuestService {
error: null, error: null,
quizLimit: null quizLimit: null
}); });
// Public readonly guest state // Public readonly guest state
public readonly guestState = this.guestStateSignal.asReadonly(); public readonly guestState = this.guestStateSignal.asReadonly();
/** /**
* 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
})); }));
this.toastService.success('Welcome! You\'re browsing as a guest.'); this.toastService.success('Welcome! You\'re browsing as a guest.');
}), }),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
@@ -67,13 +67,13 @@ export class GuestService {
}) })
); );
} }
/** /**
* Get guest session details * Get guest session details
*/ */
getSession(guestId: string): Observable<GuestSession> { getSession(guestId: string): Observable<GuestSession> {
this.setLoading(true); this.setLoading(true);
return this.http.get<GuestSession>(`${this.API_URL}/session/${guestId}`).pipe( return this.http.get<GuestSession>(`${this.API_URL}/session/${guestId}`).pipe(
tap((session: GuestSession) => { tap((session: GuestSession) => {
this.guestStateSignal.update(state => ({ this.guestStateSignal.update(state => ({
@@ -95,13 +95,13 @@ export class GuestService {
}) })
); );
} }
/** /**
* Get remaining quiz attempts for guest * Get remaining quiz attempts for guest
*/ */
getQuizLimit(): Observable<GuestLimit> { getQuizLimit(): Observable<GuestLimit> {
this.setLoading(true); this.setLoading(true);
return this.http.get<GuestLimit>(`${this.API_URL}/quiz-limit`).pipe( return this.http.get<GuestLimit>(`${this.API_URL}/quiz-limit`).pipe(
tap((limit: GuestLimit) => { tap((limit: GuestLimit) => {
this.guestStateSignal.update(state => ({ this.guestStateSignal.update(state => ({
@@ -117,14 +117,14 @@ export class GuestService {
}) })
); );
} }
/** /**
* Convert guest session to registered user * Convert guest session to registered user
* Called during registration process * Called during registration process
*/ */
convertToUser(guestSessionId: string, userData: any): Observable<any> { convertToUser(guestSessionId: string, userData: any): Observable<any> {
this.setLoading(true); this.setLoading(true);
return this.http.post(`${this.API_URL}/convert`, { return this.http.post(`${this.API_URL}/convert`, {
guestSessionId, guestSessionId,
...userData ...userData
@@ -140,23 +140,23 @@ export class GuestService {
}) })
); );
} }
/** /**
* Generate or retrieve device ID * Generate or retrieve device ID
* Used for fingerprinting guest sessions * Used for fingerprinting guest sessions
*/ */
private getOrCreateDeviceId(): string { private getOrCreateDeviceId(): string {
let deviceId = this.storageService.getItem(this.DEVICE_ID_KEY); let deviceId = this.storageService.getItem(this.DEVICE_ID_KEY);
if (!deviceId) { if (!deviceId) {
// Generate UUID v4 // Generate UUID v4
deviceId = this.generateUUID(); deviceId = this.generateUUID();
this.storageService.setItem(this.DEVICE_ID_KEY, deviceId); this.storageService.setItem(this.DEVICE_ID_KEY, deviceId);
} }
return deviceId; return deviceId;
} }
/** /**
* Generate UUID v4 * Generate UUID v4
*/ */
@@ -167,7 +167,7 @@ export class GuestService {
return v.toString(16); return v.toString(16);
}); });
} }
/** /**
* Check if user has an active guest session * Check if user has an active guest session
*/ */
@@ -176,42 +176,42 @@ export class GuestService {
const guestId = this.storageService.getItem(this.GUEST_ID_KEY); const guestId = this.storageService.getItem(this.GUEST_ID_KEY);
return !!(token && guestId); return !!(token && guestId);
} }
/** /**
* Get stored guest token * Get stored guest token
*/ */
getGuestToken(): string | null { getGuestToken(): string | null {
return this.storageService.getItem(this.GUEST_TOKEN_KEY); return this.storageService.getItem(this.GUEST_TOKEN_KEY);
} }
/** /**
* Get stored guest ID * Get stored guest ID
*/ */
getGuestId(): string | null { getGuestId(): string | null {
return this.storageService.getItem(this.GUEST_ID_KEY); return this.storageService.getItem(this.GUEST_ID_KEY);
} }
/** /**
* Check if session is expired (24 hours) * Check if session is expired (24 hours)
*/ */
isSessionExpired(): boolean { isSessionExpired(): boolean {
const session = this.guestState().session; const session = this.guestState().session;
if (!session) return true; if (!session) return true;
const createdAt = new Date(session.createdAt); const createdAt = new Date(session.createdAt);
const now = new Date(); const now = new Date();
const hoursDiff = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60); const hoursDiff = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60);
return hoursDiff >= this.SESSION_EXPIRY_HOURS; return hoursDiff >= this.SESSION_EXPIRY_HOURS;
} }
/** /**
* Clear guest session data * Clear guest session data
*/ */
clearGuestSession(): void { clearGuestSession(): void {
this.storageService.removeItem(this.GUEST_TOKEN_KEY); this.storageService.removeItem(this.GUEST_TOKEN_KEY);
this.storageService.removeItem(this.GUEST_ID_KEY); this.storageService.removeItem(this.GUEST_ID_KEY);
this.guestStateSignal.update(state => ({ this.guestStateSignal.update(state => ({
...state, ...state,
session: null, session: null,
@@ -221,14 +221,14 @@ export class GuestService {
quizLimit: null quizLimit: null
})); }));
} }
/** /**
* Set loading state * Set loading state
*/ */
private setLoading(isLoading: boolean): void { private setLoading(isLoading: boolean): void {
this.guestStateSignal.update(state => ({ ...state, isLoading })); this.guestStateSignal.update(state => ({ ...state, isLoading }));
} }
/** /**
* Set error state * Set error state
*/ */
@@ -239,7 +239,7 @@ export class GuestService {
error error
})); }));
} }
/** /**
* Check if guest has reached quiz limit * Check if guest has reached quiz limit
*/ */
@@ -248,24 +248,24 @@ export class GuestService {
if (!limit) return false; if (!limit) return false;
return limit.quizzesRemaining <= 0; return limit.quizzesRemaining <= 0;
} }
/** /**
* Get time remaining until session expires * Get time remaining until session expires
*/ */
getTimeRemaining(): string { getTimeRemaining(): string {
const session = this.guestState().session; const session = this.guestState().session;
if (!session) return '0h 0m'; if (!session) return '0h 0m';
const createdAt = new Date(session.createdAt); const createdAt = new Date(session.createdAt);
const expiryTime = new Date(createdAt.getTime() + (this.SESSION_EXPIRY_HOURS * 60 * 60 * 1000)); const expiryTime = new Date(createdAt.getTime() + (this.SESSION_EXPIRY_HOURS * 60 * 60 * 1000));
const now = new Date(); const now = new Date();
const diff = expiryTime.getTime() - now.getTime(); const diff = expiryTime.getTime() - now.getTime();
if (diff <= 0) return '0h 0m'; if (diff <= 0) return '0h 0m';
const hours = Math.floor(diff / (1000 * 60 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
return `${hours}h ${minutes}m`; return `${hours}h ${minutes}m`;
} }
} }

View File

@@ -3,13 +3,14 @@ import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable, tap, catchError, throwError, map } from 'rxjs'; import { Observable, tap, catchError, throwError, map } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { import {
QuizSession, QuizSession,
QuizStartRequest, QuizStartRequest,
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,
@@ -104,15 +105,15 @@ 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!');
} }
}), }),
@@ -139,11 +140,11 @@ export class QuizService {
const updated: QuizSession = { const updated: QuizSession = {
...currentSession, ...currentSession,
score: response.score, score: response.score,
correctAnswers: response.isCorrect correctAnswers: response.isCorrect
? currentSession.correctAnswers + 1 ? currentSession.correctAnswers + 1
: currentSession.correctAnswers, : currentSession.correctAnswers,
incorrectAnswers: !response.isCorrect incorrectAnswers: !response.isCorrect
? currentSession.incorrectAnswers + 1 ? currentSession.incorrectAnswers + 1
: currentSession.incorrectAnswers, : currentSession.incorrectAnswers,
currentQuestionIndex: currentSession.currentQuestionIndex + 1 currentQuestionIndex: currentSession.currentQuestionIndex + 1
}; };
@@ -169,7 +170,7 @@ export class QuizService {
tap(results => { tap(results => {
if (results.success) { if (results.success) {
this._quizResults.set(results); this._quizResults.set(results);
// Update session status // Update session status
const currentSession = this._activeSession(); const currentSession = this._activeSession();
if (currentSession) { if (currentSession) {
@@ -277,10 +278,10 @@ export class QuizService {
tap(session => { tap(session => {
// Store session ID in localStorage for future restoration // Store session ID in localStorage for future restoration
localStorage.setItem('activeQuizSessionId', sessionId); localStorage.setItem('activeQuizSessionId', sessionId);
// Check if we have questions stored // Check if we have questions stored
const hasQuestions = this._questions().length > 0; const hasQuestions = this._questions().length > 0;
if (!hasQuestions) { if (!hasQuestions) {
// Questions need to be fetched separately if not in memory // Questions need to be fetched separately if not in memory
// For now, we'll navigate to the quiz page which will handle loading // For now, we'll navigate to the quiz page which will handle loading

View File

@@ -14,25 +14,21 @@ 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);
} }
/** /**
* Set item in storage * Set item in storage
* 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

@@ -2,60 +2,56 @@
<!-- Header --> <!-- Header -->
<div class="form-header"> <div class="form-header">
@if (isEditMode()) { @if (isEditMode()) {
<h1> <h1>
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
Edit Question Edit Question
</h1> </h1>
<p class="subtitle">Update the details below to modify the quiz question</p> <p class="subtitle">Update the details below to modify the quiz question</p>
@if (questionId()) { @if (questionId()) {
<p class="question-id">Question ID: {{ questionId() }}</p> <p class="question-id">Question ID: {{ questionId() }}</p>
} }
} @else { } @else {
<h1> <h1>
<mat-icon>add_circle</mat-icon> <mat-icon>add_circle</mat-icon>
Create New Question Create New Question
</h1> </h1>
<p class="subtitle">Fill in the details below to create a new quiz question</p> <p class="subtitle">Fill in the details below to create a new quiz question</p>
} }
</div> </div>
<div class="form-layout"> <div class="form-layout">
<!-- Loading State --> <!-- Loading State -->
@if (isLoadingQuestion()) { @if (isLoadingQuestion()) {
<mat-card class="form-card loading-card"> <mat-card class="form-card loading-card">
<mat-card-content> <mat-card-content>
<div class="loading-container"> <div class="loading-container">
<mat-icon class="loading-icon">hourglass_empty</mat-icon> <mat-icon class="loading-icon">hourglass_empty</mat-icon>
<p>Loading question data...</p> <p>Loading question data...</p>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
} @else { } @else {
<!-- Form Section --> <!-- Form Section -->
<mat-card class="form-card"> <mat-card class="form-card">
<mat-card-content> <mat-card-content>
<form [formGroup]="questionForm" (ngSubmit)="onSubmit()"> <form [formGroup]="questionForm" (ngSubmit)="onSubmit()">
<!-- Form-level Error --> <!-- Form-level Error -->
@if (getFormError()) { @if (getFormError()) {
<div class="form-error"> <div class="form-error">
<mat-icon>error</mat-icon> <mat-icon>error</mat-icon>
<span>{{ getFormError() }}</span> <span>{{ getFormError() }}</span>
</div> </div>
} }
<!-- 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>
@if (getErrorMessage('questionText')) { @if (getErrorMessage('questionText')) {
<mat-error>{{ getErrorMessage('questionText') }}</mat-error> <mat-error>{{ getErrorMessage('questionText') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -65,13 +61,13 @@
<mat-label>Question Type</mat-label> <mat-label>Question Type</mat-label>
<mat-select formControlName="questionType" required> <mat-select formControlName="questionType" required>
@for (type of questionTypes; track type.value) { @for (type of questionTypes; track type.value) {
<mat-option [value]="type.value"> <mat-option [value]="type.value">
{{ type.label }} {{ type.label }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
@if (getErrorMessage('questionType')) { @if (getErrorMessage('questionType')) {
<mat-error>{{ getErrorMessage('questionType') }}</mat-error> <mat-error>{{ getErrorMessage('questionType') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -79,17 +75,17 @@
<mat-label>Category</mat-label> <mat-label>Category</mat-label>
<mat-select formControlName="categoryId" required> <mat-select formControlName="categoryId" required>
@if (isLoadingCategories()) { @if (isLoadingCategories()) {
<mat-option disabled>Loading categories...</mat-option> <mat-option disabled>Loading categories...</mat-option>
} @else { } @else {
@for (category of categories(); track category.id) { @for (category of categories(); track category.id) {
<mat-option [value]="category.id"> <mat-option [value]="category.id">
{{ category.name }} {{ category.name }}
</mat-option> </mat-option>
} }
} }
</mat-select> </mat-select>
@if (getErrorMessage('categoryId')) { @if (getErrorMessage('categoryId')) {
<mat-error>{{ getErrorMessage('categoryId') }}</mat-error> <mat-error>{{ getErrorMessage('categoryId') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
</div> </div>
@@ -100,29 +96,22 @@
<mat-label>Difficulty</mat-label> <mat-label>Difficulty</mat-label>
<mat-select formControlName="difficulty" required> <mat-select formControlName="difficulty" required>
@for (level of difficultyLevels; track level.value) { @for (level of difficultyLevels; track level.value) {
<mat-option [value]="level.value"> <mat-option [value]="level.value">
{{ level.label }} {{ level.label }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
@if (getErrorMessage('difficulty')) { @if (getErrorMessage('difficulty')) {
<mat-error>{{ getErrorMessage('difficulty') }}</mat-error> <mat-error>{{ getErrorMessage('difficulty') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
<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>
} }
</mat-form-field> </mat-form-field>
</div> </div>
@@ -131,109 +120,93 @@
<!-- Multiple Choice Options --> <!-- Multiple Choice Options -->
@if (showOptions()) { @if (showOptions()) {
<div class="options-section"> <div class="options-section">
<h3> <h3>
<mat-icon>list</mat-icon> <mat-icon>list</mat-icon>
Answer Options Answer Options
</h3> </h3>
<div formArrayName="options" class="options-list"> <div formArrayName="options" class="options-list">
@for (option of optionsArray.controls; track $index) { @for (option of optionsArray.controls; track $index) {
<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 </mat-form-field>
formControlName="text" @if (optionsArray.length > 2) {
[placeholder]="'Enter option ' + ($index + 1)" <button mat-icon-button type="button" color="warn" (click)="removeOption($index)"
required> matTooltip="Remove option">
</mat-form-field> <mat-icon>delete</mat-icon>
@if (optionsArray.length > 2) { </button>
<button
mat-icon-button
type="button"
color="warn"
(click)="removeOption($index)"
matTooltip="Remove option">
<mat-icon>delete</mat-icon>
</button>
}
</div>
} }
</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> </div>
<mat-divider></mat-divider> @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>
<!-- Correct Answer Selection --> <mat-divider></mat-divider>
<div class="correct-answer-section">
<h3> <!-- Correct Answer Selection -->
<mat-icon>check_circle</mat-icon> <div class="correct-answer-section">
Correct Answer <h3>
</h3> <mat-icon>check_circle</mat-icon>
<mat-form-field appearance="outline" class="full-width"> Correct Answer
<mat-label>Select Correct Answer</mat-label> </h3>
<mat-select formControlName="correctAnswer" required> <mat-form-field appearance="outline" class="full-width">
@for (optionText of getOptionTexts(); track $index) { <mat-label>Select Correct Answer</mat-label>
<mat-option [value]="optionText"> <mat-select formControlName="correctAnswer" required>
{{ optionText }} @for (optionText of getOptionTexts(); track $index) {
</mat-option> <mat-option [value]="optionText">
} {{ optionText }}
</mat-select> </mat-option>
@if (getErrorMessage('correctAnswer')) {
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
} }
</mat-form-field> </mat-select>
</div> @if (getErrorMessage('correctAnswer')) {
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
}
</mat-form-field>
</div>
} }
<!-- True/False Options --> <!-- True/False Options -->
@if (showTrueFalse()) { @if (showTrueFalse()) {
<div class="correct-answer-section"> <div class="correct-answer-section">
<h3> <h3>
<mat-icon>check_circle</mat-icon> <mat-icon>check_circle</mat-icon>
Correct Answer Correct Answer
</h3> </h3>
<mat-radio-group formControlName="correctAnswer" class="radio-group"> <mat-radio-group formControlName="correctAnswer" class="radio-group">
<mat-radio-button value="true">True</mat-radio-button> <mat-radio-button value="true">True</mat-radio-button>
<mat-radio-button value="false">False</mat-radio-button> <mat-radio-button value="false">False</mat-radio-button>
</mat-radio-group> </mat-radio-group>
</div> </div>
} }
<!-- Written Answer --> <!-- Written Answer -->
@if (selectedQuestionType() === 'written') { @if (selectedQuestionType() === 'written') {
<div class="correct-answer-section"> <div class="correct-answer-section">
<h3> <h3>
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
Sample Correct Answer Sample Correct Answer
</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 required>
formControlName="correctAnswer"
placeholder="Enter a sample correct answer..."
rows="3"
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>
@if (getErrorMessage('correctAnswer')) { @if (getErrorMessage('correctAnswer')) {
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error> <mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
</div> </div>
} }
<mat-divider></mat-divider> <mat-divider></mat-divider>
@@ -241,16 +214,12 @@
<!-- 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')) {
<mat-error>{{ getErrorMessage('explanation') }}</mat-error> <mat-error>{{ getErrorMessage('explanation') }}</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -264,19 +233,16 @@
<mat-label>Add Tags</mat-label> <mat-label>Add Tags</mat-label>
<mat-chip-grid #chipGrid> <mat-chip-grid #chipGrid>
@for (tag of tagsArray; track tag) { @for (tag of tagsArray; track tag) {
<mat-chip-row (removed)="removeTag(tag)"> <mat-chip-row (removed)="removeTag(tag)">
{{ tag }} {{ tag }}
<button matChipRemove> <button matChipRemove>
<mat-icon>cancel</mat-icon> <mat-icon>cancel</mat-icon>
</button> </button>
</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,29 +259,23 @@
<!-- 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>
<mat-icon>hourglass_empty</mat-icon> <mat-icon>hourglass_empty</mat-icon>
<span>{{ isEditMode() ? 'Updating...' : 'Creating...' }}</span> <span>{{ isEditMode() ? 'Updating...' : 'Creating...' }}</span>
</ng-container> </ng-container>
} @else { } @else {
<ng-container> <ng-container>
<mat-icon>save</mat-icon> <mat-icon>save</mat-icon>
<span>{{ isEditMode() ? 'Update Question' : 'Save Question' }}</span> <span>{{ isEditMode() ? 'Update Question' : 'Save Question' }}</span>
</ng-container> </ng-container>
} }
</button> </button>
</div> </div>
@@ -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">
@@ -357,56 +318,59 @@
<!-- Options Preview (MCQ) --> <!-- Options Preview (MCQ) -->
@if (showOptions() && getOptionTexts().length > 0) { @if (showOptions() && getOptionTexts().length > 0) {
<div class="preview-section"> <div class="preview-section">
<div class="preview-label">Options:</div> <div class="preview-label">Options:</div>
<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' :
<span>{{ optionText }}</span> 'radio_button_unchecked' }}</mat-icon>
</div> <span>{{ optionText }}</span>
}
</div> </div>
}
</div> </div>
</div>
} }
<!-- True/False Preview --> <!-- True/False Preview -->
@if (showTrueFalse()) { @if (showTrueFalse()) {
<div class="preview-section"> <div class="preview-section">
<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' :
<span>True</span> 'radio_button_unchecked' }}</mat-icon>
</div> <span>True</span>
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'false'"> </div>
<mat-icon>{{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon> <div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'false'">
<span>False</span> <mat-icon>{{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' :
</div> 'radio_button_unchecked' }}</mat-icon>
<span>False</span>
</div> </div>
</div> </div>
</div>
} }
<!-- Explanation Preview --> <!-- Explanation Preview -->
@if (questionForm.get('explanation')?.value) { @if (questionForm.get('explanation')?.value) {
<div class="preview-section"> <div class="preview-section">
<div class="preview-label">Explanation:</div> <div class="preview-label">Explanation:</div>
<div class="preview-explanation"> <div class="preview-explanation">
{{ questionForm.get('explanation')?.value }} {{ questionForm.get('explanation')?.value }}
</div>
</div> </div>
</div>
} }
<!-- Tags Preview --> <!-- Tags Preview -->
@if (tagsArray.length > 0) { @if (tagsArray.length > 0) {
<div class="preview-section"> <div class="preview-section">
<div class="preview-label">Tags:</div> <div class="preview-label">Tags:</div>
<div class="preview-tags"> <div class="preview-tags">
@for (tag of tagsArray; track tag) { @for (tag of tagsArray; track tag) {
<span class="preview-tag">{{ tag }}</span> <span class="preview-tag">{{ tag }}</span>
} }
</div>
</div> </div>
</div>
} }
<!-- Accessibility Preview --> <!-- Accessibility Preview -->
@@ -414,12 +378,12 @@
<div class="preview-label">Access:</div> <div class="preview-label">Access:</div>
<div class="preview-access"> <div class="preview-access">
@if (questionForm.get('isPublic')?.value) { @if (questionForm.get('isPublic')?.value) {
<span class="access-badge public">Public</span> <span class="access-badge public">Public</span>
} @else { } @else {
<span class="access-badge private">Private</span> <span class="access-badge private">Private</span>
} }
@if (questionForm.get('isGuestAccessible')?.value) { @if (questionForm.get('isGuestAccessible')?.value) {
<span class="access-badge guest">Guest Accessible</span> <span class="access-badge guest">Guest Accessible</span>
} }
</div> </div>
</div> </div>
@@ -427,4 +391,4 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</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(() => {
@@ -147,17 +147,17 @@ export class AdminQuestionFormComponent implements OnInit {
this.isLoadingQuestion.set(true); this.isLoadingQuestion.set(true);
this.adminService.getQuestion(id).subscribe({ this.adminService.getQuestion(id).subscribe({
next: (response) => { next: (response) => {
this.isLoadingQuestion.set(false); this.isLoadingQuestion.set(false);
this.populateForm(response.data); this.populateForm(response.data);
}, },
error: (error) => { error: (error) => {
this.isLoadingQuestion.set(false); this.isLoadingQuestion.set(false);
console.error('Error loading question:', error); console.error('Error loading question:', error);
// Redirect back if question not found // Redirect back if question not found
this.router.navigate(['/admin/questions']); this.router.navigate(['/admin/questions']);
} }
}); });
} }
/** /**
@@ -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]
}); });
@@ -247,14 +247,14 @@ 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]);
@@ -287,7 +287,7 @@ export class AdminQuestionFormComponent implements OnInit {
removeOption(index: number): void { removeOption(index: number): void {
if (this.optionsArray.length > 2) { if (this.optionsArray.length > 2) {
this.optionsArray.removeAt(index); this.optionsArray.removeAt(index);
// Clear correct answer if it matches the removed option // Clear correct answer if it matches the removed option
const correctAnswer = this.questionForm.get('correctAnswer')?.value; const correctAnswer = this.questionForm.get('correctAnswer')?.value;
const removedOption = this.optionsArray.at(index)?.get('text')?.value; const removedOption = this.optionsArray.at(index)?.get('text')?.value;
@@ -336,7 +336,7 @@ export class AdminQuestionFormComponent implements OnInit {
if (questionType === 'multiple_choice' && correctAnswer && options) { if (questionType === 'multiple_choice' && correctAnswer && options) {
const optionTexts = options.controls.map(opt => opt.get('text')?.value); const optionTexts = options.controls.map(opt => opt.get('text')?.value);
const isValid = optionTexts.includes(correctAnswer); const isValid = optionTexts.includes(correctAnswer);
if (!isValid) { if (!isValid) {
return { correctAnswerMismatch: true }; return { correctAnswerMismatch: true };
} }
@@ -388,15 +388,15 @@ export class AdminQuestionFormComponent implements OnInit {
: this.adminService.createQuestion(questionData); : this.adminService.createQuestion(questionData);
serviceCall.subscribe({ serviceCall.subscribe({
next: (response) => { next: (response) => {
this.isSubmitting.set(false); this.isSubmitting.set(false);
this.router.navigate(['/admin/questions']); this.router.navigate(['/admin/questions']);
}, },
error: (error) => { error: (error) => {
this.isSubmitting.set(false); this.isSubmitting.set(false);
console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error); console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error);
} }
}); });
} }
/** /**
@@ -425,7 +425,7 @@ export class AdminQuestionFormComponent implements OnInit {
*/ */
getErrorMessage(fieldName: string): string { getErrorMessage(fieldName: string): string {
const control = this.questionForm.get(fieldName); const control = this.questionForm.get(fieldName);
if (!control || !control.errors || !control.touched) { if (!control || !control.errors || !control.touched) {
return ''; return '';
} }

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,19 +37,20 @@ 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>();
// Signals // Signals
isSubmitting = signal<boolean>(false); isSubmitting = signal<boolean>(false);
hidePassword = signal<boolean>(true); hidePassword = signal<boolean>(true);
returnUrl = signal<string>('/categories'); returnUrl = signal<string>('/categories');
isStartingGuestSession = signal<boolean>(false); isStartingGuestSession = signal<boolean>(false);
// Form // Form
loginForm: FormGroup; loginForm: FormGroup;
constructor() { constructor() {
// Initialize form // Initialize form
this.loginForm = this.fb.group({ this.loginForm = this.fb.group({
@@ -56,27 +58,27 @@ export class LoginComponent implements OnDestroy {
password: ['', [Validators.required, Validators.minLength(8)]], password: ['', [Validators.required, Validators.minLength(8)]],
rememberMe: [false] rememberMe: [false]
}); });
// Get return URL from query params // Get return URL from query params
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe(params => { .subscribe(params => {
this.returnUrl.set(params['returnUrl'] || '/categories'); this.returnUrl.set(params['returnUrl'] || '/categories');
}); });
// Redirect if already authenticated // Redirect if already authenticated
if (this.authService.isAuthenticated()) { if (this.authService.isAuthenticated()) {
this.router.navigate(['/categories']); this.router.navigate(['/categories']);
} }
} }
/** /**
* Toggle password visibility * Toggle password visibility
*/ */
togglePasswordVisibility(): void { togglePasswordVisibility(): void {
this.hidePassword.update(val => !val); this.hidePassword.update(val => !val);
} }
/** /**
* Submit login form * Submit login form
*/ */
@@ -85,11 +87,11 @@ export class LoginComponent implements OnDestroy {
this.loginForm.markAllAsTouched(); this.loginForm.markAllAsTouched();
return; return;
} }
this.isSubmitting.set(true); this.isSubmitting.set(true);
const { email, password, rememberMe } = this.loginForm.value; const { email, password, rememberMe } = this.loginForm.value;
this.authService.login(email, password, rememberMe, this.returnUrl()) this.authService.login(email, password, rememberMe, this.returnUrl())
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
@@ -102,33 +104,33 @@ export class LoginComponent implements OnDestroy {
} }
}); });
} }
/** /**
* Get form control error message * Get form control error message
*/ */
getErrorMessage(controlName: string): string { getErrorMessage(controlName: string): string {
const control = this.loginForm.get(controlName); const control = this.loginForm.get(controlName);
if (!control || !control.touched) { if (!control || !control.touched) {
return ''; return '';
} }
if (control.hasError('required')) { if (control.hasError('required')) {
return `${this.getFieldLabel(controlName)} is required`; return `${this.getFieldLabel(controlName)} is required`;
} }
if (control.hasError('email')) { if (control.hasError('email')) {
return 'Please enter a valid email address'; return 'Please enter a valid email address';
} }
if (control.hasError('minlength')) { if (control.hasError('minlength')) {
const minLength = control.getError('minlength').requiredLength; const minLength = control.getError('minlength').requiredLength;
return `Must be at least ${minLength} characters`; return `Must be at least ${minLength} characters`;
} }
return ''; return '';
} }
/** /**
* Get field label * Get field label
*/ */
@@ -139,7 +141,7 @@ export class LoginComponent implements OnDestroy {
}; };
return labels[controlName] || controlName; return labels[controlName] || controlName;
} }
/** /**
* Start guest session * Start guest session
*/ */
@@ -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

@@ -17,7 +17,7 @@
<!-- History Content --> <!-- History Content -->
<div *ngIf="!isLoading() && !error()" class="history-container"> <div *ngIf="!isLoading() && !error()" class="history-container">
<!-- Header --> <!-- Header -->
<div class="history-header"> <div class="history-header">
<div class="header-title"> <div class="header-title">
@@ -32,7 +32,7 @@
Export CSV Export CSV
</button> </button>
</div> </div>
<!-- Filters and Sort --> <!-- Filters and Sort -->
<mat-card class="filters-card"> <mat-card class="filters-card">
<mat-card-content> <mat-card-content>
@@ -46,7 +46,7 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="filter-field"> <mat-form-field appearance="outline" class="filter-field">
<mat-label>Sort By</mat-label> <mat-label>Sort By</mat-label>
<mat-select [value]="sortBy()" (selectionChange)="onSortChange($event.value)"> <mat-select [value]="sortBy()" (selectionChange)="onSortChange($event.value)">
@@ -54,14 +54,14 @@
<mat-option value="score">Score (Highest First)</mat-option> <mat-option value="score">Score (Highest First)</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<button mat-icon-button (click)="refresh()" matTooltip="Refresh"> <button mat-icon-button (click)="refresh()" matTooltip="Refresh">
<mat-icon>refresh</mat-icon> <mat-icon>refresh</mat-icon>
</button> </button>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<!-- Empty State --> <!-- Empty State -->
<div *ngIf="isEmpty()" class="empty-state"> <div *ngIf="isEmpty()" class="empty-state">
<mat-icon class="empty-icon">quiz</mat-icon> <mat-icon class="empty-icon">quiz</mat-icon>
@@ -72,11 +72,11 @@
Start a Quiz Start a Quiz
</button> </button>
</div> </div>
<!-- Desktop Table View --> <!-- Desktop Table View -->
<mat-card class="table-card desktop-only" *ngIf="!isEmpty()"> <mat-card class="table-card desktop-only" *ngIf="!isEmpty()">
<table mat-table [dataSource]="history()" class="history-table"> <table mat-table [dataSource]="history()" class="history-table">
<!-- Date Column --> <!-- Date Column -->
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th> <th mat-header-cell *matHeaderCellDef>Date</th>
@@ -84,74 +84,66 @@
{{ formatDate(session.completedAt || session.startedAt) }} {{ formatDate(session.completedAt || session.startedAt) }}
</td> </td>
</ng-container> </ng-container>
<!-- Category Column --> <!-- Category Column -->
<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>
<!-- Score Column --> <!-- Score Column -->
<ng-container matColumnDef="score"> <ng-container matColumnDef="score">
<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>
<!-- Time Column --> <!-- Time Column -->
<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>
<!-- Status Column --> <!-- Status Column -->
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th> <th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let session"> <td mat-cell *matCellDef="let session">
<mat-chip [ngClass]="getStatusClass(session.status)"> <mat-chip [ngClass]="getStatusClass(session.status)">
{{ session.status === 'in_progress' ? 'In Progress' : {{ session.status === 'in_progress' ? 'In Progress' :
session.status === 'completed' ? 'Completed' : session.status === 'completed' ? 'Completed' :
'Abandoned' }} 'Abandoned' }}
</mat-chip> </mat-chip>
</td> </td>
</ng-container> </ng-container>
<!-- Actions Column --> <!-- Actions Column -->
<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>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table> </table>
</mat-card> </mat-card>
<!-- Mobile Card View --> <!-- Mobile Card View -->
<div class="mobile-cards mobile-only" *ngIf="!isEmpty()"> <div class="mobile-cards mobile-only" *ngIf="!isEmpty()">
<mat-card *ngFor="let session of history()" class="history-card"> <mat-card *ngFor="let session of history()" class="history-card">
@@ -159,15 +151,15 @@
<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' :
session.status === 'completed' ? 'Completed' : session.status === 'completed' ? 'Completed' :
'Abandoned' }} 'Abandoned' }}
</mat-chip> </mat-chip>
</div> </div>
<div class="card-details"> <div class="card-details">
<div class="detail-row"> <div class="detail-row">
<mat-icon>calendar_today</mat-icon> <mat-icon>calendar_today</mat-icon>
@@ -175,17 +167,17 @@
</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>
<div class="card-actions" *ngIf="session.status === 'completed'"> <div class="card-actions" *ngIf="session.status === 'completed'">
<button mat-button color="primary" (click)="viewResults(session.id)"> <button mat-button color="primary" (click)="viewResults(session.id)">
<mat-icon>visibility</mat-icon> <mat-icon>visibility</mat-icon>
@@ -199,16 +191,9 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</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({
@@ -44,32 +44,32 @@ export class QuizHistoryComponent implements OnInit {
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
// 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);
// Filter and sort state // Filter and sort state
currentPage = signal<number>(1); currentPage = signal<number>(1);
pageSize = signal<number>(10); pageSize = signal<number>(10);
selectedCategory = signal<string | null>(null); selectedCategory = signal<string | null>(null);
sortBy = signal<'date' | 'score'>('date'); sortBy = signal<'date' | 'score'>('date');
// Table columns // Table columns
displayedColumns: string[] = ['date', 'category', 'score', 'time', 'status', 'actions']; displayedColumns: string[] = ['date', 'category', 'score', 'time', 'status', 'actions'];
// Computed values // Computed values
isEmpty = computed(() => this.history().length === 0 && !this.isLoading()); isEmpty = computed(() => this.history().length === 0 && !this.isLoading());
totalItems = computed(() => this.pagination()?.totalItems || 0); totalItems = computed(() => this.pagination()?.totalItems || 0);
ngOnInit(): void { ngOnInit(): void {
this.loadCategories(); this.loadCategories();
this.loadHistoryFromRoute(); this.loadHistoryFromRoute();
} }
/** /**
* Load categories for filter * Load categories for filter
*/ */
@@ -83,7 +83,7 @@ export class QuizHistoryComponent implements OnInit {
} }
}); });
} }
/** /**
* Load history based on route query params * Load history based on route query params
*/ */
@@ -93,31 +93,31 @@ export class QuizHistoryComponent implements OnInit {
const limit = params['limit'] ? parseInt(params['limit'], 10) : 10; const limit = params['limit'] ? parseInt(params['limit'], 10) : 10;
const category = params['category'] || null; const category = params['category'] || null;
const sortBy = params['sortBy'] || 'date'; const sortBy = params['sortBy'] || 'date';
this.currentPage.set(page); this.currentPage.set(page);
this.pageSize.set(limit); this.pageSize.set(limit);
this.selectedCategory.set(category); this.selectedCategory.set(category);
this.sortBy.set(sortBy); this.sortBy.set(sortBy);
this.loadHistory(); this.loadHistory();
}); });
} }
/** /**
* Load quiz history * Load quiz history
*/ */
loadHistory(): void { loadHistory(): void {
const state: any = (this.authService as any).authState(); const state: any = (this.authService as any).authState();
const user = state?.user; const user = state?.user;
if (!user || !user.id) { if (!user || !user.id) {
this.router.navigate(['/login']); this.router.navigate(['/login']);
return; return;
} }
this.isLoading.set(true); this.isLoading.set(true);
this.error.set(null); this.error.set(null);
(this.userService as any).getHistory( (this.userService as any).getHistory(
user.id, user.id,
this.currentPage(), this.currentPage(),
@@ -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) => {
@@ -137,7 +137,7 @@ export class QuizHistoryComponent implements OnInit {
} }
}); });
} }
/** /**
* Handle page change * Handle page change
*/ */
@@ -146,7 +146,7 @@ export class QuizHistoryComponent implements OnInit {
this.pageSize.set(event.pageSize); this.pageSize.set(event.pageSize);
this.updateUrlAndLoad(); this.updateUrlAndLoad();
} }
/** /**
* Handle category filter change * Handle category filter change
*/ */
@@ -155,7 +155,7 @@ export class QuizHistoryComponent implements OnInit {
this.currentPage.set(1); // Reset to first page this.currentPage.set(1); // Reset to first page
this.updateUrlAndLoad(); this.updateUrlAndLoad();
} }
/** /**
* Handle sort change * Handle sort change
*/ */
@@ -164,7 +164,7 @@ export class QuizHistoryComponent implements OnInit {
this.currentPage.set(1); // Reset to first page this.currentPage.set(1); // Reset to first page
this.updateUrlAndLoad(); this.updateUrlAndLoad();
} }
/** /**
* Update URL with query params and reload data * Update URL with query params and reload data
*/ */
@@ -174,18 +174,18 @@ export class QuizHistoryComponent implements OnInit {
limit: this.pageSize(), limit: this.pageSize(),
sortBy: this.sortBy() sortBy: this.sortBy()
}; };
if (this.selectedCategory()) { if (this.selectedCategory()) {
queryParams.category = this.selectedCategory(); queryParams.category = this.selectedCategory();
} }
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams, queryParams,
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
} }
/** /**
* View quiz results * View quiz results
*/ */
@@ -194,7 +194,7 @@ export class QuizHistoryComponent implements OnInit {
this.router.navigate(['/quiz', sessionId, 'results']); this.router.navigate(['/quiz', sessionId, 'results']);
} }
} }
/** /**
* Review quiz * Review quiz
*/ */
@@ -203,13 +203,13 @@ export class QuizHistoryComponent implements OnInit {
this.router.navigate(['/quiz', sessionId, 'review']); this.router.navigate(['/quiz', sessionId, 'review']);
} }
} }
/** /**
* Format date * Format date
*/ */
formatDate(dateString: string | undefined): string { formatDate(dateString: string | undefined): string {
if (!dateString) return 'N/A'; if (!dateString) return 'N/A';
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@@ -219,23 +219,23 @@ export class QuizHistoryComponent implements OnInit {
minute: '2-digit' minute: '2-digit'
}); });
} }
/** /**
* Format duration * Format duration
*/ */
formatDuration(seconds: number | undefined): string { formatDuration(seconds: number | undefined): string {
if (!seconds) return '0s'; if (!seconds) return '0s';
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
if (minutes === 0) { if (minutes === 0) {
return `${secs}s`; return `${secs}s`;
} }
return `${minutes}m ${secs}s`; return `${minutes}m ${secs}s`;
} }
/** /**
* Get score color * Get score color
*/ */
@@ -245,7 +245,7 @@ export class QuizHistoryComponent implements OnInit {
if (percentage >= 60) return 'warning'; if (percentage >= 60) return 'warning';
return 'error'; return 'error';
} }
/** /**
* Get status badge class * Get status badge class
*/ */
@@ -261,7 +261,7 @@ export class QuizHistoryComponent implements OnInit {
return ''; return '';
} }
} }
/** /**
* Export to CSV * Export to CSV
*/ */
@@ -269,32 +269,32 @@ export class QuizHistoryComponent implements OnInit {
if (this.history().length === 0) { if (this.history().length === 0) {
return; return;
} }
// Create CSV header // Create CSV header
const headers = ['Date', 'Category', 'Score', 'Total Questions', 'Percentage', 'Time Spent', 'Status']; const headers = ['Date', 'Category', 'Score', 'Total Questions', 'Percentage', 'Time Spent', 'Status'];
const csvRows = [headers.join(',')]; const csvRows = [headers.join(',')];
// 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(','));
}); });
// Create blob and download // Create blob and download
const csvContent = csvRows.join('\n'); const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); const link = document.createElement('a');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
link.setAttribute('href', url); link.setAttribute('href', url);
link.setAttribute('download', `quiz-history-${new Date().toISOString().split('T')[0]}.csv`); link.setAttribute('download', `quiz-history-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden'; link.style.visibility = 'hidden';
@@ -302,7 +302,7 @@ export class QuizHistoryComponent implements OnInit {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
/** /**
* Refresh history * Refresh history
*/ */

View File

@@ -6,187 +6,164 @@
Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions() }} Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions() }}
</span> </span>
@if (activeSession()?.quizType === 'timed') { @if (activeSession()?.quizType === 'timed') {
<div class="timer" [class.warning]="timeRemaining() < 60"> <div class="timer" [class.warning]="timeRemaining() < 60">
<mat-icon>timer</mat-icon> <mat-icon>timer</mat-icon>
<span>{{ formatTime(timeRemaining()) }}</span> <span>{{ formatTime(timeRemaining()) }}</span>
</div> </div>
} }
<div class="score-display"> <div class="score-display">
<mat-icon>stars</mat-icon> <mat-icon>stars</mat-icon>
<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>
<!-- Question Card --> <!-- Question Card -->
<mat-card class="question-card"> <mat-card class="question-card">
@if (currentQuestion(); as question) { @if (currentQuestion(); as question) {
<!-- Question Header --> <!-- Question Header -->
<mat-card-header> <mat-card-header>
<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.color]="getDifficultyColor(question.difficulty)">
[style.background-color]="getDifficultyColor(question.difficulty) + '20'" {{ question.difficulty | titlecase }}
[style.color]="getDifficultyColor(question.difficulty)"> </mat-chip>
{{ question.difficulty | titlecase }} <span class="points">{{ question.points }} points</span>
</mat-chip>
<span class="points">{{ question.points }} points</span>
</div>
</div> </div>
</mat-card-header> </div>
</mat-card-header>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<mat-card-content> <mat-card-content>
<!-- Question Text --> <!-- Question Text -->
<div class="question-text"> <div class="question-text">
<h2>{{ question.questionText }}</h2> <h2>{{ question.questionText }}</h2>
</div>
<!-- Answer Form -->
<form [formGroup]="answerForm" (ngSubmit)="submitAnswer()" class="answer-form">
<!-- Multiple Choice -->
@if (question.questionType === 'multiple' && question.options) {
<mat-radio-group formControlName="answer" class="radio-group">
@for (option of question.options; track option) {
<mat-radio-button [value]="typeof option === 'string' ? option : option.id" [disabled]="answerSubmitted()"
class="radio-option">
{{ typeof option === 'string' ? option : option.text }}
</mat-radio-button>
}
</mat-radio-group>
}
<!-- True/False -->
@if (isTrueFalse()) {
<div class="true-false-buttons">
<button type="button" mat-raised-button [class.selected]="answerForm.get('answer')?.value === 'true'"
[disabled]="answerSubmitted()" (click)="answerForm.patchValue({ answer: 'true' })"
class="tf-button true-button">
<mat-icon>check_circle</mat-icon>
<span>True</span>
</button>
<button type="button" mat-raised-button [class.selected]="answerForm.get('answer')?.value === 'false'"
[disabled]="answerSubmitted()" (click)="answerForm.patchValue({ answer: 'false' })"
class="tf-button false-button">
<mat-icon>cancel</mat-icon>
<span>False</span>
</button>
</div> </div>
}
<!-- Answer Form --> <!-- Written Answer -->
<form [formGroup]="answerForm" (ngSubmit)="submitAnswer()" class="answer-form"> @if (isWritten()) {
<mat-form-field appearance="outline" class="full-width">
<!-- Multiple Choice --> <mat-label>Your Answer</mat-label>
@if (isMultipleChoice() && question.options) { <textarea matInput formControlName="answer" [disabled]="answerSubmitted()" rows="6"
<mat-radio-group formControlName="answer" class="radio-group"> placeholder="Type your answer here...">
@for (option of question.options; track option) {
<mat-radio-button
[value]="option"
[disabled]="answerSubmitted()"
class="radio-option">
{{ option }}
</mat-radio-button>
}
</mat-radio-group>
}
<!-- True/False -->
@if (isTrueFalse()) {
<div class="true-false-buttons">
<button
type="button"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'true'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'true' })"
class="tf-button true-button">
<mat-icon>check_circle</mat-icon>
<span>True</span>
</button>
<button
type="button"
mat-raised-button
[class.selected]="answerForm.get('answer')?.value === 'false'"
[disabled]="answerSubmitted()"
(click)="answerForm.patchValue({ answer: 'false' })"
class="tf-button false-button">
<mat-icon>cancel</mat-icon>
<span>False</span>
</button>
</div>
}
<!-- Written Answer -->
@if (isWritten()) {
<mat-form-field appearance="outline" class="full-width">
<mat-label>Your Answer</mat-label>
<textarea
matInput
formControlName="answer"
[disabled]="answerSubmitted()"
rows="6"
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>
</mat-form-field> </mat-form-field>
} }
<!-- 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"
<div class="feedback-header"> [class.incorrect]="!answerResult()?.isCorrect">
<mat-icon [style.color]="getFeedbackColor()"> <div class="feedback-header">
{{ getFeedbackIcon() }} <mat-icon [style.color]="getFeedbackColor()">
</mat-icon> {{ getFeedbackIcon() }}
<h3 [style.color]="getFeedbackColor()"> </mat-icon>
{{ getFeedbackMessage() }} <h3 [style.color]="getFeedbackColor()">
</h3> {{ getFeedbackMessage() }}
</div> </h3>
@if (!answerResult()?.isCorrect) {
<div class="correct-answer">
<strong>Correct Answer:</strong>
<p>{{ answerResult()?.correctAnswer }}</p>
</div>
}
@if (answerResult()?.explanation) {
<div class="explanation">
<strong>Explanation:</strong>
<p>{{ answerResult()?.explanation }}</p>
</div>
}
<div class="points-earned">
<mat-icon>stars</mat-icon>
<span>Points earned: {{ answerResult()?.points }}</span>
</div>
</div>
}
<!-- Action Buttons -->
<div class="action-buttons">
@if (!answerSubmitted()) {
<button
type="submit"
mat-raised-button
color="primary"
[disabled]="!canSubmitAnswer()">
@if (isSubmittingAnswer()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Submitting...</span>
} @else {
<ng-container>
<mat-icon>send</mat-icon>
<span>Submit Answer</span>
</ng-container>
}
</button>
} @else {
<button
type="button"
mat-raised-button
color="primary"
(click)="nextQuestion()">
@if (isLastQuestion()) {
<ng-container>
<mat-icon>flag</mat-icon>
<span>Complete Quiz</span>
</ng-container>
} @else {
<ng-container>
<mat-icon>arrow_forward</mat-icon>
<span>Next Question</span>
</ng-container>
}
</button>
}
</div> </div>
</form>
</mat-card-content> @if (!answerResult()?.isCorrect) {
<div class="correct-answer">
<strong>Correct Answer:</strong>
<p>{{ answerResult()?.correctAnswer }}</p>
</div>
}
@if (answerResult()?.explanation) {
<div class="explanation">
<strong>Explanation:</strong>
<p>{{ answerResult()?.explanation }}</p>
</div>
}
<div class="points-earned">
<mat-icon>stars</mat-icon>
<span>Points earned: {{ answerResult()?.points }}</span>
</div>
</div>
}
{{this.answerForm?.valid }}
{{ !this.answerSubmitted()}}
{{ !this.isSubmittingAnswer()}}
<!-- Action Buttons -->
<div class="action-buttons">
@if (!answerSubmitted()) {
<button type="submit" mat-raised-button color="primary" [disabled]="!answerForm?.valid || !canSubmitAnswer()">
@if (isSubmittingAnswer()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Submitting...</span>
} @else {
<ng-container>
<mat-icon>send</mat-icon>
<span>Submit Answer</span>
</ng-container>
}
</button>
} @else {
<button type="button" mat-raised-button color="primary" (click)="nextQuestion()">
@if (isLastQuestion()) {
<ng-container>
<mat-icon>flag</mat-icon>
<span>Complete Quiz</span>
</ng-container>
} @else {
<ng-container>
<mat-icon>arrow_forward</mat-icon>
<span>Next Question</span>
</ng-container>
}
</button>
}
</div>
</form>
</mat-card-content>
} @else { } @else {
<!-- Loading State --> <!-- Loading State -->
<mat-card-content class="loading-container"> <mat-card-content class="loading-container">
<mat-spinner diameter="50"></mat-spinner> <mat-spinner diameter="50"></mat-spinner>
<p>Loading question...</p> <p>Loading question...</p>
</mat-card-content> </mat-card-content>
} }
</mat-card> </mat-card>
@@ -208,4 +185,4 @@
</div> </div>
</div> </div>
</mat-card> </mat-card>
</div> </div>

View File

@@ -56,21 +56,21 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
readonly activeSession = this.quizService.activeSession; readonly activeSession = this.quizService.activeSession;
readonly isSubmittingAnswer = this.quizService.isSubmittingAnswer; readonly isSubmittingAnswer = this.quizService.isSubmittingAnswer;
readonly questions = this.quizService.questions; readonly questions = this.quizService.questions;
// Current question state // Current question state
readonly currentQuestionIndex = computed(() => this.activeSession()?.currentQuestionIndex ?? 0); readonly currentQuestionIndex = computed(() => this.activeSession()?.currentQuestionIndex ?? 0);
readonly totalQuestions = computed(() => this.activeSession()?.totalQuestions ?? 0); readonly totalQuestions = computed(() => this.activeSession()?.totalQuestions ?? 0);
readonly currentQuestion = signal<Question | null>(null); readonly currentQuestion = signal<Question | null>(null);
// Answer feedback state // Answer feedback state
readonly answerSubmitted = signal<boolean>(false); readonly answerSubmitted = signal<boolean>(false);
readonly answerResult = signal<QuizAnswerResponse | null>(null); readonly answerResult = signal<QuizAnswerResponse | null>(null);
readonly showExplanation = signal<boolean>(false); readonly showExplanation = signal<boolean>(false);
// Timer state (for timed quizzes) // Timer state (for timed quizzes)
readonly timeRemaining = signal<number>(0); // in seconds readonly timeRemaining = signal<number>(0); // in seconds
readonly timerRunning = signal<boolean>(false); readonly timerRunning = signal<boolean>(false);
// Progress // Progress
readonly progress = computed(() => { readonly progress = computed(() => {
const total = this.totalQuestions(); const total = this.totalQuestions();
@@ -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 '';
} }
@@ -102,7 +102,7 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.sessionId = this.route.snapshot.params['sessionId']; this.sessionId = this.route.snapshot.params['sessionId'];
if (!this.sessionId) { if (!this.sessionId) {
this.router.navigate(['/quiz/setup']); this.router.navigate(['/quiz/setup']);
return; 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 {
@@ -134,16 +136,16 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
// Check if we have an active session with questions already loaded // Check if we have an active session with questions already loaded
const activeSession = this.activeSession(); const activeSession = this.activeSession();
const questions = this.questions(); const questions = this.questions();
if (activeSession && activeSession.id === this.sessionId && questions.length > 0) { if (activeSession && activeSession.id === this.sessionId && questions.length > 0) {
// Session and questions already loaded from quiz start // Session and questions already loaded from quiz start
if (activeSession.status === 'completed') { if (activeSession.status === 'completed') {
this.router.navigate(['/quiz', this.sessionId, 'results']); this.router.navigate(['/quiz', this.sessionId, 'results']);
return; return;
} }
this.loadCurrentQuestion(); this.loadCurrentQuestion();
// Start timer for timed quizzes // Start timer for timed quizzes
if (activeSession.quizType === 'timed' && activeSession.timeSpent) { if (activeSession.quizType === 'timed' && activeSession.timeSpent) {
const timeLimit = this.calculateTimeLimit(activeSession.totalQuestions); const timeLimit = this.calculateTimeLimit(activeSession.totalQuestions);
@@ -212,7 +214,7 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
private loadCurrentQuestion(): void { private loadCurrentQuestion(): void {
const index = this.currentQuestionIndex(); const index = this.currentQuestionIndex();
const allQuestions = this.questions(); const allQuestions = this.questions();
if (index < allQuestions.length) { if (index < allQuestions.length) {
this.currentQuestion.set(allQuestions[index]); this.currentQuestion.set(allQuestions[index]);
this.answerSubmitted.set(false); this.answerSubmitted.set(false);
@@ -264,7 +266,7 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
if (!question) return; if (!question) return;
const answer = this.answerForm.get('answer')?.value; const answer = this.answerForm.get('answer')?.value;
const submission: QuizAnswerSubmission = { const submission: QuizAnswerSubmission = {
questionId: question.id, questionId: question.id,
answer: answer, answer: answer,
@@ -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

@@ -13,191 +13,176 @@
<mat-card-content> <mat-card-content>
<!-- Guest Warning --> <!-- Guest Warning -->
@if (showGuestWarning()) { @if (showGuestWarning()) {
<div class="guest-warning"> <div class="guest-warning">
<mat-icon class="warning-icon">warning</mat-icon> <mat-icon class="warning-icon">warning</mat-icon>
<div class="warning-content"> <div class="warning-content">
<p><strong>Limited Quizzes Remaining</strong></p> <p><strong>Limited Quizzes Remaining</strong></p>
<p>You have {{ remainingQuizzes() }} quiz(es) left as a guest.</p> <p>You have {{ remainingQuizzes() }} quiz(es) left as a guest.</p>
<button mat-stroked-button color="primary" (click)="navigateToRegister()"> <button mat-stroked-button color="primary" (click)="navigateToRegister()">
Sign Up for Unlimited Access Sign Up for Unlimited Access
</button> </button>
</div>
</div> </div>
</div>
} }
<!-- Loading State --> <!-- Loading State -->
@if (isLoadingCategories()) { @if (isLoadingCategories()) {
<div class="loading-container"> <div class="loading-container">
<mat-spinner diameter="50"></mat-spinner> <mat-spinner diameter="50"></mat-spinner>
<p>Loading categories...</p> <p>Loading categories...</p>
</div> </div>
} }
<!-- Setup Form --> <!-- Setup Form -->
@if (!isLoadingCategories()) { @if (!isLoadingCategories()) {
<form [formGroup]="setupForm" (ngSubmit)="startQuiz()" class="setup-form"> <form [formGroup]="setupForm" (ngSubmit)="startQuiz()" class="setup-form">
<!-- Category Selection -->
<div class="form-section">
<h2>
<mat-icon>category</mat-icon>
Select Category
</h2>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Choose a category</mat-label>
<mat-select formControlName="categoryId" required>
@for (category of getAvailableCategories(); track category.id) {
<mat-option [value]="category.id">
<div class="category-option">
@if (category.icon) {
<mat-icon [style.color]="category.color">{{ category.icon }}</mat-icon>
}
<span class="category-name">{{ category.name }}</span>
<span class="question-count">({{ category.questionCount }} questions)</span>
</div>
</mat-option>
}
</mat-select>
@if (setupForm.get('categoryId')?.hasError('required') && setupForm.get('categoryId')?.touched) {
<mat-error>Please select a category</mat-error>
}
</mat-form-field>
@if (selectedCategory()) { <!-- Category Selection -->
<div class="category-preview"> <div class="form-section">
<mat-icon [style.color]="selectedCategory()?.color">{{ selectedCategory()?.icon }}</mat-icon> <h2>
<div class="category-info"> <mat-icon>category</mat-icon>
<h3>{{ selectedCategory()?.name }}</h3> Select Category
<p>{{ selectedCategory()?.description }}</p> </h2>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Choose a category</mat-label>
<mat-select formControlName="categoryId" required>
@for (category of getAvailableCategories(); track category.id) {
<mat-option [value]="category.id">
<div class="category-option">
@if (category.icon) {
<mat-icon [style.color]="category.color">{{ category.icon }}</mat-icon>
}
<span class="category-name">{{ category.name }}</span>
<span class="question-count">({{ category.questionCount }} questions)</span>
</div> </div>
</div> </mat-option>
}
</mat-select>
@if (setupForm.get('categoryId')?.hasError('required') && setupForm.get('categoryId')?.touched) {
<mat-error>Please select a category</mat-error>
}
</mat-form-field>
@if (selectedCategory()) {
<div class="category-preview">
<mat-icon [style.color]="selectedCategory()?.color">{{ selectedCategory()?.icon }}</mat-icon>
<div class="category-info">
<h3>{{ selectedCategory()?.name }}</h3>
<p>{{ selectedCategory()?.description }}</p>
</div>
</div>
}
</div>
<!-- Question Count -->
<div class="form-section">
<h2>
<mat-icon>format_list_numbered</mat-icon>
Number of Questions
</h2>
<div class="question-count-selector">
@for (count of questionCountOptions; track count) {
<button type="button" mat-stroked-button [class.selected]="setupForm.get('questionCount')?.value === count"
(click)="setupForm.patchValue({ questionCount: count })">
{{ count }}
</button>
} }
</div> </div>
<p class="helper-text">Selected: {{ setupForm.get('questionCount')?.value }} questions</p>
</div>
<!-- Question Count --> <!-- Difficulty Selection -->
<div class="form-section"> <div class="form-section">
<h2> <h2>
<mat-icon>format_list_numbered</mat-icon> <mat-icon>tune</mat-icon>
Number of Questions Difficulty Level
</h2> </h2>
<div class="question-count-selector"> <div class="difficulty-selector">
@for (count of questionCountOptions; track count) { @for (difficulty of difficultyOptions; track difficulty.value) {
<button <button type="button" mat-stroked-button class="difficulty-option"
type="button" [class.selected]="setupForm.get('difficulty')?.value === difficulty.value"
mat-stroked-button (click)="setupForm.patchValue({ difficulty: difficulty.value })">
[class.selected]="setupForm.get('questionCount')?.value === count" <mat-icon [style.color]="difficulty.color">{{ difficulty.icon }}</mat-icon>
(click)="setupForm.patchValue({ questionCount: count })"> <span>{{ difficulty.label }}</span>
{{ count }} </button>
</button> }
}
</div>
<p class="helper-text">Selected: {{ setupForm.get('questionCount')?.value }} questions</p>
</div> </div>
</div>
<!-- Difficulty Selection --> <!-- Quiz Type Selection -->
<div class="form-section"> <div class="form-section">
<h2> <h2>
<mat-icon>tune</mat-icon> <mat-icon>mode</mat-icon>
Difficulty Level Quiz Mode
</h2> </h2>
<div class="difficulty-selector"> <div class="quiz-type-selector">
@for (difficulty of difficultyOptions; track difficulty.value) { @for (type of quizTypeOptions; track type.value) {
<button <mat-card class="quiz-type-card" [class.selected]="setupForm.get('quizType')?.value === type.value"
type="button" (click)="setupForm.patchValue({ quizType: type.value })">
mat-stroked-button <mat-icon class="type-icon">{{ type.icon }}</mat-icon>
class="difficulty-option" <h3>{{ type.label }}</h3>
[class.selected]="setupForm.get('difficulty')?.value === difficulty.value" <p>{{ type.description }}</p>
(click)="setupForm.patchValue({ difficulty: difficulty.value })">
<mat-icon [style.color]="difficulty.color">{{ difficulty.icon }}</mat-icon>
<span>{{ difficulty.label }}</span>
</button>
}
</div>
</div>
<!-- Quiz Type Selection -->
<div class="form-section">
<h2>
<mat-icon>mode</mat-icon>
Quiz Mode
</h2>
<div class="quiz-type-selector">
@for (type of quizTypeOptions; track type.value) {
<mat-card
class="quiz-type-card"
[class.selected]="setupForm.get('quizType')?.value === type.value"
(click)="setupForm.patchValue({ quizType: type.value })">
<mat-icon class="type-icon">{{ type.icon }}</mat-icon>
<h3>{{ type.label }}</h3>
<p>{{ type.description }}</p>
</mat-card>
}
</div>
</div>
<!-- Summary Card -->
<div class="summary-section">
<mat-card class="summary-card">
<h3>
<mat-icon>info</mat-icon>
Quiz Summary
</h3>
<div class="summary-details">
<div class="summary-item">
<span class="label">Category:</span>
<span class="value">{{ selectedCategory()?.name || 'Not selected' }}</span>
</div>
<div class="summary-item">
<span class="label">Questions:</span>
<span class="value">{{ setupForm.get('questionCount')?.value }}</span>
</div>
<div class="summary-item">
<span class="label">Difficulty:</span>
<span class="value">
{{ getSelectedDifficultyLabel() }}
</span>
</div>
<div class="summary-item">
<span class="label">Mode:</span>
<span class="value">
{{ getSelectedQuizTypeLabel() }}
</span>
</div>
<div class="summary-item">
<span class="label">Estimated Time:</span>
<span class="value">~{{ estimatedTime() }} minutes</span>
</div>
</div>
</mat-card> </mat-card>
}
</div> </div>
</div>
<!-- Action Buttons --> <!-- Summary Card -->
<div class="action-buttons"> <div class="summary-section">
<button <mat-card class="summary-card">
type="button" <h3>
mat-stroked-button <mat-icon>info</mat-icon>
routerLink="/categories"> Quiz Summary
<mat-icon>arrow_back</mat-icon> </h3>
Back to Categories <div class="summary-details">
</button> <div class="summary-item">
<button <span class="label">Category:</span>
type="submit" <span class="value">{{ selectedCategory()?.name || 'Not selected' }}</span>
mat-raised-button </div>
color="primary" <div class="summary-item">
[disabled]="!canStartQuiz()"> <span class="label">Questions:</span>
@if (isStartingQuiz()) { <span class="value">{{ setupForm.get('questionCount')?.value }}</span>
<mat-spinner diameter="20"></mat-spinner> </div>
<span>Starting...</span> <div class="summary-item">
} @else { <span class="label">Difficulty:</span>
<ng-container> <span class="value">
<mat-icon>play_arrow</mat-icon> {{ getSelectedDifficultyLabel() }}
<span>Start Quiz</span> </span>
</ng-container> </div>
} <div class="summary-item">
</button> <span class="label">Mode:</span>
</div> <span class="value">
</form> {{ getSelectedQuizTypeLabel() }}
</span>
</div>
<div class="summary-item">
<span class="label">Estimated Time:</span>
<span class="value">~{{ estimatedTime() }} minutes</span>
</div>
</div>
</mat-card>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button type="button" mat-stroked-button routerLink="/categories">
<mat-icon>arrow_back</mat-icon>
Back to Categories
</button>
<button type="submit" mat-raised-button color="primary" [disabled]="(setupForm.invalid && !isStartingQuiz())">
@if (isStartingQuiz()) {
<mat-spinner diameter="20"></mat-spinner>
<span>Starting...</span>
} @else {
<ng-container>
<mat-icon>play_arrow</mat-icon>
<span>Start Quiz</span>
</ng-container>
}
</button>
</div>
</form>
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

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',
@@ -56,7 +56,7 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
readonly categories = this.categoryService.categories; readonly categories = this.categoryService.categories;
readonly isLoadingCategories = this.categoryService.isLoading; readonly isLoadingCategories = this.categoryService.isLoading;
readonly isStartingQuiz = this.quizService.isStartingQuiz; readonly isStartingQuiz = this.quizService.isStartingQuiz;
// Guest limit // Guest limit
readonly isGuest = computed(() => !this.storageService.isAuthenticated()); readonly isGuest = computed(() => !this.storageService.isAuthenticated());
readonly guestState = this.guestService.guestState; readonly guestState = this.guestService.guestState;
@@ -68,7 +68,7 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
// Question count options // Question count options
readonly questionCountOptions = [5, 10, 15, 20]; readonly questionCountOptions = [5, 10, 15, 20];
// Difficulty options // Difficulty options
readonly difficultyOptions = [ readonly difficultyOptions = [
{ value: 'easy', label: 'Easy', icon: 'sentiment_satisfied', color: '#4CAF50' }, { value: 'easy', label: 'Easy', icon: 'sentiment_satisfied', color: '#4CAF50' },
@@ -79,15 +79,15 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
// Quiz type options // Quiz type options
readonly quizTypeOptions = [ readonly quizTypeOptions = [
{ {
value: 'practice', value: 'practice',
label: 'Practice Mode', label: 'Practice Mode',
icon: 'school', icon: 'school',
description: 'No time limit, learn at your own pace' description: 'No time limit, learn at your own pace'
}, },
{ {
value: 'timed', value: 'timed',
label: 'Timed Mode', label: 'Timed Mode',
icon: 'timer', icon: 'timer',
description: 'Challenge yourself with time constraints' description: 'Challenge yourself with time constraints'
} }
@@ -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) => {
@@ -187,12 +187,12 @@ export class QuizSetupComponent implements OnInit, OnDestroy {
*/ */
getAvailableCategories(): Category[] { getAvailableCategories(): Category[] {
const allCategories = this.categories() || []; const allCategories = this.categories() || [];
if (this.isGuest()) { if (this.isGuest()) {
// Filter to show only guest-accessible categories // Filter to show only guest-accessible categories
return allCategories.filter(cat => cat.guestAccessible); return allCategories.filter(cat => cat.guestAccessible);
} }
return allCategories; return allCategories;
} }

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>
@@ -76,32 +73,23 @@
</div> </div>
@if (session().score > 0) { @if (session().score > 0) {
<div class="current-score"> <div class="current-score">
<mat-icon>emoji_events</mat-icon> <mat-icon>emoji_events</mat-icon>
<span>Current Score: <strong>{{ session().score }} points</strong></span> <span>Current Score: <strong>{{ session().score }} points</strong></span>
</div> </div>
} }
</div> </div>
</div> </div>
</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>
</mat-dialog-actions> </mat-dialog-actions>
</div> </div>