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