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