add changes

This commit is contained in:
AD2025
2025-11-13 23:15:11 +02:00
parent 9746cfbc79
commit 41565aec12
88 changed files with 18629 additions and 1 deletions

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View 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>&copy; {{ 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>

View 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;
}

View 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' }
];
}

View File

@@ -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>

View File

@@ -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%;
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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%;
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}

View File

@@ -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']);
}
}

View 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>

View 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;
}
}

View 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']);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}

View 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>

View 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);
}
}

View 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']);
}
}

View 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>

View 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);
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}