add changes
This commit is contained in:
23
frontend/src/app/app.config.ts
Normal file
23
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor, guestInterceptor, errorInterceptor } from './core/interceptors';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(
|
||||
withInterceptors([
|
||||
authInterceptor,
|
||||
guestInterceptor,
|
||||
errorInterceptor
|
||||
])
|
||||
)
|
||||
]
|
||||
};
|
||||
41
frontend/src/app/app.html
Normal file
41
frontend/src/app/app.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- Interview Quiz Application -->
|
||||
|
||||
<!-- Loading Screen -->
|
||||
@if (isInitializing()) {
|
||||
<app-loading></app-loading>
|
||||
}
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<app-toast-container></app-toast-container>
|
||||
|
||||
<!-- App Shell -->
|
||||
<div class="app-shell">
|
||||
<!-- Header -->
|
||||
<app-header (menuToggle)="toggleSidebar()"></app-header>
|
||||
|
||||
<!-- Guest Mode Banner -->
|
||||
@if (isGuest()) {
|
||||
<app-guest-banner></app-guest-banner>
|
||||
}
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="main-container">
|
||||
<!-- Sidebar Navigation -->
|
||||
<app-sidebar [isOpen]="isSidebarOpen()"></app-sidebar>
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
@if (isSidebarOpen()) {
|
||||
<div class="sidebar-overlay" (click)="closeSidebar()"></div>
|
||||
}
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<router-outlet />
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<app-footer></app-footer>
|
||||
</div>
|
||||
|
||||
31
frontend/src/app/app.routes.ts
Normal file
31
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard } from './core/guards';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Authentication routes (guest only - redirect to dashboard if already logged in)
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login/login').then(m => m.LoginComponent),
|
||||
canActivate: [guestGuard],
|
||||
title: 'Login - Quiz Platform'
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () => import('./features/auth/register/register').then(m => m.RegisterComponent),
|
||||
canActivate: [guestGuard],
|
||||
title: 'Register - Quiz Platform'
|
||||
},
|
||||
|
||||
// TODO: Add more routes as components are created
|
||||
// - Home page (public)
|
||||
// - Dashboard (protected with authGuard)
|
||||
// - Quiz routes (protected with authGuard)
|
||||
// - Results routes (protected with authGuard)
|
||||
// - Admin routes (protected with adminGuard)
|
||||
|
||||
// Fallback - redirect to login for now
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'login'
|
||||
}
|
||||
];
|
||||
74
frontend/src/app/app.scss
Normal file
74
frontend/src/app/app.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
// App Shell Layout
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
// Main Container
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
margin-top: var(--header-height);
|
||||
}
|
||||
|
||||
// Main Content Area
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-x: hidden;
|
||||
|
||||
// Add left margin for sidebar on desktop
|
||||
@media (min-width: 1024px) {
|
||||
margin-left: var(--sidebar-width);
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
@media (max-width: 767px) {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
// Min height to push footer down
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
}
|
||||
|
||||
// Sidebar Overlay (Mobile)
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: calc(var(--z-sticky) - 1);
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
|
||||
// Hide on desktop
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth scrolling
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
// Prevent scroll when sidebar is open on mobile
|
||||
body.sidebar-open {
|
||||
@media (max-width: 1023px) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/app.spec.ts
Normal file
25
frontend/src/app/app.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideZonelessChangeDetection()]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
93
frontend/src/app/app.ts
Normal file
93
frontend/src/app/app.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Component, signal, inject, OnInit, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { ToastContainerComponent } from './shared/components/toast-container/toast-container';
|
||||
import { HeaderComponent } from './shared/components/header/header';
|
||||
import { SidebarComponent } from './shared/components/sidebar/sidebar';
|
||||
import { FooterComponent } from './shared/components/footer/footer';
|
||||
import { AppLoadingComponent } from './shared/components/app-loading/app-loading';
|
||||
import { GuestBannerComponent } from './shared/components/guest-banner/guest-banner';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
import { GuestService } from './core/services/guest.service';
|
||||
import { ToastService } from './core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
ToastContainerComponent,
|
||||
HeaderComponent,
|
||||
SidebarComponent,
|
||||
FooterComponent,
|
||||
AppLoadingComponent,
|
||||
GuestBannerComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private guestService = inject(GuestService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
protected title = 'Interview Quiz Application';
|
||||
|
||||
// Signal for mobile sidebar state
|
||||
isSidebarOpen = signal<boolean>(false);
|
||||
|
||||
// Signal for app initialization state
|
||||
isInitializing = signal<boolean>(true);
|
||||
|
||||
// Computed signal to check if user is guest
|
||||
isGuest = computed(() => {
|
||||
return this.guestService.guestState().isGuest && !this.authService.authState().isAuthenticated;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize application and verify token
|
||||
*/
|
||||
private initializeApp(): void {
|
||||
const token = this.authService.authState().isAuthenticated;
|
||||
|
||||
// If no token, skip verification
|
||||
if (!token) {
|
||||
this.isInitializing.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token on app load
|
||||
this.authService.verifyToken().subscribe({
|
||||
next: (response) => {
|
||||
this.isInitializing.set(false);
|
||||
if (!response.valid) {
|
||||
this.toastService.warning('Session expired. Please login again.');
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isInitializing.set(false);
|
||||
this.toastService.warning('Session expired. Please login again.');
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mobile sidebar
|
||||
*/
|
||||
toggleSidebar(): void {
|
||||
this.isSidebarOpen.update(value => !value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close sidebar (for mobile)
|
||||
*/
|
||||
closeSidebar(): void {
|
||||
this.isSidebarOpen.set(false);
|
||||
}
|
||||
}
|
||||
100
frontend/src/app/core/guards/auth.guard.ts
Normal file
100
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
/**
|
||||
* Auth Guard - Protects routes that require authentication
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* {
|
||||
* path: 'dashboard',
|
||||
* component: DashboardComponent,
|
||||
* canActivate: [authGuard]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const toastService = inject(ToastService);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store the attempted URL for redirecting
|
||||
const redirectUrl = state.url;
|
||||
|
||||
// Show message
|
||||
toastService.warning('Please login to access this page.');
|
||||
|
||||
// Redirect to login with return URL
|
||||
router.navigate(['/login'], {
|
||||
queryParams: { returnUrl: redirectUrl }
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin Guard - Protects routes that require admin role
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* {
|
||||
* path: 'admin',
|
||||
* component: AdminDashboardComponent,
|
||||
* canActivate: [adminGuard]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const adminGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const toastService = inject(ToastService);
|
||||
|
||||
if (!authService.isAuthenticated()) {
|
||||
toastService.warning('Please login to access this page.');
|
||||
router.navigate(['/login'], {
|
||||
queryParams: { returnUrl: state.url }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authService.isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// User is authenticated but not admin
|
||||
toastService.error('You do not have permission to access this page.');
|
||||
router.navigate(['/dashboard']);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest Guard - Redirects authenticated users away from guest-only pages
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* {
|
||||
* path: 'login',
|
||||
* component: LoginComponent,
|
||||
* canActivate: [guestGuard]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const guestGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
// Already logged in, redirect to dashboard
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
1
frontend/src/app/core/guards/index.ts
Normal file
1
frontend/src/app/core/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth.guard';
|
||||
37
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
37
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
import { GuestService } from '../services/guest.service';
|
||||
|
||||
/**
|
||||
* Auth Interceptor
|
||||
* Adds JWT token or guest token to outgoing HTTP requests
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const storageService = inject(StorageService);
|
||||
const guestService = inject(GuestService);
|
||||
|
||||
const token = storageService.getToken();
|
||||
const guestToken = guestService.getGuestToken();
|
||||
|
||||
let headers: { [key: string]: string } = {};
|
||||
|
||||
// Add JWT token if user is authenticated
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// Add guest token if user is in guest mode (and not authenticated)
|
||||
else if (guestToken) {
|
||||
headers['x-guest-token'] = guestToken;
|
||||
}
|
||||
|
||||
// Clone request with headers if any were added
|
||||
if (Object.keys(headers).length > 0) {
|
||||
const authReq = req.clone({
|
||||
setHeaders: headers
|
||||
});
|
||||
return next(authReq);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
69
frontend/src/app/core/interceptors/error.interceptor.ts
Normal file
69
frontend/src/app/core/interceptors/error.interceptor.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
|
||||
/**
|
||||
* Error Interceptor
|
||||
* Handles HTTP errors globally
|
||||
*/
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const router = inject(Router);
|
||||
const toastService = inject(ToastService);
|
||||
const storageService = inject(StorageService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side error
|
||||
errorMessage = `Error: ${error.error.message}`;
|
||||
} else {
|
||||
// Server-side error
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
errorMessage = error.error?.message || 'Bad request';
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = 'Unauthorized. Please login again.';
|
||||
storageService.clearToken();
|
||||
storageService.clearGuestToken();
|
||||
router.navigate(['/login']);
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = error.error?.message || 'Access forbidden';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = error.error?.message || 'Resource not found';
|
||||
break;
|
||||
case 409:
|
||||
errorMessage = error.error?.message || 'Conflict - Resource already exists';
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = 'Too many requests. Please try again later.';
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.error?.message || `Error ${error.status}: ${error.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Show toast notification for user-facing errors
|
||||
if (error.status !== 401) { // Don't show toast for 401, redirect is enough
|
||||
toastService.error(errorMessage);
|
||||
}
|
||||
|
||||
return throwError(() => ({
|
||||
status: error.status,
|
||||
statusText: error.statusText,
|
||||
message: errorMessage,
|
||||
error: error.error
|
||||
}));
|
||||
})
|
||||
);
|
||||
};
|
||||
26
frontend/src/app/core/interceptors/guest.interceptor.ts
Normal file
26
frontend/src/app/core/interceptors/guest.interceptor.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
|
||||
/**
|
||||
* Guest Interceptor
|
||||
* Adds x-guest-token header for guest user requests
|
||||
*/
|
||||
export const guestInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const storageService = inject(StorageService);
|
||||
const guestToken = storageService.getGuestToken();
|
||||
|
||||
// Only add guest token if no auth token and guest token exists
|
||||
const authToken = storageService.getToken();
|
||||
|
||||
if (!authToken && guestToken) {
|
||||
const guestReq = req.clone({
|
||||
setHeaders: {
|
||||
'x-guest-token': guestToken
|
||||
}
|
||||
});
|
||||
return next(guestReq);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
3
frontend/src/app/core/interceptors/index.ts
Normal file
3
frontend/src/app/core/interceptors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth.interceptor';
|
||||
export * from './guest.interceptor';
|
||||
export * from './error.interceptor';
|
||||
76
frontend/src/app/core/models/category.model.ts
Normal file
76
frontend/src/app/core/models/category.model.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Category Interface
|
||||
* Represents a quiz category
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
questionCount: number;
|
||||
displayOrder?: number;
|
||||
isActive: boolean;
|
||||
guestAccessible: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category Detail with Stats
|
||||
*/
|
||||
export interface CategoryDetail extends Category {
|
||||
questionPreview?: QuestionPreview[];
|
||||
stats?: CategoryStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category Statistics
|
||||
*/
|
||||
export interface CategoryStats {
|
||||
totalQuestions: number;
|
||||
questionsByDifficulty: {
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
};
|
||||
totalAttempts: number;
|
||||
totalCorrect: number;
|
||||
averageAccuracy: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Question Preview (limited info)
|
||||
*/
|
||||
export interface QuestionPreview {
|
||||
id: string;
|
||||
questionText: string;
|
||||
questionType: QuestionType;
|
||||
difficulty: Difficulty;
|
||||
points: number;
|
||||
accuracy?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Question Types
|
||||
*/
|
||||
export type QuestionType = 'multiple_choice' | 'true_false' | 'written';
|
||||
|
||||
/**
|
||||
* Difficulty Levels
|
||||
*/
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
/**
|
||||
* Category Create/Update Request
|
||||
*/
|
||||
export interface CategoryFormData {
|
||||
name: string;
|
||||
slug?: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
displayOrder?: number;
|
||||
guestAccessible: boolean;
|
||||
}
|
||||
148
frontend/src/app/core/models/dashboard.model.ts
Normal file
148
frontend/src/app/core/models/dashboard.model.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { User } from './user.model';
|
||||
import { QuizSession } from './quiz.model';
|
||||
|
||||
/**
|
||||
* User Dashboard Response
|
||||
*/
|
||||
export interface UserDashboard {
|
||||
success: boolean;
|
||||
totalQuizzes: number;
|
||||
totalQuestionsAnswered: number;
|
||||
overallAccuracy: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
averageScore: number;
|
||||
recentQuizzes: QuizSession[];
|
||||
categoryPerformance: CategoryPerformance[];
|
||||
achievements?: Achievement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Category Performance
|
||||
*/
|
||||
export interface CategoryPerformance {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
quizzesTaken: number;
|
||||
averageScore: number;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Quiz History
|
||||
*/
|
||||
export interface QuizHistoryResponse {
|
||||
success: boolean;
|
||||
sessions: QuizSession[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination Info
|
||||
*/
|
||||
export interface PaginationInfo {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Achievement
|
||||
*/
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
earnedAt?: string;
|
||||
progress?: number;
|
||||
maxProgress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Profile Update Request
|
||||
*/
|
||||
export interface UserProfileUpdate {
|
||||
username?: string;
|
||||
email?: string;
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark
|
||||
*/
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
userId: string;
|
||||
questionId: string;
|
||||
question?: any; // Will use Question type
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmarks Response
|
||||
*/
|
||||
export interface BookmarksResponse {
|
||||
success: boolean;
|
||||
bookmarks: any[]; // Will contain Question objects
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Statistics
|
||||
*/
|
||||
export interface AdminStatistics {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalQuizSessions: number;
|
||||
totalQuestions: number;
|
||||
totalCategories: number;
|
||||
mostPopularCategories: PopularCategory[];
|
||||
averageQuizScore: number;
|
||||
userGrowth: UserGrowthData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Popular Category
|
||||
*/
|
||||
export interface PopularCategory {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
quizzesTaken: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Growth Data
|
||||
*/
|
||||
export interface UserGrowthData {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Users List Response
|
||||
*/
|
||||
export interface AdminUsersResponse {
|
||||
success: boolean;
|
||||
users: User[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin User Details
|
||||
*/
|
||||
export interface AdminUserDetails extends User {
|
||||
quizHistory?: QuizSession[];
|
||||
activityTimeline?: ActivityEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity Event
|
||||
*/
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
type: 'quiz_completed' | 'achievement_earned' | 'profile_updated';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
104
frontend/src/app/core/models/guest.model.ts
Normal file
104
frontend/src/app/core/models/guest.model.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Guest Session Interface
|
||||
* Represents a temporary guest user session
|
||||
*/
|
||||
export interface GuestSession {
|
||||
guestId: string;
|
||||
sessionToken: string;
|
||||
deviceId?: string;
|
||||
quizzesTaken: number;
|
||||
maxQuizzes: number;
|
||||
remainingQuizzes: number;
|
||||
expiresAt: string;
|
||||
isConverted: boolean;
|
||||
convertedUserId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Session Start Response
|
||||
*/
|
||||
export interface GuestSessionStartResponse {
|
||||
success: boolean;
|
||||
sessionToken: string;
|
||||
guestId: string;
|
||||
expiresAt: string;
|
||||
maxQuizzes: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Quiz Limit Response
|
||||
*/
|
||||
export interface GuestQuizLimitResponse {
|
||||
success: boolean;
|
||||
remainingQuizzes: number;
|
||||
maxQuizzes: number;
|
||||
quizzesTaken: number;
|
||||
expiresAt: string;
|
||||
upgradePrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest to User Conversion Request
|
||||
*/
|
||||
export interface GuestConversionRequest {
|
||||
guestSessionId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Settings (Admin)
|
||||
*/
|
||||
export interface GuestSettings {
|
||||
id: string;
|
||||
guestAccessEnabled: boolean;
|
||||
maxQuizzesPerDay: number;
|
||||
maxQuestionsPerQuiz: number;
|
||||
sessionExpiryHours: number;
|
||||
upgradePromptMessage: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Analytics (Admin)
|
||||
*/
|
||||
export interface GuestAnalytics {
|
||||
totalGuestSessions: number;
|
||||
activeGuestSessions: number;
|
||||
guestToUserConversionRate: number;
|
||||
averageQuizzesPerGuest: number;
|
||||
totalGuestQuizzes: number;
|
||||
conversionFunnel?: {
|
||||
totalSessions: number;
|
||||
startedQuiz: number;
|
||||
completedQuiz: number;
|
||||
converted: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Quiz Limit
|
||||
* Tracks remaining quiz attempts for guest
|
||||
*/
|
||||
export interface GuestLimit {
|
||||
maxQuizzes: number;
|
||||
quizzesTaken: number;
|
||||
quizzesRemaining: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest State (for signal management)
|
||||
*/
|
||||
export interface GuestState {
|
||||
session: GuestSession | null;
|
||||
isGuest: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
quizLimit: GuestLimit | null;
|
||||
}
|
||||
90
frontend/src/app/core/models/index.ts
Normal file
90
frontend/src/app/core/models/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* API Response Wrapper
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Error Response
|
||||
*/
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Error
|
||||
*/
|
||||
export interface HttpError {
|
||||
status: number;
|
||||
statusText: string;
|
||||
message: string;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading State
|
||||
*/
|
||||
export interface LoadingState {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Notification
|
||||
*/
|
||||
export interface ToastNotification {
|
||||
id?: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Action
|
||||
*/
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort Options
|
||||
*/
|
||||
export interface SortOptions {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter Options
|
||||
*/
|
||||
export interface FilterOptions {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Options
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
query: string;
|
||||
filters?: FilterOptions;
|
||||
sort?: SortOptions;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Export all models
|
||||
export * from './user.model';
|
||||
export * from './category.model';
|
||||
export * from './question.model';
|
||||
export * from './quiz.model';
|
||||
export * from './guest.model';
|
||||
export * from './dashboard.model';
|
||||
71
frontend/src/app/core/models/question.model.ts
Normal file
71
frontend/src/app/core/models/question.model.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { QuestionType, Difficulty } from './category.model';
|
||||
|
||||
/**
|
||||
* Question Interface
|
||||
* Represents a quiz question
|
||||
*/
|
||||
export interface Question {
|
||||
id: string;
|
||||
questionText: string;
|
||||
questionType: QuestionType;
|
||||
difficulty: Difficulty;
|
||||
categoryId: string;
|
||||
categoryName?: string;
|
||||
options?: string[]; // For multiple choice
|
||||
correctAnswer: string | string[];
|
||||
explanation: string;
|
||||
points: number;
|
||||
timeLimit?: number; // in seconds
|
||||
tags?: string[];
|
||||
keywords?: string[];
|
||||
isActive: boolean;
|
||||
isPublic: boolean;
|
||||
timesAttempted?: number;
|
||||
timesCorrect?: number;
|
||||
accuracy?: number;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Question Create/Update Request
|
||||
*/
|
||||
export interface QuestionFormData {
|
||||
questionText: string;
|
||||
questionType: QuestionType;
|
||||
difficulty: Difficulty;
|
||||
categoryId: string;
|
||||
options?: string[];
|
||||
correctAnswer: string | string[];
|
||||
explanation: string;
|
||||
points?: number;
|
||||
timeLimit?: number;
|
||||
tags?: string[];
|
||||
keywords?: string[];
|
||||
isPublic: boolean;
|
||||
isGuestAccessible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Question Search Filters
|
||||
*/
|
||||
export interface QuestionSearchFilters {
|
||||
q?: string; // search query
|
||||
category?: string;
|
||||
difficulty?: Difficulty;
|
||||
questionType?: QuestionType;
|
||||
isPublic?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Question Search Response
|
||||
*/
|
||||
export interface QuestionSearchResponse {
|
||||
results: Question[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
134
frontend/src/app/core/models/quiz.model.ts
Normal file
134
frontend/src/app/core/models/quiz.model.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Question } from './question.model';
|
||||
|
||||
/**
|
||||
* Quiz Session Interface
|
||||
* Represents an active or completed quiz session
|
||||
*/
|
||||
export interface QuizSession {
|
||||
id: string;
|
||||
userId?: string;
|
||||
guestSessionId?: string;
|
||||
categoryId: string;
|
||||
categoryName?: string;
|
||||
quizType: QuizType;
|
||||
difficulty: string;
|
||||
totalQuestions: number;
|
||||
currentQuestionIndex: number;
|
||||
score: number;
|
||||
correctAnswers: number;
|
||||
incorrectAnswers: number;
|
||||
skippedAnswers: number;
|
||||
status: QuizStatus;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
timeSpent?: number; // in seconds
|
||||
isPassed?: boolean;
|
||||
passingScore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Types
|
||||
*/
|
||||
export type QuizType = 'practice' | 'timed' | 'exam';
|
||||
|
||||
/**
|
||||
* Quiz Status
|
||||
*/
|
||||
export type QuizStatus = 'in_progress' | 'completed' | 'abandoned';
|
||||
|
||||
/**
|
||||
* Quiz Start Request
|
||||
*/
|
||||
export interface QuizStartRequest {
|
||||
categoryId: string;
|
||||
questionCount: number;
|
||||
difficulty?: string; // 'easy', 'medium', 'hard', 'mixed'
|
||||
quizType?: QuizType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Start Response
|
||||
*/
|
||||
export interface QuizStartResponse {
|
||||
success: boolean;
|
||||
sessionId: string;
|
||||
questions: Question[];
|
||||
totalQuestions: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Answer Submission
|
||||
*/
|
||||
export interface QuizAnswerSubmission {
|
||||
questionId: string;
|
||||
answer: string | string[];
|
||||
quizSessionId: string;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Answer Response
|
||||
*/
|
||||
export interface QuizAnswerResponse {
|
||||
success: boolean;
|
||||
isCorrect: boolean;
|
||||
correctAnswer: string | string[];
|
||||
explanation: string;
|
||||
points: number;
|
||||
score: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Results
|
||||
*/
|
||||
export interface QuizResults {
|
||||
success: boolean;
|
||||
score: number;
|
||||
totalQuestions: number;
|
||||
correctAnswers: number;
|
||||
incorrectAnswers: number;
|
||||
skippedAnswers: number;
|
||||
percentage: number;
|
||||
timeSpent: number;
|
||||
isPassed: boolean;
|
||||
performanceMessage: string;
|
||||
questions: QuizQuestionResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Question Result
|
||||
*/
|
||||
export interface QuizQuestionResult {
|
||||
questionId: string;
|
||||
questionText: string;
|
||||
questionType: string;
|
||||
userAnswer: string | string[];
|
||||
correctAnswer: string | string[];
|
||||
isCorrect: boolean;
|
||||
explanation: string;
|
||||
points: number;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Session State (for signal management)
|
||||
*/
|
||||
export interface QuizSessionState {
|
||||
session: QuizSession | null;
|
||||
questions: Question[];
|
||||
currentQuestionIndex: number;
|
||||
answers: Map<string, QuizAnswerResponse>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz Review Response
|
||||
*/
|
||||
export interface QuizReviewResponse {
|
||||
success: boolean;
|
||||
session: QuizSession;
|
||||
questions: QuizQuestionResult[];
|
||||
}
|
||||
61
frontend/src/app/core/models/user.model.ts
Normal file
61
frontend/src/app/core/models/user.model.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* User Interface
|
||||
* Represents a registered user in the system
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
isActive: boolean;
|
||||
totalQuizzesTaken?: number;
|
||||
totalQuestionsAnswered?: number;
|
||||
totalCorrectAnswers?: number;
|
||||
currentStreak?: number;
|
||||
longestStreak?: number;
|
||||
averageScore?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Registration Request
|
||||
*/
|
||||
export interface UserRegistration {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
guestSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Login Request
|
||||
*/
|
||||
export interface UserLogin {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth Response
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
user: User;
|
||||
message?: string;
|
||||
migratedStats?: {
|
||||
quizzesTaken: number;
|
||||
score: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth State (for signal management)
|
||||
*/
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
272
frontend/src/app/core/services/auth.service.ts
Normal file
272
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError, tap, catchError } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment.development';
|
||||
import { StorageService } from './storage.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import {
|
||||
User,
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
AuthResponse,
|
||||
AuthState
|
||||
} from '../models/user.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private storageService = inject(StorageService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
|
||||
private readonly API_URL = `${environment.apiUrl}/auth`;
|
||||
|
||||
// Auth state signal
|
||||
private authStateSignal = signal<AuthState>({
|
||||
user: this.storageService.getUserData(),
|
||||
isAuthenticated: this.storageService.isAuthenticated(),
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Public readonly auth state
|
||||
public readonly authState = this.authStateSignal.asReadonly();
|
||||
|
||||
/**
|
||||
* Register a new user account
|
||||
* Handles guest-to-user conversion if guestSessionId provided
|
||||
*/
|
||||
register(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
guestSessionId?: string
|
||||
): Observable<AuthResponse> {
|
||||
this.setLoading(true);
|
||||
|
||||
const registrationData: UserRegistration = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
guestSessionId
|
||||
};
|
||||
|
||||
return this.http.post<AuthResponse>(`${this.API_URL}/register`, registrationData).pipe(
|
||||
tap((response) => {
|
||||
// Store token and user data
|
||||
this.storageService.setToken(response.token, true); // Remember me by default
|
||||
this.storageService.setUserData(response.user);
|
||||
|
||||
// Clear guest token if converting
|
||||
if (guestSessionId) {
|
||||
this.storageService.clearGuestToken();
|
||||
}
|
||||
|
||||
// Update auth state
|
||||
this.updateAuthState(response.user, null);
|
||||
|
||||
// Show success message
|
||||
const message = response.migratedStats
|
||||
? `Welcome ${response.user.username}! Your guest progress has been saved.`
|
||||
: `Welcome ${response.user.username}! Your account has been created.`;
|
||||
this.toastService.success(message);
|
||||
|
||||
// Auto-login: redirect to dashboard
|
||||
this.router.navigate(['/dashboard']);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.handleAuthError(error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
login(email: string, password: string, rememberMe: boolean = false, redirectUrl: string = '/dashboard'): Observable<AuthResponse> {
|
||||
this.setLoading(true);
|
||||
|
||||
const loginData: UserLogin = { email, password };
|
||||
|
||||
return this.http.post<AuthResponse>(`${this.API_URL}/login`, loginData).pipe(
|
||||
tap((response) => {
|
||||
// Store token and user data
|
||||
this.storageService.setToken(response.token, rememberMe);
|
||||
this.storageService.setUserData(response.user);
|
||||
|
||||
// Clear guest token
|
||||
this.storageService.clearGuestToken();
|
||||
|
||||
// Update auth state
|
||||
this.updateAuthState(response.user, null);
|
||||
|
||||
// Show success message
|
||||
this.toastService.success(`Welcome back, ${response.user.username}!`);
|
||||
|
||||
// Redirect to requested URL
|
||||
this.router.navigate([redirectUrl]);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.handleAuthError(error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
logout(): Observable<void> {
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.post<void>(`${this.API_URL}/logout`, {}).pipe(
|
||||
tap(() => {
|
||||
// Clear all auth data
|
||||
this.storageService.clearAll();
|
||||
|
||||
// Reset auth state
|
||||
this.authStateSignal.set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Show success message
|
||||
this.toastService.success('You have been logged out successfully.');
|
||||
|
||||
// Redirect to login
|
||||
this.router.navigate(['/login']);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
// Even if logout fails on server, clear local data
|
||||
this.storageService.clearAll();
|
||||
this.authStateSignal.set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
this.router.navigate(['/login']);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token validity
|
||||
*/
|
||||
verifyToken(): Observable<{ valid: boolean; user?: User }> {
|
||||
const token = this.storageService.getToken();
|
||||
|
||||
if (!token) {
|
||||
this.authStateSignal.update(state => ({
|
||||
...state,
|
||||
isAuthenticated: false,
|
||||
user: null
|
||||
}));
|
||||
return throwError(() => new Error('No token found'));
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.get<{ valid: boolean; user?: User }>(`${this.API_URL}/verify`).pipe(
|
||||
tap((response) => {
|
||||
if (response.valid && response.user) {
|
||||
// Update user data
|
||||
this.storageService.setUserData(response.user);
|
||||
this.updateAuthState(response.user, null);
|
||||
} else {
|
||||
// Token invalid, clear auth
|
||||
this.clearAuth();
|
||||
}
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
// Token expired or invalid
|
||||
this.clearAuth();
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
private clearAuth(): void {
|
||||
this.storageService.clearToken();
|
||||
this.storageService.clearUserData();
|
||||
this.authStateSignal.set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auth state signal
|
||||
*/
|
||||
private updateAuthState(user: User | null, error: string | null): void {
|
||||
this.authStateSignal.set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
private setLoading(isLoading: boolean): void {
|
||||
this.authStateSignal.update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication errors
|
||||
*/
|
||||
private handleAuthError(error: HttpErrorResponse): void {
|
||||
let errorMessage = 'An error occurred. Please try again.';
|
||||
|
||||
if (error.status === 400) {
|
||||
errorMessage = 'Invalid input. Please check your information.';
|
||||
} else if (error.status === 401) {
|
||||
errorMessage = 'Invalid email or password.';
|
||||
} else if (error.status === 409) {
|
||||
errorMessage = error.error?.message || 'Email or username already exists.';
|
||||
} else if (error.status === 429) {
|
||||
errorMessage = 'Too many attempts. Please try again later.';
|
||||
} else if (error.status === 0) {
|
||||
errorMessage = 'Unable to connect to server. Please check your internet connection.';
|
||||
}
|
||||
|
||||
this.updateAuthState(null, errorMessage);
|
||||
this.toastService.error(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
getCurrentUser(): User | null {
|
||||
return this.authStateSignal().user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.authStateSignal().isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*/
|
||||
isAdmin(): boolean {
|
||||
const user = this.getCurrentUser();
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
}
|
||||
271
frontend/src/app/core/services/guest.service.ts
Normal file
271
frontend/src/app/core/services/guest.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError, tap, catchError } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment.development';
|
||||
import { StorageService } from './storage.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import { GuestSession, GuestState, GuestLimit } from '../models/guest.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GuestService {
|
||||
private http = inject(HttpClient);
|
||||
private storageService = inject(StorageService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
|
||||
private readonly API_URL = `${environment.apiUrl}/guest`;
|
||||
private readonly GUEST_TOKEN_KEY = 'guest_token';
|
||||
private readonly GUEST_ID_KEY = 'guest_id';
|
||||
private readonly DEVICE_ID_KEY = 'device_id';
|
||||
private readonly SESSION_EXPIRY_HOURS = 24;
|
||||
|
||||
// Guest state signal
|
||||
private guestStateSignal = signal<GuestState>({
|
||||
session: null,
|
||||
isGuest: this.hasActiveGuestSession(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
quizLimit: null
|
||||
});
|
||||
|
||||
// Public readonly guest state
|
||||
public readonly guestState = this.guestStateSignal.asReadonly();
|
||||
|
||||
/**
|
||||
* Start a new guest session
|
||||
* Generates device ID and creates session on backend
|
||||
*/
|
||||
startSession(): Observable<GuestSession> {
|
||||
this.setLoading(true);
|
||||
|
||||
const deviceId = this.getOrCreateDeviceId();
|
||||
|
||||
return this.http.post<GuestSession>(`${this.API_URL}/start-session`, { deviceId }).pipe(
|
||||
tap((session: GuestSession) => {
|
||||
// Store guest session data
|
||||
this.storageService.setItem(this.GUEST_TOKEN_KEY, session.sessionToken);
|
||||
this.storageService.setItem(this.GUEST_ID_KEY, session.guestId);
|
||||
|
||||
// Update guest state
|
||||
this.guestStateSignal.update(state => ({
|
||||
...state,
|
||||
session,
|
||||
isGuest: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
|
||||
this.toastService.success('Welcome! You\'re browsing as a guest.');
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.setError('Failed to start guest session');
|
||||
this.toastService.error('Unable to start guest session. Please try again.');
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guest session details
|
||||
*/
|
||||
getSession(guestId: string): Observable<GuestSession> {
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.get<GuestSession>(`${this.API_URL}/session/${guestId}`).pipe(
|
||||
tap((session: GuestSession) => {
|
||||
this.guestStateSignal.update(state => ({
|
||||
...state,
|
||||
session,
|
||||
isGuest: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 404) {
|
||||
this.clearGuestSession();
|
||||
this.toastService.warning('Guest session expired. Please start a new session.');
|
||||
} else {
|
||||
this.setError('Failed to fetch guest session');
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining quiz attempts for guest
|
||||
*/
|
||||
getQuizLimit(): Observable<GuestLimit> {
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.get<GuestLimit>(`${this.API_URL}/quiz-limit`).pipe(
|
||||
tap((limit: GuestLimit) => {
|
||||
this.guestStateSignal.update(state => ({
|
||||
...state,
|
||||
quizLimit: limit,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}));
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.setError('Failed to fetch quiz limit');
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert guest session to registered user
|
||||
* Called during registration process
|
||||
*/
|
||||
convertToUser(guestSessionId: string, userData: any): Observable<any> {
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.post(`${this.API_URL}/convert`, {
|
||||
guestSessionId,
|
||||
...userData
|
||||
}).pipe(
|
||||
tap(() => {
|
||||
// Clear guest session data
|
||||
this.clearGuestSession();
|
||||
this.toastService.success('Guest data successfully migrated to your account!');
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.setError('Failed to convert guest session');
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or retrieve device ID
|
||||
* Used for fingerprinting guest sessions
|
||||
*/
|
||||
private getOrCreateDeviceId(): string {
|
||||
let deviceId = this.storageService.getItem(this.DEVICE_ID_KEY);
|
||||
|
||||
if (!deviceId) {
|
||||
// Generate UUID v4
|
||||
deviceId = this.generateUUID();
|
||||
this.storageService.setItem(this.DEVICE_ID_KEY, deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID v4
|
||||
*/
|
||||
private generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has an active guest session
|
||||
*/
|
||||
private hasActiveGuestSession(): boolean {
|
||||
const token = this.storageService.getItem(this.GUEST_TOKEN_KEY);
|
||||
const guestId = this.storageService.getItem(this.GUEST_ID_KEY);
|
||||
return !!(token && guestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored guest token
|
||||
*/
|
||||
getGuestToken(): string | null {
|
||||
return this.storageService.getItem(this.GUEST_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored guest ID
|
||||
*/
|
||||
getGuestId(): string | null {
|
||||
return this.storageService.getItem(this.GUEST_ID_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is expired (24 hours)
|
||||
*/
|
||||
isSessionExpired(): boolean {
|
||||
const session = this.guestState().session;
|
||||
if (!session) return true;
|
||||
|
||||
const createdAt = new Date(session.createdAt);
|
||||
const now = new Date();
|
||||
const hoursDiff = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
return hoursDiff >= this.SESSION_EXPIRY_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear guest session data
|
||||
*/
|
||||
clearGuestSession(): void {
|
||||
this.storageService.removeItem(this.GUEST_TOKEN_KEY);
|
||||
this.storageService.removeItem(this.GUEST_ID_KEY);
|
||||
|
||||
this.guestStateSignal.update(state => ({
|
||||
...state,
|
||||
session: null,
|
||||
isGuest: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
quizLimit: null
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
private setLoading(isLoading: boolean): void {
|
||||
this.guestStateSignal.update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state
|
||||
*/
|
||||
private setError(error: string): void {
|
||||
this.guestStateSignal.update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if guest has reached quiz limit
|
||||
*/
|
||||
hasReachedQuizLimit(): boolean {
|
||||
const limit = this.guestState().quizLimit;
|
||||
if (!limit) return false;
|
||||
return limit.quizzesRemaining <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time remaining until session expires
|
||||
*/
|
||||
getTimeRemaining(): string {
|
||||
const session = this.guestState().session;
|
||||
if (!session) return '0h 0m';
|
||||
|
||||
const createdAt = new Date(session.createdAt);
|
||||
const expiryTime = new Date(createdAt.getTime() + (this.SESSION_EXPIRY_HOURS * 60 * 60 * 1000));
|
||||
const now = new Date();
|
||||
const diff = expiryTime.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return '0h 0m';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
6
frontend/src/app/core/services/index.ts
Normal file
6
frontend/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './storage.service';
|
||||
export * from './toast.service';
|
||||
export * from './state.service';
|
||||
export * from './loading.service';
|
||||
export * from './theme.service';
|
||||
export * from './auth.service';
|
||||
58
frontend/src/app/core/services/loading.service.ts
Normal file
58
frontend/src/app/core/services/loading.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable, signal, Signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Loading Service
|
||||
* Manages global loading state using signals
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LoadingService {
|
||||
private loadingSignal = signal<boolean>(false);
|
||||
private loadingMessageSignal = signal<string>('');
|
||||
private loadingCountSignal = signal<number>(0);
|
||||
|
||||
public readonly isLoading: Signal<boolean> = this.loadingSignal.asReadonly();
|
||||
public readonly loadingMessage: Signal<string> = this.loadingMessageSignal.asReadonly();
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Start loading
|
||||
*/
|
||||
start(message: string = 'Loading...'): void {
|
||||
this.loadingCountSignal.update(count => count + 1);
|
||||
this.loadingMessageSignal.set(message);
|
||||
this.loadingSignal.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop loading
|
||||
*/
|
||||
stop(): void {
|
||||
this.loadingCountSignal.update(count => {
|
||||
const newCount = Math.max(0, count - 1);
|
||||
if (newCount === 0) {
|
||||
this.loadingSignal.set(false);
|
||||
this.loadingMessageSignal.set('');
|
||||
}
|
||||
return newCount;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force stop all loading
|
||||
*/
|
||||
stopAll(): void {
|
||||
this.loadingCountSignal.set(0);
|
||||
this.loadingSignal.set(false);
|
||||
this.loadingMessageSignal.set('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading
|
||||
*/
|
||||
getLoadingState(): boolean {
|
||||
return this.loadingSignal();
|
||||
}
|
||||
}
|
||||
102
frontend/src/app/core/services/state.service.ts
Normal file
102
frontend/src/app/core/services/state.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable, signal, Signal, WritableSignal, effect } from '@angular/core';
|
||||
|
||||
/**
|
||||
* State Management Utility
|
||||
* Provides signal-based state management with persistence and computed values
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StateService {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Create a signal with localStorage persistence
|
||||
*/
|
||||
createPersistedSignal<T>(key: string, initialValue: T): WritableSignal<T> {
|
||||
// Try to load from localStorage
|
||||
const stored = localStorage.getItem(key);
|
||||
const value = stored ? JSON.parse(stored) : initialValue;
|
||||
|
||||
// Create signal
|
||||
const stateSignal = signal<T>(value);
|
||||
|
||||
// Persist changes to localStorage
|
||||
effect(() => {
|
||||
const currentValue = stateSignal();
|
||||
localStorage.setItem(key, JSON.stringify(currentValue));
|
||||
});
|
||||
|
||||
return stateSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signal with sessionStorage persistence
|
||||
*/
|
||||
createSessionSignal<T>(key: string, initialValue: T): WritableSignal<T> {
|
||||
// Try to load from sessionStorage
|
||||
const stored = sessionStorage.getItem(key);
|
||||
const value = stored ? JSON.parse(stored) : initialValue;
|
||||
|
||||
// Create signal
|
||||
const stateSignal = signal<T>(value);
|
||||
|
||||
// Persist changes to sessionStorage
|
||||
effect(() => {
|
||||
const currentValue = stateSignal();
|
||||
sessionStorage.setItem(key, JSON.stringify(currentValue));
|
||||
});
|
||||
|
||||
return stateSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loading state signal
|
||||
*/
|
||||
createLoadingSignal(): WritableSignal<boolean> {
|
||||
return signal(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error state signal
|
||||
*/
|
||||
createErrorSignal(): WritableSignal<string | null> {
|
||||
return signal<string | null>(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted state
|
||||
*/
|
||||
clearPersistedState(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading State Interface
|
||||
*/
|
||||
export interface LoadingState {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete state object with loading and error
|
||||
*/
|
||||
export interface CompleteState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signal for complete state management
|
||||
*/
|
||||
export function createCompleteState<T>(initialData: T | null = null): WritableSignal<CompleteState<T>> {
|
||||
return signal<CompleteState<T>>({
|
||||
data: initialData,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
115
frontend/src/app/core/services/storage.service.ts
Normal file
115
frontend/src/app/core/services/storage.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Storage Service
|
||||
* Handles localStorage and sessionStorage operations
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StorageService {
|
||||
private readonly TOKEN_KEY = 'auth_token';
|
||||
private readonly GUEST_TOKEN_KEY = 'guest_token';
|
||||
private readonly USER_KEY = 'user_data';
|
||||
private readonly THEME_KEY = 'app_theme';
|
||||
private readonly REMEMBER_ME_KEY = 'remember_me';
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get item from storage (checks localStorage first, then sessionStorage)
|
||||
*/
|
||||
getItem(key: string): string | null {
|
||||
return localStorage.getItem(key) || sessionStorage.getItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in storage
|
||||
* Uses localStorage if rememberMe is true, otherwise sessionStorage
|
||||
*/
|
||||
setItem(key: string, value: string, persistent: boolean = true): void {
|
||||
if (persistent) {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
sessionStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Token Methods
|
||||
getToken(): string | null {
|
||||
return this.getItem(this.TOKEN_KEY);
|
||||
}
|
||||
|
||||
setToken(token: string, rememberMe: boolean = true): void {
|
||||
this.setItem(this.TOKEN_KEY, token, rememberMe);
|
||||
this.setItem(this.REMEMBER_ME_KEY, rememberMe.toString(), true);
|
||||
}
|
||||
|
||||
clearToken(): void {
|
||||
this.removeItem(this.TOKEN_KEY);
|
||||
}
|
||||
|
||||
// Guest Token Methods
|
||||
getGuestToken(): string | null {
|
||||
return this.getItem(this.GUEST_TOKEN_KEY);
|
||||
}
|
||||
|
||||
setGuestToken(token: string): void {
|
||||
this.setItem(this.GUEST_TOKEN_KEY, token, true);
|
||||
}
|
||||
|
||||
clearGuestToken(): void {
|
||||
this.removeItem(this.GUEST_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// User Data Methods
|
||||
getUserData(): any {
|
||||
const userData = this.getItem(this.USER_KEY);
|
||||
return userData ? JSON.parse(userData) : null;
|
||||
}
|
||||
|
||||
setUserData(user: any, rememberMe: boolean = true): void {
|
||||
this.setItem(this.USER_KEY, JSON.stringify(user), rememberMe);
|
||||
}
|
||||
|
||||
clearUserData(): void {
|
||||
this.removeItem(this.USER_KEY);
|
||||
}
|
||||
|
||||
// Theme Methods
|
||||
getTheme(): string {
|
||||
return this.getItem(this.THEME_KEY) || 'light';
|
||||
}
|
||||
|
||||
setTheme(theme: string): void {
|
||||
this.setItem(this.THEME_KEY, theme, true);
|
||||
}
|
||||
|
||||
// Remember Me
|
||||
getRememberMe(): boolean {
|
||||
return this.getItem(this.REMEMBER_ME_KEY) === 'true';
|
||||
}
|
||||
|
||||
// Clear All
|
||||
clearAll(): void {
|
||||
this.clearToken();
|
||||
this.clearGuestToken();
|
||||
this.clearUserData();
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken();
|
||||
}
|
||||
|
||||
// Check if user is guest
|
||||
isGuest(): boolean {
|
||||
return !this.getToken() && !!this.getGuestToken();
|
||||
}
|
||||
|
||||
// Remove a specific item from storage
|
||||
removeItem(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
122
frontend/src/app/core/services/theme.service.ts
Normal file
122
frontend/src/app/core/services/theme.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Injectable, signal, effect, inject } from '@angular/core';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
private readonly THEME_KEY = 'app-theme';
|
||||
private readonly storageService = inject(StorageService);
|
||||
private readonly themeSignal = signal<Theme>(this.getInitialTheme());
|
||||
|
||||
// Public readonly signal for theme state
|
||||
public readonly theme = this.themeSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
// Apply theme on initialization
|
||||
this.applyTheme(this.themeSignal());
|
||||
|
||||
// Watch for theme changes and persist
|
||||
effect(() => {
|
||||
const currentTheme = this.themeSignal();
|
||||
this.applyTheme(currentTheme);
|
||||
this.storageService.setTheme(currentTheme);
|
||||
});
|
||||
|
||||
// Listen for system theme preference changes
|
||||
this.watchSystemThemePreference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial theme from storage or system preference
|
||||
*/
|
||||
private getInitialTheme(): Theme {
|
||||
const storedTheme = this.storageService.getTheme();
|
||||
|
||||
if (storedTheme) {
|
||||
return storedTheme as Theme;
|
||||
}
|
||||
|
||||
// Detect system preference
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return 'light'; // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to document body
|
||||
*/
|
||||
private applyTheme(theme: Theme): void {
|
||||
if (typeof document !== 'undefined') {
|
||||
const body = document.body;
|
||||
|
||||
if (theme === 'dark') {
|
||||
body.classList.add('dark-theme');
|
||||
body.classList.remove('light-theme');
|
||||
} else {
|
||||
body.classList.add('light-theme');
|
||||
body.classList.remove('dark-theme');
|
||||
}
|
||||
|
||||
// Update color-scheme meta tag for better browser integration
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for system theme preference changes
|
||||
*/
|
||||
private watchSystemThemePreference(): void {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// Only auto-update if user hasn't explicitly set a theme
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
const storedTheme = this.storageService.getTheme();
|
||||
if (!storedTheme) {
|
||||
this.setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme explicitly
|
||||
*/
|
||||
public setTheme(theme: Theme): void {
|
||||
this.themeSignal.set(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
public toggleTheme(): void {
|
||||
const currentTheme = this.themeSignal();
|
||||
this.setTheme(currentTheme === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current theme is dark
|
||||
*/
|
||||
public isDarkMode(): boolean {
|
||||
return this.themeSignal() === 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset theme to system preference
|
||||
*/
|
||||
public resetToSystemPreference(): void {
|
||||
localStorage.removeItem(this.THEME_KEY);
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
this.setTheme(isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
this.setTheme('light');
|
||||
}
|
||||
}
|
||||
}
|
||||
127
frontend/src/app/core/services/toast.service.ts
Normal file
127
frontend/src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Injectable, signal, Signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Toast Notification Interface
|
||||
*/
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
action?: {
|
||||
label: string;
|
||||
callback: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Service
|
||||
* Manages toast notifications using signals
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ToastService {
|
||||
private toastsSignal = signal<Toast[]>([]);
|
||||
public readonly toasts: Signal<Toast[]> = this.toastsSignal.asReadonly();
|
||||
|
||||
private defaultDuration = 5000;
|
||||
private toastIdCounter = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Show success toast
|
||||
*/
|
||||
success(message: string, duration?: number): void {
|
||||
this.show({
|
||||
type: 'success',
|
||||
message,
|
||||
duration: duration || this.defaultDuration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast
|
||||
*/
|
||||
error(message: string, duration?: number): void {
|
||||
this.show({
|
||||
type: 'error',
|
||||
message,
|
||||
duration: duration || this.defaultDuration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast
|
||||
*/
|
||||
warning(message: string, duration?: number): void {
|
||||
this.show({
|
||||
type: 'warning',
|
||||
message,
|
||||
duration: duration || this.defaultDuration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast
|
||||
*/
|
||||
info(message: string, duration?: number): void {
|
||||
this.show({
|
||||
type: 'info',
|
||||
message,
|
||||
duration: duration || this.defaultDuration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast with action button
|
||||
*/
|
||||
showWithAction(
|
||||
message: string,
|
||||
actionLabel: string,
|
||||
actionCallback: () => void,
|
||||
type: 'success' | 'error' | 'warning' | 'info' = 'info',
|
||||
duration?: number
|
||||
): void {
|
||||
this.show({
|
||||
type,
|
||||
message,
|
||||
duration: duration || 10000, // Longer duration for actionable toasts
|
||||
action: {
|
||||
label: actionLabel,
|
||||
callback: actionCallback
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast
|
||||
*/
|
||||
private show(toast: Omit<Toast, 'id'>): void {
|
||||
const id = `toast-${++this.toastIdCounter}`;
|
||||
const newToast: Toast = { ...toast, id };
|
||||
|
||||
// Add toast to the signal
|
||||
this.toastsSignal.update(toasts => [...toasts, newToast]);
|
||||
|
||||
// Auto-remove after duration
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
setTimeout(() => this.remove(id), toast.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove toast by ID
|
||||
*/
|
||||
remove(id: string): void {
|
||||
this.toastsSignal.update(toasts => toasts.filter(t => t.id !== id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all toasts
|
||||
*/
|
||||
removeAll(): void {
|
||||
this.toastsSignal.set([]);
|
||||
}
|
||||
}
|
||||
108
frontend/src/app/features/auth/login/login.html
Normal file
108
frontend/src/app/features/auth/login/login.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<div class="login-container">
|
||||
<mat-card class="login-card">
|
||||
<mat-card-header>
|
||||
<div class="header-content">
|
||||
<mat-icon class="logo-icon">quiz</mat-icon>
|
||||
<div>
|
||||
<mat-card-title>Welcome Back!</mat-card-title>
|
||||
<mat-card-subtitle>Login to continue your preparation</mat-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
|
||||
<!-- Email Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Email</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Enter your email"
|
||||
autocomplete="email">
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
@if (loginForm.get('email')?.invalid && loginForm.get('email')?.touched) {
|
||||
<mat-error>{{ getErrorMessage('email') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Password Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hidePassword() ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password">
|
||||
<mat-icon matPrefix>lock</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="togglePasswordVisibility()"
|
||||
[attr.aria-label]="'Toggle password visibility'">
|
||||
<mat-icon>{{ hidePassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
|
||||
<mat-error>{{ getErrorMessage('password') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="options-row">
|
||||
<mat-checkbox formControlName="rememberMe">
|
||||
Remember me
|
||||
</mat-checkbox>
|
||||
<a routerLink="/forgot-password" class="forgot-link">Forgot Password?</a>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="full-width submit-button"
|
||||
[disabled]="isSubmitting()">
|
||||
@if (isSubmitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
<span>Logging in...</span>
|
||||
} @else {
|
||||
<span>Login</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<mat-divider class="divider"></mat-divider>
|
||||
|
||||
<!-- Guest Option -->
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
class="full-width guest-button"
|
||||
(click)="continueAsGuest()"
|
||||
[disabled]="isStartingGuestSession()">
|
||||
@if (isStartingGuestSession()) {
|
||||
<ng-container>
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
<span>Starting Session...</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Continue as Guest</span>
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-footer>
|
||||
<div class="footer-links">
|
||||
<p>Don't have an account?
|
||||
<a routerLink="/register" class="link">Create one here</a>
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-footer>
|
||||
</mat-card>
|
||||
</div>
|
||||
217
frontend/src/app/features/auth/login/login.scss
Normal file
217
frontend/src/app/features/auth/login/login.scss
Normal file
@@ -0,0 +1,217 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
padding: var(--spacing-lg);
|
||||
background: linear-gradient(135deg,
|
||||
var(--color-primary-lighter) 0%,
|
||||
var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
|
||||
::ng-deep .mat-mdc-card-header {
|
||||
padding: var(--spacing-xl) var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-content {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-footer {
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
|
||||
.logo-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: var(--spacing-xs) 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Options Row (Remember Me & Forgot Password)
|
||||
.options-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: calc(var(--spacing-md) * -1);
|
||||
|
||||
.forgot-link {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
.submit-button {
|
||||
margin-top: var(--spacing-md);
|
||||
height: 48px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// Divider
|
||||
.divider {
|
||||
margin: var(--spacing-xl) 0 var(--spacing-lg);
|
||||
background-color: var(--color-divider);
|
||||
}
|
||||
|
||||
// Guest Button
|
||||
.guest-button {
|
||||
height: 48px;
|
||||
font-size: var(--font-size-base);
|
||||
|
||||
mat-icon {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Links
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
text-align: center;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form Field Customization
|
||||
::ng-deep .mat-mdc-form-field {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint,
|
||||
.mat-mdc-form-field-error {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Prefix Styling
|
||||
::ng-deep .mat-mdc-form-field-icon-prefix {
|
||||
color: var(--color-text-secondary);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
// Checkbox Styling
|
||||
::ng-deep .mat-mdc-checkbox {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 767px) {
|
||||
.login-container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
::ng-deep .mat-mdc-card-header {
|
||||
padding: var(--spacing-lg) var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-footer {
|
||||
padding: 0 var(--spacing-md) var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.options-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
152
frontend/src/app/features/auth/login/login.ts
Normal file
152
frontend/src/app/features/auth/login/login.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDividerModule
|
||||
],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss'
|
||||
})
|
||||
export class LoginComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private authService = inject(AuthService);
|
||||
private guestService = inject(GuestService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
// Signals
|
||||
isSubmitting = signal<boolean>(false);
|
||||
hidePassword = signal<boolean>(true);
|
||||
returnUrl = signal<string>('/dashboard');
|
||||
isStartingGuestSession = signal<boolean>(false);
|
||||
|
||||
// Form
|
||||
loginForm: FormGroup;
|
||||
|
||||
constructor() {
|
||||
// Initialize form
|
||||
this.loginForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||
rememberMe: [false]
|
||||
});
|
||||
|
||||
// Get return URL from query params
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.returnUrl.set(params['returnUrl'] || '/dashboard');
|
||||
});
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle password visibility
|
||||
*/
|
||||
togglePasswordVisibility(): void {
|
||||
this.hidePassword.update(val => !val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit login form
|
||||
*/
|
||||
onSubmit(): void {
|
||||
if (this.loginForm.invalid || this.isSubmitting()) {
|
||||
this.loginForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
|
||||
const { email, password, rememberMe } = this.loginForm.value;
|
||||
|
||||
this.authService.login(email, password, rememberMe, this.returnUrl()).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
// Navigation is handled by AuthService
|
||||
},
|
||||
error: () => {
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form control error message
|
||||
*/
|
||||
getErrorMessage(controlName: string): string {
|
||||
const control = this.loginForm.get(controlName);
|
||||
|
||||
if (!control || !control.touched) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (control.hasError('required')) {
|
||||
return `${this.getFieldLabel(controlName)} is required`;
|
||||
}
|
||||
|
||||
if (control.hasError('email')) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (control.hasError('minlength')) {
|
||||
const minLength = control.getError('minlength').requiredLength;
|
||||
return `Must be at least ${minLength} characters`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field label
|
||||
*/
|
||||
private getFieldLabel(controlName: string): string {
|
||||
const labels: { [key: string]: string } = {
|
||||
email: 'Email',
|
||||
password: 'Password'
|
||||
};
|
||||
return labels[controlName] || controlName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start guest session
|
||||
*/
|
||||
continueAsGuest(): void {
|
||||
this.isStartingGuestSession.set(true);
|
||||
this.guestService.startSession().subscribe({
|
||||
next: () => {
|
||||
this.isStartingGuestSession.set(false);
|
||||
this.router.navigate(['/categories']);
|
||||
},
|
||||
error: () => {
|
||||
this.isStartingGuestSession.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
145
frontend/src/app/features/auth/register/register.html
Normal file
145
frontend/src/app/features/auth/register/register.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<div class="register-container">
|
||||
<mat-card class="register-card">
|
||||
<mat-card-header>
|
||||
<div class="header-content">
|
||||
<mat-icon class="logo-icon">quiz</mat-icon>
|
||||
<div>
|
||||
<mat-card-title>Create Your Account</mat-card-title>
|
||||
<mat-card-subtitle>Start your interview preparation journey</mat-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()" class="register-form">
|
||||
<!-- Username Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="username"
|
||||
placeholder="Enter your username"
|
||||
autocomplete="username">
|
||||
<mat-icon matPrefix>person</mat-icon>
|
||||
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
|
||||
<mat-error>{{ getErrorMessage('username') }}</mat-error>
|
||||
}
|
||||
<mat-hint>3-30 characters, letters, numbers, and underscores only</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Email Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Email</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Enter your email"
|
||||
autocomplete="email">
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
|
||||
<mat-error>{{ getErrorMessage('email') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Password Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hidePassword() ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="new-password">
|
||||
<mat-icon matPrefix>lock</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="togglePasswordVisibility()"
|
||||
[attr.aria-label]="'Toggle password visibility'">
|
||||
<mat-icon>{{ hidePassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
|
||||
<mat-error>{{ getErrorMessage('password') }}</mat-error>
|
||||
}
|
||||
<mat-hint>Minimum 8 characters</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Password Strength Indicator -->
|
||||
@if (registerForm.get('password')?.value) {
|
||||
<div class="password-strength">
|
||||
<div class="strength-label">
|
||||
<span>Password Strength:</span>
|
||||
<span [class]="'strength-' + passwordStrength().color">
|
||||
{{ passwordStrength().label }}
|
||||
</span>
|
||||
</div>
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="passwordStrength().score"
|
||||
[color]="passwordStrength().color">
|
||||
</mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Confirm Password Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Confirm Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hideConfirmPassword() ? 'password' : 'text'"
|
||||
formControlName="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
autocomplete="new-password">
|
||||
<mat-icon matPrefix>lock</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="toggleConfirmPasswordVisibility()"
|
||||
[attr.aria-label]="'Toggle confirm password visibility'">
|
||||
<mat-icon>{{ hideConfirmPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (hasPasswordMismatch()) {
|
||||
<mat-error>Passwords do not match</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Guest Conversion Message -->
|
||||
@if (false) {
|
||||
<div class="guest-conversion-message">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>Your guest progress will be saved to this account!</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="full-width submit-button"
|
||||
[disabled]="isSubmitting()">
|
||||
@if (isSubmitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
<span>Creating Account...</span>
|
||||
} @else {
|
||||
<span>Create Account</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-footer>
|
||||
<div class="footer-links">
|
||||
<p>Already have an account?
|
||||
<a routerLink="/login" class="link">Login here</a>
|
||||
</p>
|
||||
<p>Or continue as
|
||||
<a routerLink="/" class="link">Guest</a>
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-footer>
|
||||
</mat-card>
|
||||
</div>
|
||||
224
frontend/src/app/features/auth/register/register.scss
Normal file
224
frontend/src/app/features/auth/register/register.scss
Normal file
@@ -0,0 +1,224 @@
|
||||
.register-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
padding: var(--spacing-lg);
|
||||
background: linear-gradient(135deg,
|
||||
var(--color-primary-lighter) 0%,
|
||||
var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
|
||||
::ng-deep .mat-mdc-card-header {
|
||||
padding: var(--spacing-xl) var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-content {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-footer {
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
|
||||
.logo-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: var(--spacing-xs) 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Password Strength Indicator
|
||||
.password-strength {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: calc(var(--spacing-md) * -1);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
|
||||
.strength-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
span:first-child {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&.strength-warn {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&.strength-accent {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&.strength-primary {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-progress-bar {
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
// Guest Conversion Message
|
||||
.guest-conversion-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-info-light);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-info-dark);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: calc(var(--spacing-md) * -1);
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
.submit-button {
|
||||
margin-top: var(--spacing-md);
|
||||
height: 48px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Links
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
text-align: center;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form Field Customization
|
||||
::ng-deep .mat-mdc-form-field {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint,
|
||||
.mat-mdc-form-field-error {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Prefix Styling
|
||||
::ng-deep .mat-mdc-form-field-icon-prefix {
|
||||
color: var(--color-text-secondary);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 767px) {
|
||||
.register-container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
::ng-deep .mat-mdc-card-header {
|
||||
padding: var(--spacing-lg) var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-footer {
|
||||
padding: 0 var(--spacing-md) var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
255
frontend/src/app/features/auth/register/register.ts
Normal file
255
frontend/src/app/features/auth/register/register.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './register.html',
|
||||
styleUrl: './register.scss'
|
||||
})
|
||||
export class RegisterComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private authService = inject(AuthService);
|
||||
private storageService = inject(StorageService);
|
||||
private router = inject(Router);
|
||||
|
||||
// Signals
|
||||
isSubmitting = signal<boolean>(false);
|
||||
hidePassword = signal<boolean>(true);
|
||||
hideConfirmPassword = signal<boolean>(true);
|
||||
|
||||
// Form
|
||||
registerForm: FormGroup;
|
||||
|
||||
// Password strength computed signal
|
||||
passwordStrength = computed(() => {
|
||||
const password = this.registerForm?.get('password')?.value || '';
|
||||
return this.calculatePasswordStrength(password);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Check if converting from guest
|
||||
const guestToken = this.storageService.getGuestToken();
|
||||
|
||||
// Initialize form
|
||||
this.registerForm = this.fb.group({
|
||||
username: ['', [
|
||||
Validators.required,
|
||||
Validators.minLength(3),
|
||||
Validators.maxLength(30),
|
||||
Validators.pattern(/^[a-zA-Z0-9_]+$/)
|
||||
]],
|
||||
email: ['', [
|
||||
Validators.required,
|
||||
Validators.email
|
||||
]],
|
||||
password: ['', [
|
||||
Validators.required,
|
||||
Validators.minLength(8),
|
||||
this.passwordStrengthValidator
|
||||
]],
|
||||
confirmPassword: ['', [Validators.required]]
|
||||
}, { validators: this.passwordMatchValidator });
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password strength validator
|
||||
*/
|
||||
private passwordStrengthValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const password = control.value;
|
||||
|
||||
if (!password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
const isValid = hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
|
||||
|
||||
return isValid ? null : { weakPassword: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Password match validator
|
||||
*/
|
||||
private passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
|
||||
const password = group.get('password')?.value;
|
||||
const confirmPassword = group.get('confirmPassword')?.value;
|
||||
|
||||
return password === confirmPassword ? null : { passwordMismatch: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate password strength
|
||||
*/
|
||||
private calculatePasswordStrength(password: string): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
} {
|
||||
if (!password) {
|
||||
return { score: 0, label: '', color: '' };
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Length
|
||||
if (password.length >= 8) score += 25;
|
||||
if (password.length >= 12) score += 25;
|
||||
|
||||
// Character types
|
||||
if (/[a-z]/.test(password)) score += 15;
|
||||
if (/[A-Z]/.test(password)) score += 15;
|
||||
if (/[0-9]/.test(password)) score += 10;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 10;
|
||||
|
||||
let label = '';
|
||||
let color = '';
|
||||
|
||||
if (score < 40) {
|
||||
label = 'Weak';
|
||||
color = 'warn';
|
||||
} else if (score < 70) {
|
||||
label = 'Fair';
|
||||
color = 'accent';
|
||||
} else if (score < 90) {
|
||||
label = 'Good';
|
||||
color = 'primary';
|
||||
} else {
|
||||
label = 'Strong';
|
||||
color = 'primary';
|
||||
}
|
||||
|
||||
return { score, label, color };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle password visibility
|
||||
*/
|
||||
togglePasswordVisibility(): void {
|
||||
this.hidePassword.update(val => !val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle confirm password visibility
|
||||
*/
|
||||
toggleConfirmPasswordVisibility(): void {
|
||||
this.hideConfirmPassword.update(val => !val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit registration form
|
||||
*/
|
||||
onSubmit(): void {
|
||||
if (this.registerForm.invalid || this.isSubmitting()) {
|
||||
this.registerForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
|
||||
const { username, email, password } = this.registerForm.value;
|
||||
const guestSessionId = this.storageService.getGuestToken() || undefined;
|
||||
|
||||
this.authService.register(username, email, password, guestSessionId).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
// Navigation handled by service
|
||||
},
|
||||
error: () => {
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form control error message
|
||||
*/
|
||||
getErrorMessage(controlName: string): string {
|
||||
const control = this.registerForm.get(controlName);
|
||||
|
||||
if (!control || !control.touched) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (control.hasError('required')) {
|
||||
return `${this.getFieldLabel(controlName)} is required`;
|
||||
}
|
||||
|
||||
if (control.hasError('email')) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (control.hasError('minlength')) {
|
||||
const minLength = control.getError('minlength').requiredLength;
|
||||
return `Must be at least ${minLength} characters`;
|
||||
}
|
||||
|
||||
if (control.hasError('maxlength')) {
|
||||
const maxLength = control.getError('maxlength').requiredLength;
|
||||
return `Must not exceed ${maxLength} characters`;
|
||||
}
|
||||
|
||||
if (control.hasError('pattern') && controlName === 'username') {
|
||||
return 'Username can only contain letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (control.hasError('weakPassword')) {
|
||||
return 'Password must include uppercase, lowercase, number, and special character';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field label
|
||||
*/
|
||||
private getFieldLabel(controlName: string): string {
|
||||
const labels: { [key: string]: string } = {
|
||||
username: 'Username',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password'
|
||||
};
|
||||
return labels[controlName] || controlName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form has password mismatch error
|
||||
*/
|
||||
hasPasswordMismatch(): boolean {
|
||||
const confirmControl = this.registerForm.get('confirmPassword');
|
||||
return !!confirmControl?.touched && this.registerForm.hasError('passwordMismatch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="app-loading-container">
|
||||
<div class="app-loading-content">
|
||||
<mat-icon class="app-logo">quiz</mat-icon>
|
||||
<h1>Interview Quiz</h1>
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
<p>Loading application...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
.app-loading-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-color);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.app-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
|
||||
.app-logo {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--primary-color);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './app-loading.html',
|
||||
styleUrl: './app-loading.scss'
|
||||
})
|
||||
export class AppLoadingComponent {
|
||||
// Component for displaying app initialization loading state
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<h2 mat-dialog-title>
|
||||
@if (data.icon) {
|
||||
<mat-icon>{{ data.icon }}</mat-icon>
|
||||
}
|
||||
{{ data.title }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
(click)="onCancel()">
|
||||
{{ data.cancelText || 'Cancel' }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="data.confirmColor || 'primary'"
|
||||
(click)="onConfirm()">
|
||||
{{ data.confirmText || 'Confirm' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,28 @@
|
||||
h2[mat-dialog-title] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 1rem 0;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 1rem 0 0 0;
|
||||
margin: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmColor?: 'primary' | 'accent' | 'warn';
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './confirm-dialog.html',
|
||||
styleUrl: './confirm-dialog.scss'
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
data: ConfirmDialogData = inject(MAT_DIALOG_DATA);
|
||||
dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
|
||||
|
||||
/**
|
||||
* Confirm action
|
||||
*/
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel action
|
||||
*/
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<div class="error-boundary">
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<!-- Error Icon -->
|
||||
<div class="error-icon-container">
|
||||
<mat-icon class="error-icon">error</mat-icon>
|
||||
</div>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h2 class="error-title">{{ title() }}</h2>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="error-message">{{ message() }}</p>
|
||||
|
||||
<!-- Error Details (Collapsible) -->
|
||||
@if (showDetails() && error()) {
|
||||
<div class="error-details-container">
|
||||
<button
|
||||
mat-button
|
||||
class="details-toggle"
|
||||
(click)="toggleDetails()">
|
||||
<mat-icon>{{ detailsExpanded ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
<span>{{ detailsExpanded ? 'Hide' : 'Show' }} Technical Details</span>
|
||||
</button>
|
||||
|
||||
@if (detailsExpanded) {
|
||||
<div class="error-details">
|
||||
<div class="detail-item">
|
||||
<strong>Error Type:</strong>
|
||||
<span>{{ error()?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<strong>Message:</strong>
|
||||
<span>{{ error()?.message }}</span>
|
||||
</div>
|
||||
|
||||
@if (error()?.stack) {
|
||||
<div class="detail-item stack-trace">
|
||||
<strong>Stack Trace:</strong>
|
||||
<pre>{{ error()?.stack }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button mat-raised-button color="primary" (click)="onRetry()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
<span>Try Again</span>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button (click)="reloadPage()">
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
<span>Reload Page</span>
|
||||
</button>
|
||||
|
||||
<button mat-button (click)="onDismiss()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<span>Dismiss</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="help-text">
|
||||
If this problem persists, please
|
||||
<a href="/contact" class="contact-link">contact support</a>
|
||||
with the error details.
|
||||
</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,201 @@
|
||||
.error-boundary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Error Icon
|
||||
.error-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--color-error-light);
|
||||
border-radius: var(--radius-full);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// Error Title
|
||||
.error-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
.error-message {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
// Error Details
|
||||
.error-details-container {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.details-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin: 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-top: var(--spacing-md);
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
strong {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&.stack-trace {
|
||||
pre {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-background);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-md);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
min-width: 140px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// Help Text
|
||||
.help-text {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--spacing-md) 0 0 0;
|
||||
|
||||
.contact-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-boundary',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCardModule
|
||||
],
|
||||
templateUrl: './error-boundary.html',
|
||||
styleUrl: './error-boundary.scss'
|
||||
})
|
||||
export class ErrorBoundaryComponent {
|
||||
// Inputs
|
||||
error = input<Error | null>(null);
|
||||
title = input<string>('Something went wrong');
|
||||
message = input<string>('An unexpected error occurred. Please try again.');
|
||||
showDetails = input<boolean>(false);
|
||||
|
||||
// Outputs
|
||||
retry = output<void>();
|
||||
dismiss = output<void>();
|
||||
|
||||
detailsExpanded = false;
|
||||
|
||||
/**
|
||||
* Toggle error details visibility
|
||||
*/
|
||||
toggleDetails(): void {
|
||||
this.detailsExpanded = !this.detailsExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit retry event
|
||||
*/
|
||||
onRetry(): void {
|
||||
this.retry.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit dismiss event
|
||||
*/
|
||||
onDismiss(): void {
|
||||
this.dismiss.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the page
|
||||
*/
|
||||
reloadPage(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
86
frontend/src/app/shared/components/footer/footer.html
Normal file
86
frontend/src/app/shared/components/footer/footer.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<!-- Top Section -->
|
||||
<div class="footer-top">
|
||||
<!-- Brand Section -->
|
||||
<div class="footer-section brand-section">
|
||||
<div class="brand">
|
||||
<mat-icon class="brand-icon">quiz</mat-icon>
|
||||
<span class="brand-name">Interview Quiz</span>
|
||||
</div>
|
||||
<p class="brand-description">
|
||||
Master your interview skills with interactive quizzes and comprehensive practice tests.
|
||||
</p>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="social-links">
|
||||
@for (social of socialLinks; track social.label) {
|
||||
<a
|
||||
[href]="social.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="social.label"
|
||||
mat-icon-button>
|
||||
<mat-icon>{{ social.icon }}</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links Section -->
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-heading">Quick Links</h3>
|
||||
<nav class="footer-nav">
|
||||
<a routerLink="/categories" class="footer-link">Browse Categories</a>
|
||||
<a routerLink="/dashboard" class="footer-link">Dashboard</a>
|
||||
<a routerLink="/history" class="footer-link">Quiz History</a>
|
||||
<a routerLink="/bookmarks" class="footer-link">Bookmarks</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Resources Section -->
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-heading">Resources</h3>
|
||||
<nav class="footer-nav">
|
||||
<a routerLink="/about" class="footer-link">About Us</a>
|
||||
<a routerLink="/help" class="footer-link">Help Center</a>
|
||||
<a routerLink="/faq" class="footer-link">FAQ</a>
|
||||
<a routerLink="/contact" class="footer-link">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Legal Section -->
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-heading">Legal</h3>
|
||||
<nav class="footer-nav">
|
||||
<a routerLink="/privacy" class="footer-link">Privacy Policy</a>
|
||||
<a routerLink="/terms" class="footer-link">Terms of Service</a>
|
||||
<a routerLink="/cookies" class="footer-link">Cookie Policy</a>
|
||||
<a routerLink="/accessibility" class="footer-link">Accessibility</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider class="footer-divider"></mat-divider>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="footer-bottom">
|
||||
<div class="copyright">
|
||||
<p>© {{ currentYear }} Interview Quiz. All rights reserved.</p>
|
||||
<p class="version">Version {{ appVersion }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<nav class="footer-bottom-links">
|
||||
@for (link of footerLinks; track link.label; let last = $last) {
|
||||
<a [routerLink]="link.route" class="footer-bottom-link">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
@if (!last) {
|
||||
<span class="separator">•</span>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
201
frontend/src/app/shared/components/footer/footer.scss
Normal file
201
frontend/src/app/shared/components/footer/footer.scss
Normal file
@@ -0,0 +1,201 @@
|
||||
.footer {
|
||||
background-color: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: auto;
|
||||
padding: var(--spacing-2xl) 0 var(--spacing-lg);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
padding: var(--spacing-xl) 0 var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: var(--container-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
// Top Section
|
||||
.footer-top {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr repeat(3, 1fr);
|
||||
gap: var(--spacing-2xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
// Brand Section
|
||||
.brand-section {
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
|
||||
.brand-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-relaxed);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Social Links
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: var(--spacing-sm);
|
||||
|
||||
a {
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Headings
|
||||
.footer-heading {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
// Footer Navigation
|
||||
.footer-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), padding-left var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
padding-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// Divider
|
||||
.footer-divider {
|
||||
margin: var(--spacing-xl) 0;
|
||||
background-color: var(--color-divider);
|
||||
}
|
||||
|
||||
// Bottom Section
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom-link {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--color-text-disabled);
|
||||
user-select: none;
|
||||
}
|
||||
44
frontend/src/app/shared/components/footer/footer.ts
Normal file
44
frontend/src/app/shared/components/footer/footer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatDividerModule
|
||||
],
|
||||
templateUrl: './footer.html',
|
||||
styleUrl: './footer.scss'
|
||||
})
|
||||
export class FooterComponent {
|
||||
currentYear = new Date().getFullYear();
|
||||
appVersion = '1.0.0';
|
||||
|
||||
/**
|
||||
* Social media links
|
||||
*/
|
||||
socialLinks = [
|
||||
{ icon: 'public', label: 'Website', url: 'https://yourdomain.com' },
|
||||
{ icon: 'alternate_email', label: 'Twitter', url: 'https://twitter.com/yourapp' },
|
||||
{ icon: 'alternate_email', label: 'LinkedIn', url: 'https://linkedin.com/company/yourapp' },
|
||||
{ icon: 'code', label: 'GitHub', url: 'https://github.com/yourorg/yourapp' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Footer navigation links
|
||||
*/
|
||||
footerLinks = [
|
||||
{ label: 'About', route: '/about' },
|
||||
{ label: 'Help', route: '/help' },
|
||||
{ label: 'Privacy Policy', route: '/privacy' },
|
||||
{ label: 'Terms of Service', route: '/terms' },
|
||||
{ label: 'Contact', route: '/contact' }
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="guest-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-left">
|
||||
<mat-icon class="guest-icon">visibility</mat-icon>
|
||||
<div class="session-info">
|
||||
<span class="guest-label">Guest Mode</span>
|
||||
<div class="stats">
|
||||
@if (guestState().quizLimit) {
|
||||
<span class="quiz-count" [matTooltip]="quizText()">
|
||||
<mat-icon>quiz</mat-icon>
|
||||
{{ quizText() }}
|
||||
</span>
|
||||
}
|
||||
@if (timeRemaining) {
|
||||
<span class="time-remaining" matTooltip="Session expires after this time">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ timeRemaining }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="banner-right">
|
||||
<div class="upgrade-message">
|
||||
<mat-icon>stars</mat-icon>
|
||||
<span>Sign Up for Full Access</span>
|
||||
</div>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="signup-button"
|
||||
(click)="navigateToRegister()">
|
||||
Sign Up Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (guestState().quizLimit) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="quizProgress()"
|
||||
class="quiz-progress">
|
||||
</mat-progress-bar>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
.guest-banner {
|
||||
background: linear-gradient(90deg, var(--accent-color) 0%, var(--primary-color) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 64px; // Below header
|
||||
z-index: 100;
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
|
||||
.guest-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.guest-label {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.95;
|
||||
|
||||
.quiz-count,
|
||||
.time-remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.upgrade-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-button {
|
||||
background-color: white !important;
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-progress {
|
||||
height: 4px;
|
||||
|
||||
::ng-deep .mat-mdc-progress-bar-fill::after {
|
||||
background-color: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.guest-banner {
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
width: 100%;
|
||||
|
||||
.session-info .stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
|
||||
.upgrade-message {
|
||||
font-size: 0.875rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-button {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.guest-banner {
|
||||
.banner-right {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
.signup-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Component, inject, OnInit, OnDestroy, computed } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guest-banner',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
templateUrl: './guest-banner.html',
|
||||
styleUrl: './guest-banner.scss'
|
||||
})
|
||||
export class GuestBannerComponent implements OnInit, OnDestroy {
|
||||
private guestService = inject(GuestService);
|
||||
private router = inject(Router);
|
||||
|
||||
guestState = this.guestService.guestState;
|
||||
timeRemaining = '';
|
||||
private timerInterval?: number;
|
||||
|
||||
// Computed values
|
||||
quizProgress = computed(() => {
|
||||
const limit = this.guestState().quizLimit;
|
||||
if (!limit) return 0;
|
||||
return (limit.quizzesTaken / limit.maxQuizzes) * 100;
|
||||
});
|
||||
|
||||
quizText = computed(() => {
|
||||
const limit = this.guestState().quizLimit;
|
||||
if (!limit) return '';
|
||||
return `${limit.quizzesRemaining} of ${limit.maxQuizzes} quizzes remaining`;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Fetch quiz limit on initialization
|
||||
this.guestService.getQuizLimit().subscribe();
|
||||
|
||||
// Update time remaining every minute
|
||||
this.updateTimeRemaining();
|
||||
this.timerInterval = window.setInterval(() => {
|
||||
this.updateTimeRemaining();
|
||||
}, 60000); // Update every minute
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.timerInterval) {
|
||||
clearInterval(this.timerInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private updateTimeRemaining(): void {
|
||||
this.timeRemaining = this.guestService.getTimeRemaining();
|
||||
}
|
||||
|
||||
navigateToRegister(): void {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
|
||||
dismissBanner(): void {
|
||||
// Optional: Add logic to hide banner temporarily
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="limit-modal">
|
||||
<div class="modal-header">
|
||||
<div class="icon-container">
|
||||
<mat-icon class="limit-icon">block</mat-icon>
|
||||
</div>
|
||||
<h2 mat-dialog-title>Quiz Limit Reached</h2>
|
||||
<p class="subtitle">You've used all your guest quizzes for today!</p>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="upgrade-section">
|
||||
<h3>
|
||||
<mat-icon>stars</mat-icon>
|
||||
Unlock Full Access
|
||||
</h3>
|
||||
<p class="upgrade-message">
|
||||
Create a free account to get unlimited quizzes and much more!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-list class="benefits-list">
|
||||
@for (benefit of benefits; track benefit.text) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>{{ benefit.icon }}</mat-icon>
|
||||
<span matListItemTitle>{{ benefit.text }}</span>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="maybeLater()">
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="signup-cta"
|
||||
(click)="signUpNow()">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
Sign Up Now - It's Free!
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,130 @@
|
||||
.limit-modal {
|
||||
.modal-header {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1.5rem 1rem;
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.limit-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--warn-color);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 1.5rem !important;
|
||||
max-height: 500px;
|
||||
|
||||
.upgrade-section {
|
||||
background: linear-gradient(135deg, var(--primary-color-light) 0%, var(--accent-color-light) 100%);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
|
||||
mat-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-message {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
padding: 0;
|
||||
|
||||
mat-list-item {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.signup-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 480px) {
|
||||
.limit-modal {
|
||||
mat-dialog-actions {
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guest-limit-reached',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatListModule
|
||||
],
|
||||
templateUrl: './guest-limit-reached.html',
|
||||
styleUrl: './guest-limit-reached.scss'
|
||||
})
|
||||
export class GuestLimitReachedComponent {
|
||||
private dialogRef = inject(MatDialogRef<GuestLimitReachedComponent>);
|
||||
private router = inject(Router);
|
||||
|
||||
benefits = [
|
||||
{ icon: 'all_inclusive', text: 'Unlimited quizzes every day' },
|
||||
{ icon: 'lock_open', text: 'Access all categories and questions' },
|
||||
{ icon: 'trending_up', text: 'Track your progress over time' },
|
||||
{ icon: 'bookmark', text: 'Bookmark questions for later review' },
|
||||
{ icon: 'history', text: 'View complete quiz history' },
|
||||
{ icon: 'emoji_events', text: 'Earn achievements and badges' },
|
||||
{ icon: 'leaderboard', text: 'Compare scores with others' },
|
||||
{ icon: 'cloud', text: 'Sync across all your devices' }
|
||||
];
|
||||
|
||||
signUpNow(): void {
|
||||
this.dialogRef.close();
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
|
||||
maybeLater(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="guest-welcome-container">
|
||||
<mat-card class="welcome-card">
|
||||
<mat-card-header>
|
||||
<mat-icon class="welcome-icon">waving_hand</mat-icon>
|
||||
<mat-card-title>Welcome to Interview Quiz!</mat-card-title>
|
||||
<mat-card-subtitle>Choose how you'd like to continue</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<!-- Guest Mode Section -->
|
||||
<div class="mode-section">
|
||||
<div class="mode-header">
|
||||
<mat-icon color="accent">visibility</mat-icon>
|
||||
<h3>Try as Guest</h3>
|
||||
</div>
|
||||
<p class="mode-description">
|
||||
Start exploring immediately without creating an account.
|
||||
</p>
|
||||
<mat-list class="features-list">
|
||||
@for (feature of guestFeatures; track feature.text) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>{{ feature.icon }}</mat-icon>
|
||||
<span matListItemTitle>{{ feature.text }}</span>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
class="action-button"
|
||||
(click)="startGuestSession()"
|
||||
[disabled]="isLoading">
|
||||
@if (isLoading) {
|
||||
<ng-container>
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
<span>Starting Session...</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>Try as Guest</span>
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
|
||||
<!-- Registered User Section -->
|
||||
<div class="mode-section">
|
||||
<div class="mode-header">
|
||||
<mat-icon color="primary">account_circle</mat-icon>
|
||||
<h3>Create an Account</h3>
|
||||
</div>
|
||||
<p class="mode-description">
|
||||
Get the full experience with unlimited access.
|
||||
</p>
|
||||
<mat-list class="features-list">
|
||||
@for (feature of registeredFeatures; track feature.text) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>{{ feature.icon }}</mat-icon>
|
||||
<span matListItemTitle>{{ feature.text }}</span>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
<div class="button-group">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="action-button"
|
||||
(click)="navigateToRegister()">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
Sign Up
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="navigateToLogin()">
|
||||
<span>Already have an account? Login</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,174 @@
|
||||
.guest-welcome-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, var(--primary-color-light) 0%, var(--accent-color-light) 100%);
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
mat-card-subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-section {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-color);
|
||||
|
||||
.mode-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-description {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.features-list {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
mat-list-item {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
mat-spinner {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.action-button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 0 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.guest-welcome-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
mat-card-header {
|
||||
padding: 1.5rem;
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-section {
|
||||
padding: 1rem;
|
||||
|
||||
.mode-header {
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guest-welcome',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatListModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './guest-welcome.html',
|
||||
styleUrl: './guest-welcome.scss'
|
||||
})
|
||||
export class GuestWelcomeComponent {
|
||||
private guestService = inject(GuestService);
|
||||
private router = inject(Router);
|
||||
|
||||
isLoading = false;
|
||||
|
||||
guestFeatures = [
|
||||
{ icon: 'quiz', text: 'Take up to 3 quizzes per day' },
|
||||
{ icon: 'category', text: 'Access selected categories' },
|
||||
{ icon: 'timer', text: '24-hour session validity' },
|
||||
{ icon: 'phone_android', text: 'No installation required' }
|
||||
];
|
||||
|
||||
registeredFeatures = [
|
||||
{ icon: 'all_inclusive', text: 'Unlimited quizzes' },
|
||||
{ icon: 'lock_open', text: 'Access all categories' },
|
||||
{ icon: 'trending_up', text: 'Track your progress' },
|
||||
{ icon: 'bookmark', text: 'Bookmark questions' },
|
||||
{ icon: 'history', text: 'View quiz history' },
|
||||
{ icon: 'emoji_events', text: 'Earn achievements' }
|
||||
];
|
||||
|
||||
startGuestSession(): void {
|
||||
this.isLoading = true;
|
||||
this.guestService.startSession().subscribe({
|
||||
next: () => {
|
||||
this.isLoading = false;
|
||||
this.router.navigate(['/categories']);
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigateToRegister(): void {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
|
||||
navigateToLogin(): void {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
107
frontend/src/app/shared/components/header/header.html
Normal file
107
frontend/src/app/shared/components/header/header.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<mat-toolbar class="header" color="primary">
|
||||
<div class="header-container">
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button
|
||||
mat-icon-button
|
||||
class="menu-toggle mobile-only"
|
||||
(click)="onMenuToggle()"
|
||||
aria-label="Toggle menu">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="logo" routerLink="/">
|
||||
<mat-icon class="logo-icon">quiz</mat-icon>
|
||||
<span class="logo-text">Interview Quiz</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleTheme()"
|
||||
[matTooltip]="theme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode'"
|
||||
aria-label="Toggle theme">
|
||||
@if (theme() === 'light') {
|
||||
<mat-icon>dark_mode</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>light_mode</mat-icon>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Guest User -->
|
||||
@if (isGuest) {
|
||||
<div class="guest-badge">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Guest Mode</span>
|
||||
</div>
|
||||
<button mat-raised-button color="accent" (click)="register()">
|
||||
Sign Up
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Authenticated User Menu -->
|
||||
@if (isAuthenticated) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
aria-label="User menu">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<div class="user-menu-header">
|
||||
<mat-icon>person</mat-icon>
|
||||
<div class="user-info">
|
||||
<span class="username">{{ currentUser?.username }}</span>
|
||||
<span class="email">{{ currentUser?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<button mat-menu-item (click)="goToDashboard()">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item (click)="goToProfile()">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item (click)="goToSettings()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
@if (currentUser?.role === 'admin') {
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item routerLink="/admin">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<span>Admin Panel</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<button mat-menu-item (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
|
||||
<!-- Not Authenticated -->
|
||||
@if (!isAuthenticated && !isGuest) {
|
||||
<button mat-button (click)="login()">
|
||||
Login
|
||||
</button>
|
||||
<button mat-raised-button color="accent" (click)="register()">
|
||||
Sign Up
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
132
frontend/src/app/shared/components/header/header.scss
Normal file
132
frontend/src/app/shared/components/header/header.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-fixed);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: var(--container-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
height: var(--header-height);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.guest-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User Menu Styles
|
||||
.user-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-surface);
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
.username {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-menu-panel {
|
||||
min-width: 250px !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-menu-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: var(--spacing-sm) !important;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
152
frontend/src/app/shared/components/header/header.ts
Normal file
152
frontend/src/app/shared/components/header/header.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component, inject, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { ThemeService } from '../../../core/services/theme.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { GuestService } from '../../../core/services/guest.service';
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
MatDividerModule,
|
||||
MatDialogModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatChipsModule
|
||||
],
|
||||
templateUrl: './header.html',
|
||||
styleUrl: './header.scss'
|
||||
})
|
||||
export class HeaderComponent {
|
||||
private themeService = inject(ThemeService);
|
||||
private authService = inject(AuthService);
|
||||
private guestService = inject(GuestService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
// Output event for mobile menu toggle
|
||||
menuToggle = output<void>();
|
||||
|
||||
// Expose theme signal for template
|
||||
theme = this.themeService.theme;
|
||||
|
||||
// Expose auth state
|
||||
authState = this.authService.authState;
|
||||
|
||||
// Expose guest state
|
||||
guestState = this.guestService.guestState;
|
||||
|
||||
// Loading state for logout
|
||||
isLoggingOut = signal<boolean>(false);
|
||||
|
||||
// Get user data
|
||||
get currentUser() {
|
||||
return this.authState().user;
|
||||
}
|
||||
|
||||
get isAuthenticated() {
|
||||
return this.authState().isAuthenticated;
|
||||
}
|
||||
|
||||
get isGuest() {
|
||||
return this.guestState().isGuest && !this.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
toggleTheme(): void {
|
||||
this.themeService.toggleTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mobile menu
|
||||
*/
|
||||
onMenuToggle(): void {
|
||||
this.menuToggle.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to profile
|
||||
*/
|
||||
goToProfile(): void {
|
||||
this.router.navigate(['/profile']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to dashboard
|
||||
*/
|
||||
goToDashboard(): void {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to settings
|
||||
*/
|
||||
goToSettings(): void {
|
||||
this.router.navigate(['/settings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user with confirmation
|
||||
*/
|
||||
logout(): void {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: {
|
||||
title: 'Logout Confirmation',
|
||||
message: 'Are you sure you want to logout?',
|
||||
confirmText: 'Logout',
|
||||
cancelText: 'Cancel',
|
||||
confirmColor: 'warn'
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoggingOut.set(true);
|
||||
this.authService.logout().subscribe({
|
||||
next: () => {
|
||||
this.isLoggingOut.set(false);
|
||||
// Navigation and toast handled by AuthService
|
||||
},
|
||||
error: () => {
|
||||
this.isLoggingOut.set(false);
|
||||
// Still navigates to login even on error
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Navigate to login
|
||||
*/
|
||||
login(): void {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to register
|
||||
*/
|
||||
register(): void {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="loading-spinner-container" [class.overlay]="overlay()">
|
||||
<div class="spinner-wrapper">
|
||||
<mat-spinner [diameter]="size()"></mat-spinner>
|
||||
<p class="loading-message">{{ message() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
.loading-spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
|
||||
&.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
z-index: 9999;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component, input, Signal } from '@angular/core';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading-spinner',
|
||||
imports: [CommonModule, MatProgressSpinnerModule],
|
||||
templateUrl: './loading-spinner.html',
|
||||
styleUrl: './loading-spinner.scss',
|
||||
standalone: true
|
||||
})
|
||||
export class LoadingSpinnerComponent {
|
||||
message = input<string>('Loading...');
|
||||
size = input<number>(50);
|
||||
overlay = input<boolean>(false);
|
||||
}
|
||||
59
frontend/src/app/shared/components/not-found/not-found.html
Normal file
59
frontend/src/app/shared/components/not-found/not-found.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<!-- 404 Illustration -->
|
||||
<div class="error-illustration">
|
||||
<mat-icon class="error-icon">error_outline</mat-icon>
|
||||
<h1 class="error-code">404</h1>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="error-message">
|
||||
<h2>Page Not Found</h2>
|
||||
<p>
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
It might have been removed, had its name changed, or is temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button mat-raised-button color="primary" (click)="goHome()">
|
||||
<mat-icon>home</mat-icon>
|
||||
<span>Go to Home</span>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button (click)="goToCategories()">
|
||||
<mat-icon>category</mat-icon>
|
||||
<span>Browse Categories</span>
|
||||
</button>
|
||||
|
||||
<button mat-button (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Go Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Helpful Links -->
|
||||
<div class="helpful-links">
|
||||
<p class="links-heading">You might be interested in:</p>
|
||||
<nav class="links-list">
|
||||
<a routerLink="/dashboard" class="link-item">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a routerLink="/quiz/setup" class="link-item">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
<span>Start a Quiz</span>
|
||||
</a>
|
||||
<a routerLink="/help" class="link-item">
|
||||
<mat-icon>help</mat-icon>
|
||||
<span>Help Center</span>
|
||||
</a>
|
||||
<a routerLink="/contact" class="link-item">
|
||||
<mat-icon>contact_support</mat-icon>
|
||||
<span>Contact Us</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
192
frontend/src/app/shared/components/not-found/not-found.scss
Normal file
192
frontend/src/app/shared/components/not-found/not-found.scss
Normal file
@@ -0,0 +1,192 @@
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
padding: var(--spacing-2xl) var(--spacing-md);
|
||||
background: linear-gradient(135deg,
|
||||
var(--color-surface) 0%,
|
||||
var(--color-background) 100%);
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xl);
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Error Illustration
|
||||
.error-illustration {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
animation: fadeInScale 0.6s ease-out;
|
||||
|
||||
.error-icon {
|
||||
font-size: 120px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.3;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 96px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
.error-message {
|
||||
animation: fadeInUp 0.6s ease-out 0.2s both;
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
animation: fadeInUp 0.6s ease-out 0.4s both;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
min-width: 160px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// Helpful Links
|
||||
.helpful-links {
|
||||
width: 100%;
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
animation: fadeInUp 0.6s ease-out 0.6s both;
|
||||
|
||||
.links-heading {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.links-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-lighter);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
42
frontend/src/app/shared/components/not-found/not-found.ts
Normal file
42
frontend/src/app/shared/components/not-found/not-found.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule, Location } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './not-found.html',
|
||||
styleUrl: './not-found.scss'
|
||||
})
|
||||
export class NotFoundComponent {
|
||||
private router = inject(Router);
|
||||
private location = inject(Location);
|
||||
|
||||
/**
|
||||
* Navigate back to previous page
|
||||
*/
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to home page
|
||||
*/
|
||||
goHome(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to categories
|
||||
*/
|
||||
goToCategories(): void {
|
||||
this.router.navigate(['/categories']);
|
||||
}
|
||||
}
|
||||
40
frontend/src/app/shared/components/sidebar/sidebar.html
Normal file
40
frontend/src/app/shared/components/sidebar/sidebar.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<aside class="sidebar" [class.open]="isOpen()">
|
||||
<nav class="sidebar-nav">
|
||||
<mat-nav-list>
|
||||
@for (item of navItems; track item.route) {
|
||||
@if (shouldShowItem(item)) {
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="item.route"
|
||||
[class.active]="isActiveRoute(item.route)"
|
||||
[matTooltip]="item.label"
|
||||
matTooltipPosition="right"
|
||||
[matTooltipDisabled]="isOpen()">
|
||||
<mat-icon matListItemIcon [matBadge]="item.badge" matBadgeColor="accent">
|
||||
{{ item.icon }}
|
||||
</mat-icon>
|
||||
<span matListItemTitle class="nav-label">{{ item.label }}</span>
|
||||
</a>
|
||||
|
||||
@if (item.dividerAfter) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
}
|
||||
</mat-nav-list>
|
||||
|
||||
<!-- Guest Mode Section -->
|
||||
@if (!isAuthenticated) {
|
||||
<div class="guest-section">
|
||||
<mat-divider></mat-divider>
|
||||
<div class="guest-prompt">
|
||||
<mat-icon>info</mat-icon>
|
||||
<p>Sign up for full access</p>
|
||||
<button mat-raised-button color="primary" routerLink="/register">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
145
frontend/src/app/shared/components/sidebar/sidebar.scss
Normal file
145
frontend/src/app/shared/components/sidebar/sidebar.scss
Normal file
@@ -0,0 +1,145 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--color-surface-elevated);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: var(--z-sticky);
|
||||
transition: transform var(--transition-base);
|
||||
|
||||
// Mobile: Hidden by default, slide in when open
|
||||
@media (max-width: 1023px) {
|
||||
transform: translateX(-100%);
|
||||
box-shadow: var(--shadow-xl);
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Always visible
|
||||
@media (min-width: 1024px) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
// Scrollbar styling
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: var(--spacing-md) 0;
|
||||
|
||||
::ng-deep .mat-mdc-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-list-item {
|
||||
height: 56px;
|
||||
padding: 0 var(--spacing-lg);
|
||||
margin: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast), transform var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-lighter);
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
mat-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-list-item-icon {
|
||||
margin-right: var(--spacing-md);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-list-item-title {
|
||||
font-size: var(--font-size-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: var(--spacing-md) var(--spacing-lg);
|
||||
background-color: var(--color-divider);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@media (max-width: 1023px) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
// Guest Section
|
||||
.guest-section {
|
||||
margin-top: auto;
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.guest-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
margin: var(--spacing-md);
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Badge styling
|
||||
::ng-deep .mat-badge-content {
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
168
frontend/src/app/shared/components/sidebar/sidebar.ts
Normal file
168
frontend/src/app/shared/components/sidebar/sidebar.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule, NavigationEnd } from '@angular/router';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
requiresAuth?: boolean;
|
||||
requiresAdmin?: boolean;
|
||||
badge?: number;
|
||||
dividerAfter?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatListModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatDividerModule,
|
||||
MatBadgeModule
|
||||
],
|
||||
templateUrl: './sidebar.html',
|
||||
styleUrl: './sidebar.scss'
|
||||
})
|
||||
export class SidebarComponent {
|
||||
private storageService = inject(StorageService);
|
||||
private router = inject(Router);
|
||||
|
||||
// Input to control mobile sidebar visibility
|
||||
isOpen = input<boolean>(false);
|
||||
|
||||
currentRoute = '';
|
||||
|
||||
constructor() {
|
||||
// Track current route for active state
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe((event: any) => {
|
||||
this.currentRoute = event.urlAfterRedirects;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation items
|
||||
*/
|
||||
navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'home',
|
||||
route: '/'
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'dashboard',
|
||||
route: '/dashboard',
|
||||
requiresAuth: true,
|
||||
dividerAfter: true
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
icon: 'category',
|
||||
route: '/categories'
|
||||
},
|
||||
{
|
||||
label: 'Start Quiz',
|
||||
icon: 'play_circle',
|
||||
route: '/quiz/setup'
|
||||
},
|
||||
{
|
||||
label: 'Quiz History',
|
||||
icon: 'history',
|
||||
route: '/history',
|
||||
requiresAuth: true
|
||||
},
|
||||
{
|
||||
label: 'Bookmarks',
|
||||
icon: 'bookmark',
|
||||
route: '/bookmarks',
|
||||
requiresAuth: true,
|
||||
dividerAfter: true
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'person',
|
||||
route: '/profile',
|
||||
requiresAuth: true
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'settings',
|
||||
route: '/settings',
|
||||
requiresAuth: true,
|
||||
dividerAfter: true
|
||||
},
|
||||
{
|
||||
label: 'Admin Panel',
|
||||
icon: 'admin_panel_settings',
|
||||
route: '/admin',
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
label: 'User Management',
|
||||
icon: 'people',
|
||||
route: '/admin/users',
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
label: 'Questions',
|
||||
icon: 'quiz',
|
||||
route: '/admin/questions',
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
label: 'Analytics',
|
||||
icon: 'analytics',
|
||||
route: '/admin/analytics',
|
||||
requiresAdmin: true
|
||||
}
|
||||
];
|
||||
|
||||
get currentUser() {
|
||||
return this.storageService.getUserData();
|
||||
}
|
||||
|
||||
get isAuthenticated() {
|
||||
return this.storageService.isAuthenticated();
|
||||
}
|
||||
|
||||
get isAdmin() {
|
||||
return this.currentUser?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if nav item should be visible
|
||||
*/
|
||||
shouldShowItem(item: NavItem): boolean {
|
||||
if (item.requiresAdmin && !this.isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.requiresAuth && !this.isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if nav item is active
|
||||
*/
|
||||
isActiveRoute(route: string): boolean {
|
||||
if (route === '/') {
|
||||
return this.currentRoute === '/';
|
||||
}
|
||||
return this.currentRoute.startsWith(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="toast-container">
|
||||
@for (toast of toastService.toasts(); track toast.id) {
|
||||
<div class="toast" [class]="'toast-' + toast.type" [@slideIn]>
|
||||
<div class="toast-content">
|
||||
<mat-icon class="toast-icon">{{ getIcon(toast.type) }}</mat-icon>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
@if (toast.action) {
|
||||
<button
|
||||
mat-button
|
||||
class="toast-action"
|
||||
(click)="onAction(toast.action.callback, toast.id)">
|
||||
{{ toast.action.label }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="toast-close"
|
||||
(click)="toastService.remove(toast.id)">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
border-left: 4px solid;
|
||||
|
||||
&.toast-success {
|
||||
border-left-color: #4caf50;
|
||||
.toast-icon {
|
||||
color: #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
border-left-color: #f44336;
|
||||
.toast-icon {
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
border-left-color: #ff9800;
|
||||
.toast-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-info {
|
||||
border-left-color: #2196f3;
|
||||
.toast-icon {
|
||||
color: #2196f3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-action {
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-container {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-container',
|
||||
imports: [CommonModule, MatIconModule, MatButtonModule],
|
||||
templateUrl: './toast-container.html',
|
||||
styleUrl: './toast-container.scss',
|
||||
standalone: true
|
||||
})
|
||||
export class ToastContainerComponent {
|
||||
toastService = inject(ToastService);
|
||||
|
||||
getIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
success: 'check_circle',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info'
|
||||
};
|
||||
return icons[type] || 'info';
|
||||
}
|
||||
|
||||
onAction(callback: () => void, toastId: string): void {
|
||||
callback();
|
||||
this.toastService.remove(toastId);
|
||||
}
|
||||
}
|
||||
9
frontend/src/environments/environment.development.ts
Normal file
9
frontend/src/environments/environment.development.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:3000/api',
|
||||
apiTimeout: 30000,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
enableLogging: true,
|
||||
appName: 'Interview Quiz Application (Dev)',
|
||||
appVersion: '1.0.0-dev'
|
||||
};
|
||||
9
frontend/src/environments/environment.ts
Normal file
9
frontend/src/environments/environment.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.yourdomain.com/api',
|
||||
apiTimeout: 30000,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
enableLogging: false,
|
||||
appName: 'Interview Quiz Application',
|
||||
appVersion: '1.0.0'
|
||||
};
|
||||
15
frontend/src/index.html
Normal file
15
frontend/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
309
frontend/src/styles.scss
Normal file
309
frontend/src/styles.scss
Normal file
@@ -0,0 +1,309 @@
|
||||
|
||||
// Include theming for Angular Material with `mat.theme()`.
|
||||
// This Sass mixin will define CSS variables that are used for styling Angular Material
|
||||
// components according to the Material 3 design spec.
|
||||
// Learn more about theming and how to use it for your application's
|
||||
// custom components at https://material.angular.dev/guide/theming
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
html {
|
||||
@include mat.theme((
|
||||
color: (
|
||||
primary: mat.$azure-palette,
|
||||
tertiary: mat.$blue-palette,
|
||||
),
|
||||
typography: Roboto,
|
||||
density: 0,
|
||||
));
|
||||
}
|
||||
|
||||
body {
|
||||
// Default the application to a light color theme. This can be changed to
|
||||
// `dark` to enable the dark color theme, or to `light dark` to defer to the
|
||||
// user's system settings.
|
||||
color-scheme: light;
|
||||
|
||||
// Set a default background, font and text colors for the application using
|
||||
// Angular Material's system-level CSS variables. Learn more about these
|
||||
// variables at https://material.angular.dev/guide/system-variables
|
||||
background-color: var(--mat-sys-surface);
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: var(--mat-sys-body-medium);
|
||||
|
||||
// Reset the user agent margin.
|
||||
margin: 0;
|
||||
}
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
|
||||
/* ========================================
|
||||
CSS Custom Properties (Theme Variables)
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--color-primary: #0078d4;
|
||||
--color-primary-dark: #005a9e;
|
||||
--color-primary-light: #50a0e6;
|
||||
--color-primary-lighter: #e6f2fa;
|
||||
|
||||
/* Secondary Colors */
|
||||
--color-secondary: #2c3e50;
|
||||
--color-secondary-dark: #1a252f;
|
||||
--color-secondary-light: #415769;
|
||||
|
||||
/* Accent Colors */
|
||||
--color-accent: #00c853;
|
||||
--color-accent-dark: #009624;
|
||||
--color-accent-light: #5efc82;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #4caf50;
|
||||
--color-success-light: #81c784;
|
||||
--color-success-dark: #388e3c;
|
||||
|
||||
--color-error: #f44336;
|
||||
--color-error-light: #e57373;
|
||||
--color-error-dark: #d32f2f;
|
||||
|
||||
--color-warning: #ff9800;
|
||||
--color-warning-light: #ffb74d;
|
||||
--color-warning-dark: #f57c00;
|
||||
|
||||
--color-info: #2196f3;
|
||||
--color-info-light: #64b5f6;
|
||||
--color-info-dark: #1976d2;
|
||||
|
||||
/* Neutral Colors - Light Theme */
|
||||
--color-background: #ffffff;
|
||||
--color-surface: #f5f5f5;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-text-primary: #212121;
|
||||
--color-text-secondary: #757575;
|
||||
--color-text-disabled: #bdbdbd;
|
||||
--color-border: #e0e0e0;
|
||||
--color-divider: #eeeeee;
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Spacing Scale */
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
--spacing-3xl: 4rem; /* 64px */
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: 'Roboto', 'Helvetica Neue', sans-serif;
|
||||
--font-family-heading: 'Roboto', 'Helvetica Neue', sans-serif;
|
||||
--font-family-mono: 'Courier New', monospace;
|
||||
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 1.875rem; /* 30px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-fixed: 1030;
|
||||
--z-modal-backdrop: 1040;
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
--z-toast: 10000;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-base: 250ms ease-in-out;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
|
||||
/* Layout */
|
||||
--header-height: 64px;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--footer-height: 60px;
|
||||
--container-max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Dark Theme Variables */
|
||||
body.dark-theme {
|
||||
--color-background: #121212;
|
||||
--color-surface: #1e1e1e;
|
||||
--color-surface-elevated: #2d2d2d;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-disabled: #6f6f6f;
|
||||
--color-border: #3f3f3f;
|
||||
--color-divider: #2d2d2d;
|
||||
--color-shadow: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Adjust primary colors for dark mode */
|
||||
--color-primary: #50a0e6;
|
||||
--color-primary-light: #7bb8ed;
|
||||
--color-primary-lighter: #1a3a52;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Responsive Breakpoints
|
||||
======================================== */
|
||||
|
||||
/* Mobile: 320px - 767px (default, no media query needed) */
|
||||
/* Tablet: 768px - 1023px */
|
||||
/* Desktop: 1024px+ */
|
||||
|
||||
/* Utility classes for responsive visibility */
|
||||
.mobile-only {
|
||||
@media (min-width: 768px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tablet-up {
|
||||
@media (max-width: 767px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@media (max-width: 1023px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Global Utility Classes
|
||||
======================================== */
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Flexbox Utilities */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-around { justify-content: space-around; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.gap-xs { gap: var(--spacing-xs); }
|
||||
.gap-sm { gap: var(--spacing-sm); }
|
||||
.gap-md { gap: var(--spacing-md); }
|
||||
.gap-lg { gap: var(--spacing-lg); }
|
||||
.gap-xl { gap: var(--spacing-xl); }
|
||||
|
||||
/* Spacing Utilities */
|
||||
.m-0 { margin: 0; }
|
||||
.mt-sm { margin-top: var(--spacing-sm); }
|
||||
.mt-md { margin-top: var(--spacing-md); }
|
||||
.mt-lg { margin-top: var(--spacing-lg); }
|
||||
.mb-sm { margin-bottom: var(--spacing-sm); }
|
||||
.mb-md { margin-bottom: var(--spacing-md); }
|
||||
.mb-lg { margin-bottom: var(--spacing-lg); }
|
||||
.p-sm { padding: var(--spacing-sm); }
|
||||
.p-md { padding: var(--spacing-md); }
|
||||
.p-lg { padding: var(--spacing-lg); }
|
||||
|
||||
/* Text Utilities */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.text-primary { color: var(--color-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-success { color: var(--color-success); }
|
||||
.text-error { color: var(--color-error); }
|
||||
.text-warning { color: var(--color-warning); }
|
||||
.font-bold { font-weight: var(--font-weight-bold); }
|
||||
.font-medium { font-weight: var(--font-weight-medium); }
|
||||
|
||||
/* Border Utilities */
|
||||
.rounded-sm { border-radius: var(--radius-sm); }
|
||||
.rounded-md { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.rounded-full { border-radius: var(--radius-full); }
|
||||
|
||||
/* Shadow Utilities */
|
||||
.shadow-sm { box-shadow: var(--shadow-sm); }
|
||||
.shadow-md { box-shadow: var(--shadow-md); }
|
||||
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||
|
||||
/* ========================================
|
||||
Global Component Resets
|
||||
======================================== */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user