add changes

This commit is contained in:
AD2025
2025-11-14 21:48:47 +02:00
parent 6f23890407
commit 37b4d565b1
72 changed files with 17104 additions and 246 deletions

View File

@@ -0,0 +1,126 @@
import { Component, inject, input } 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';
import { MatTooltipModule } from '@angular/material/tooltip';
/**
* BackButtonComponent
*
* A reusable back navigation button that uses browser history or custom route.
*
* Features:
* - Uses browser history by default
* - Supports custom fallback route
* - Configurable button style (icon, text, or both)
* - Tooltip support
* - Keyboard accessible
* - Responsive design
*
* Usage:
* ```html
* <!-- Simple back button -->
* <app-back-button></app-back-button>
*
* <!-- With custom fallback -->
* <app-back-button [fallbackRoute]="'/dashboard'"></app-back-button>
*
* <!-- Text button -->
* <app-back-button [showText]="true" [label]="'Back to List'"></app-back-button>
*
* <!-- Icon only -->
* <app-back-button [showText]="false"></app-back-button>
* ```
*/
@Component({
selector: 'app-back-button',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatButtonModule,
MatIconModule,
MatTooltipModule
],
template: `
<button
mat-button
[class.icon-only]="!showText()"
(click)="goBack()"
[matTooltip]="tooltip()"
[attr.aria-label]="label() || 'Go back'">
<mat-icon>{{ icon() }}</mat-icon>
@if (showText()) {
<span>{{ label() }}</span>
}
</button>
`,
styles: [`
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: auto;
mat-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
}
&.icon-only {
padding: 0.5rem;
min-width: 40px;
}
}
// Focus styles
button:focus-visible {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
`]
})
export class BackButtonComponent {
private location = inject(Location);
private router = inject(Router);
/**
* Custom route to navigate to if history is empty
*/
fallbackRoute = input<string>('/');
/**
* Whether to show text label
*/
showText = input<boolean>(true);
/**
* Button label text
*/
label = input<string>('Back');
/**
* Icon to display
*/
icon = input<string>('arrow_back');
/**
* Tooltip text
*/
tooltip = input<string>('Go back');
/**
* Navigate back using browser history or fallback route
*/
goBack(): void {
// Check if there's history to go back to
if (window.history.length > 1) {
this.location.back();
} else {
// No history, use fallback route
this.router.navigate([this.fallbackRoute()]);
}
}
}

View File

@@ -0,0 +1,298 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, ActivatedRoute, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { filter, distinctUntilChanged, map } from 'rxjs/operators';
export interface Breadcrumb {
label: string;
url: string;
icon?: string;
}
/**
* BreadcrumbComponent
*
* Displays a breadcrumb trail showing the current navigation path.
*
* Features:
* - Automatically generates breadcrumbs from route hierarchy
* - Shows home icon for root
* - Supports custom labels via route data
* - Clickable navigation
* - Responsive design
* - ARIA labels for accessibility
*
* Usage in route config:
* ```typescript
* {
* path: 'categories/:id',
* component: CategoryDetailComponent,
* data: { breadcrumb: 'Category Detail' }
* }
* ```
*/
@Component({
selector: 'app-breadcrumb',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule
],
template: `
<nav class="breadcrumb-container" aria-label="Breadcrumb navigation">
<ol class="breadcrumb-list">
@for (breadcrumb of breadcrumbs; track breadcrumb.url; let isLast = $last) {
<li class="breadcrumb-item" [class.active]="isLast">
@if (!isLast) {
<a [routerLink]="breadcrumb.url" class="breadcrumb-link">
@if (breadcrumb.icon) {
<mat-icon>{{ breadcrumb.icon }}</mat-icon>
}
<span>{{ breadcrumb.label }}</span>
</a>
<mat-icon class="separator">chevron_right</mat-icon>
} @else {
<span class="breadcrumb-current">
@if (breadcrumb.icon) {
<mat-icon>{{ breadcrumb.icon }}</mat-icon>
}
<span>{{ breadcrumb.label }}</span>
</span>
}
</li>
}
</ol>
</nav>
`,
styles: [`
.breadcrumb-container {
padding: 0.75rem 0;
background: transparent;
}
.breadcrumb-list {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
gap: 0.5rem;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
&:first-child {
.breadcrumb-link mat-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
}
}
}
.breadcrumb-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s ease;
padding: 0.25rem 0.5rem;
border-radius: 4px;
&:hover {
color: var(--primary-color);
background-color: var(--hover-background);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.breadcrumb-current {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--text-primary);
font-weight: 500;
padding: 0.25rem 0.5rem;
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.separator {
font-size: 1rem;
width: 1rem;
height: 1rem;
color: var(--text-disabled);
}
// Responsive
@media (max-width: 768px) {
.breadcrumb-list {
gap: 0.25rem;
}
.breadcrumb-item {
font-size: 0.8125rem;
}
.breadcrumb-link span,
.breadcrumb-current span {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Dark mode
@media (prefers-color-scheme: dark) {
.breadcrumb-container {
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-disabled: #606060;
--primary-color: #2196f3;
--hover-background: rgba(255, 255, 255, 0.08);
}
}
// Light mode
@media (prefers-color-scheme: light) {
.breadcrumb-container {
--text-primary: #212121;
--text-secondary: #757575;
--text-disabled: #bdbdbd;
--primary-color: #1976d2;
--hover-background: rgba(0, 0, 0, 0.04);
}
}
`]
})
export class BreadcrumbComponent implements OnInit {
private router = inject(Router);
private activatedRoute = inject(ActivatedRoute);
breadcrumbs: Breadcrumb[] = [];
ngOnInit(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
distinctUntilChanged()
)
.subscribe(() => {
this.breadcrumbs = this.buildBreadcrumbs(this.activatedRoute.root);
});
// Initial breadcrumb
this.breadcrumbs = this.buildBreadcrumbs(this.activatedRoute.root);
}
/**
* Build breadcrumbs from route tree
*/
private buildBreadcrumbs(
route: ActivatedRoute,
url: string = '',
breadcrumbs: Breadcrumb[] = []
): Breadcrumb[] {
// Add home breadcrumb if this is the first item
if (breadcrumbs.length === 0) {
breadcrumbs.push({
label: 'Home',
url: '/',
icon: 'home'
});
}
// Get the child routes
const children: ActivatedRoute[] = route.children;
// Return if there are no more children
if (children.length === 0) {
return breadcrumbs;
}
// Iterate over each child
for (const child of children) {
// Get the route's URL segment
const routeURL: string = child.snapshot.url
.map(segment => segment.path)
.join('/');
// Skip empty path routes
if (routeURL === '') {
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
// Append route URL to URL
url += `/${routeURL}`;
// Get breadcrumb label from route data or generate from URL
const label = child.snapshot.data['breadcrumb'] || this.formatLabel(routeURL);
// Skip if label is explicitly set to null (hide breadcrumb)
if (label === null) {
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
// Add breadcrumb
const breadcrumb: Breadcrumb = {
label,
url
};
// Don't add duplicate breadcrumbs
if (!breadcrumbs.find(b => b.url === url)) {
breadcrumbs.push(breadcrumb);
}
// Recursive call
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
return breadcrumbs;
}
/**
* Format URL segment into readable label
*/
private formatLabel(segment: string): string {
// Handle numeric IDs
if (/^\d+$/.test(segment)) {
return 'Details';
}
// Handle UUIDs or long strings
if (segment.length > 20) {
return 'Details';
}
// Convert kebab-case or snake_case to Title Case
return segment
.replace(/[-_]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
}

View File

@@ -0,0 +1,379 @@
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';
import { Router } from '@angular/router';
/**
* Error Component
* Displays critical errors with retry and navigation options
* Used for full-page error states (500, network errors, etc.)
*/
@Component({
selector: 'app-error',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatCardModule
],
template: `
<div class="error-container">
<mat-card class="error-card">
<mat-card-content>
<!-- Error Icon -->
<div class="error-icon">
<mat-icon [class]="'error-icon-' + errorType()">
{{ getIcon() }}
</mat-icon>
</div>
<!-- Error Title -->
<h1 class="error-title">{{ title() }}</h1>
<!-- Error Message -->
<p class="error-message">{{ message() }}</p>
<!-- Error Code (if provided) -->
@if (errorCode()) {
<p class="error-code">Error Code: {{ errorCode() }}</p>
}
<!-- Action Buttons -->
<div class="error-actions">
@if (showRetry()) {
<button
mat-raised-button
color="primary"
(click)="onRetry()"
aria-label="Retry the operation">
<mat-icon>refresh</mat-icon>
Retry
</button>
}
@if (showReload()) {
<button
mat-raised-button
color="primary"
(click)="reloadPage()"
aria-label="Reload the page">
<mat-icon>refresh</mat-icon>
Reload Page
</button>
}
<button
mat-raised-button
(click)="goHome()"
aria-label="Go to home page">
<mat-icon>home</mat-icon>
Go to Home
</button>
@if (showBack()) {
<button
mat-stroked-button
(click)="goBack()"
aria-label="Go back">
<mat-icon>arrow_back</mat-icon>
Go Back
</button>
}
</div>
<!-- Additional Details (Expandable) -->
@if (showDetails() && errorDetails()) {
<div class="error-details">
<button
mat-button
(click)="toggleDetails()"
class="details-toggle"
aria-label="Toggle error details">
<mat-icon>{{ detailsExpanded ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ detailsExpanded ? 'Hide' : 'Show' }} Details
</button>
@if (detailsExpanded) {
<div class="details-content">
<pre>{{ errorDetails() }}</pre>
</div>
}
</div>
}
<!-- Support Message -->
@if (showSupport()) {
<div class="support-message">
<p>If the problem persists, please contact support.</p>
</div>
}
</mat-card-content>
</mat-card>
</div>
`,
styles: [`
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.error-card {
max-width: 600px;
width: 100%;
text-align: center;
padding: 2rem;
}
.error-icon {
margin-bottom: 1.5rem;
}
.error-icon mat-icon {
font-size: 80px;
width: 80px;
height: 80px;
}
.error-icon-500 {
color: #f44336; /* Red for server errors */
}
.error-icon-404 {
color: #ff9800; /* Orange for not found */
}
.error-icon-403 {
color: #ff9800; /* Orange for forbidden */
}
.error-icon-401 {
color: #ff9800; /* Orange for unauthorized */
}
.error-icon-network {
color: #9e9e9e; /* Gray for network errors */
}
.error-icon-default {
color: #f44336; /* Red for generic errors */
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #333;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.125rem;
color: #666;
margin-bottom: 1rem;
line-height: 1.6;
}
.error-code {
font-size: 0.875rem;
color: #999;
font-family: 'Courier New', monospace;
margin-bottom: 1.5rem;
}
.error-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.error-actions button {
min-width: 140px;
}
.error-details {
margin-top: 2rem;
text-align: left;
}
.details-toggle {
width: 100%;
justify-content: center;
margin-bottom: 1rem;
}
.details-content {
background: #f5f5f5;
border-radius: 4px;
padding: 1rem;
max-height: 300px;
overflow-y: auto;
}
.details-content pre {
margin: 0;
font-size: 0.875rem;
color: #333;
white-space: pre-wrap;
word-break: break-word;
}
.support-message {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e0e0e0;
}
.support-message p {
color: #666;
font-size: 0.875rem;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.error-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.error-card {
background: #1e1e1e;
color: #e0e0e0;
}
.error-title {
color: #e0e0e0;
}
.error-message {
color: #b0b0b0;
}
.details-content {
background: #2a2a2a;
}
.details-content pre {
color: #e0e0e0;
}
.support-message {
border-top-color: #333;
}
}
/* Responsive design */
@media (max-width: 768px) {
.error-card {
padding: 1.5rem;
}
.error-icon mat-icon {
font-size: 60px;
width: 60px;
height: 60px;
}
.error-title {
font-size: 1.5rem;
}
.error-message {
font-size: 1rem;
}
.error-actions {
flex-direction: column;
}
.error-actions button {
width: 100%;
}
}
`]
})
export class ErrorComponent {
// Input signals
title = input<string>('Something Went Wrong');
message = input<string>('An unexpected error occurred. Please try again or contact support if the problem persists.');
errorCode = input<string | null>(null);
errorType = input<'500' | '404' | '403' | '401' | 'network' | 'default'>('default');
errorDetails = input<string | null>(null);
showRetry = input<boolean>(true);
showReload = input<boolean>(false);
showBack = input<boolean>(true);
showDetails = input<boolean>(false);
showSupport = input<boolean>(true);
// Output events
retry = output<void>();
// Local state
detailsExpanded = false;
constructor(private router: Router) {}
/**
* Get appropriate icon based on error type
*/
getIcon(): string {
switch (this.errorType()) {
case '500':
return 'report_problem';
case '404':
return 'search_off';
case '403':
return 'lock';
case '401':
return 'person_off';
case 'network':
return 'cloud_off';
default:
return 'error_outline';
}
}
/**
* Toggle error details visibility
*/
toggleDetails(): void {
this.detailsExpanded = !this.detailsExpanded;
}
/**
* Emit retry event
*/
onRetry(): void {
this.retry.emit();
}
/**
* Reload the page
*/
reloadPage(): void {
window.location.reload();
}
/**
* Navigate to home page
*/
goHome(): void {
this.router.navigate(['/']);
}
/**
* Navigate back in history
*/
goBack(): void {
if (window.history.length > 1) {
window.history.back();
} else {
this.goHome();
}
}
}

View File

@@ -15,6 +15,11 @@
<span class="logo-text">Interview Quiz</span>
</div>
<!-- Search (Desktop Only) -->
<div class="search-wrapper desktop-only">
<app-search></app-search>
</div>
<!-- Spacer -->
<div class="spacer"></div>

View File

@@ -59,6 +59,17 @@
}
}
.search-wrapper {
flex: 1;
max-width: 600px;
margin: 0 var(--spacing-lg);
@media (max-width: 1024px) {
max-width: 400px;
margin: 0 var(--spacing-md);
}
}
.spacer {
flex: 1;
}

View File

@@ -17,6 +17,7 @@ import { GuestService } from '../../../core/services/guest.service';
import { QuizService } from '../../../core/services/quiz.service';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dialog';
import { SearchComponent } from '../search/search.component';
@Component({
selector: 'app-header',
@@ -31,7 +32,8 @@ import { ResumeQuizDialogComponent } from '../resume-quiz-dialog/resume-quiz-dia
MatDividerModule,
MatDialogModule,
MatProgressSpinnerModule,
MatChipsModule
MatChipsModule,
SearchComponent
],
templateUrl: './header.html',
styleUrl: './header.scss'

View File

@@ -1,6 +1,11 @@
<div class="loading-spinner-container" [class.overlay]="overlay()">
<div
class="loading-spinner-container"
[class.overlay]="overlay()"
role="status"
aria-live="polite"
[attr.aria-label]="message()">
<div class="spinner-wrapper">
<mat-spinner [diameter]="size()"></mat-spinner>
<p class="loading-message">{{ message() }}</p>
<p class="loading-message" aria-hidden="true">{{ message() }}</p>
</div>
</div>

View File

@@ -0,0 +1,334 @@
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 { MatTooltipModule } from '@angular/material/tooltip';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { PaginationState } from '../../../core/services/pagination.service';
/**
* Pagination Component
* Reusable pagination controls with customizable options
*
* Features:
* - Previous/Next buttons
* - First/Last page buttons
* - Page number buttons with active state
* - "Showing X-Y of Z results" display
* - Page size selector
* - Responsive design (fewer buttons on mobile)
* - Accessible with keyboard navigation and ARIA labels
*
* @example
* <app-pagination
* [state]="paginationState()"
* [pageSizeOptions]="[10, 25, 50, 100]"
* [showPageSizeSelector]="true"
* [showFirstLast]="true"
* [maxVisiblePages]="5"
* (pageChange)="onPageChange($event)"
* (pageSizeChange)="onPageSizeChange($event)">
* </app-pagination>
*/
@Component({
selector: 'app-pagination',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatSelectModule,
MatFormFieldModule
],
template: `
<div class="pagination-container" *ngIf="state()">
<!-- Results info -->
<div class="pagination-info">
<span class="info-text">
Showing <strong>{{ state()!.startIndex }}</strong>
to <strong>{{ state()!.endIndex }}</strong>
of <strong>{{ state()!.totalItems }}</strong> {{ itemLabel() }}
</span>
</div>
<div class="pagination-actions">
<!-- Page size selector -->
@if (showPageSizeSelector()) {
<mat-form-field class="page-size-selector" appearance="outline">
<mat-select
[value]="state()!.pageSize"
(selectionChange)="onPageSizeChange($event.value)"
aria-label="Items per page">
@for (option of pageSizeOptions(); track option) {
<mat-option [value]="option">{{ option }} per page</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- Pagination controls -->
<div class="pagination-controls">
<!-- First page -->
@if (showFirstLast()) {
<button
mat-icon-button
(click)="onPageChange(1)"
[disabled]="!state()!.hasPrev"
matTooltip="First page"
aria-label="Go to first page">
<mat-icon>first_page</mat-icon>
</button>
}
<!-- Previous page -->
<button
mat-icon-button
(click)="onPageChange(state()!.currentPage - 1)"
[disabled]="!state()!.hasPrev"
matTooltip="Previous page"
aria-label="Go to previous page">
<mat-icon>chevron_left</mat-icon>
</button>
<!-- Page numbers -->
<div class="page-numbers">
@for (page of pageNumbers(); track page) {
@if (page === '...') {
<span class="ellipsis">{{ page }}</span>
} @else {
<button
mat-button
class="page-button"
[class.active]="page === state()!.currentPage"
(click)="handlePageClick(page)"
[attr.aria-label]="'Go to page ' + page"
[attr.aria-current]="page === state()!.currentPage ? 'page' : null">
{{ page }}
</button>
}
}
</div>
<!-- Next page -->
<button
mat-icon-button
(click)="onPageChange(state()!.currentPage + 1)"
[disabled]="!state()!.hasNext"
matTooltip="Next page"
aria-label="Go to next page">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Last page -->
@if (showFirstLast()) {
<button
mat-icon-button
(click)="onPageChange(state()!.totalPages)"
[disabled]="!state()!.hasNext"
matTooltip="Last page"
aria-label="Go to last page">
<mat-icon>last_page</mat-icon>
</button>
}
</div>
</div>
</div>
`,
styles: [`
.pagination-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--surface-color, #fff);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-info {
display: flex;
align-items: center;
justify-content: center;
}
.info-text {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.info-text strong {
color: var(--text-primary, #333);
font-weight: 600;
}
.pagination-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-size-selector {
min-width: 140px;
}
.page-size-selector ::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-numbers {
display: flex;
align-items: center;
gap: 0.25rem;
}
.page-button {
min-width: 40px;
height: 40px;
padding: 0 8px;
border-radius: 4px;
transition: all 0.2s ease;
}
.page-button:hover {
background-color: var(--hover-color, rgba(0, 0, 0, 0.04));
}
.page-button.active {
background-color: var(--primary-color, #1976d2);
color: white;
font-weight: 600;
}
.page-button.active:hover {
background-color: var(--primary-dark, #1565c0);
}
.ellipsis {
padding: 0 8px;
color: var(--text-secondary, #666);
user-select: none;
}
/* Responsive design */
@media (max-width: 768px) {
.pagination-container {
padding: 0.75rem;
}
.pagination-actions {
flex-direction: column;
align-items: stretch;
}
.page-size-selector {
width: 100%;
}
.pagination-controls {
justify-content: center;
}
.page-numbers {
gap: 0.125rem;
}
.page-button {
min-width: 36px;
height: 36px;
font-size: 0.875rem;
}
/* Hide ellipsis on very small screens */
@media (max-width: 480px) {
.ellipsis {
display: none;
}
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.pagination-container {
background: var(--surface-dark, #1e1e1e);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.info-text {
color: var(--text-secondary-dark, #b0b0b0);
}
.info-text strong {
color: var(--text-primary-dark, #e0e0e0);
}
.page-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.ellipsis {
color: var(--text-secondary-dark, #b0b0b0);
}
}
/* Focus styles for accessibility */
.page-button:focus-visible,
button:focus-visible {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
/* Disabled state */
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
`]
})
export class PaginationComponent {
// Input signals
state = input.required<PaginationState | null>();
pageNumbers = input<(number | string)[]>([]);
pageSizeOptions = input<number[]>([10, 25, 50, 100]);
showPageSizeSelector = input<boolean>(true);
showFirstLast = input<boolean>(true);
maxVisiblePages = input<number>(5);
itemLabel = input<string>('results');
// Output events
pageChange = output<number>();
pageSizeChange = output<number>();
/**
* Handle page change
*/
onPageChange(page: number): void {
if (page >= 1 && page <= (this.state()?.totalPages ?? 1)) {
this.pageChange.emit(page);
}
}
/**
* Handle page button click (for page numbers)
*/
handlePageClick(page: number | string): void {
if (typeof page === 'number') {
this.onPageChange(page);
}
}
/**
* Handle page size change
*/
onPageSizeChange(pageSize: number): void {
this.pageSizeChange.emit(pageSize);
}
}

View File

@@ -0,0 +1,166 @@
<div class="search-container">
<!-- Search Input -->
<div class="search-input-wrapper">
<mat-form-field appearance="outline" class="search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#searchInput
matInput
type="text"
placeholder="Search questions, categories, quizzes..."
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchInput($event)"
(keydown)="onKeyDown($event)"
autocomplete="off"
/>
@if (searchQuery()) {
<button
mat-icon-button
matSuffix
(click)="clearSearch()"
aria-label="Clear search">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div>
<!-- Search Results Dropdown -->
@if (showDropdown()) {
<div class="search-dropdown" role="listbox">
<!-- Loading State -->
@if (isSearching()) {
<div class="search-loading">
<mat-spinner diameter="40"></mat-spinner>
<p>Searching...</p>
</div>
}
<!-- Empty State -->
@else if (isEmptySearch()) {
<div class="search-empty">
<mat-icon>search_off</mat-icon>
<p>No results found for "<strong>{{ searchQuery() }}</strong>"</p>
<span class="hint">Try different keywords or check your spelling</span>
</div>
}
<!-- Results -->
@else if (hasResults()) {
<div class="search-results">
<!-- Categories Section -->
@if (searchResults().categories.length > 0) {
<div class="results-section">
<div class="section-header">
<mat-icon>category</mat-icon>
<span>Categories</span>
<span class="count">{{ searchResults().categories.length }}</span>
</div>
@for (category of searchResults().categories; track category.id) {
<div
class="result-item"
[class.selected]="isSelected(category)"
(click)="navigateToResult(category)"
role="option">
<mat-icon class="result-icon">{{ category.icon || 'category' }}</mat-icon>
<div class="result-content">
<div class="result-title" [innerHTML]="category.highlight || category.title"></div>
@if (category.description) {
<div class="result-description">{{ category.description }}</div>
}
</div>
<mat-icon class="navigate-icon">chevron_right</mat-icon>
</div>
}
</div>
}
<!-- Questions Section -->
@if (searchResults().questions.length > 0) {
@if (searchResults().categories.length > 0) {
<mat-divider></mat-divider>
}
<div class="results-section">
<div class="section-header">
<mat-icon>quiz</mat-icon>
<span>Questions</span>
<span class="count">{{ searchResults().questions.length }}</span>
</div>
@for (question of searchResults().questions; track question.id) {
<div
class="result-item"
[class.selected]="isSelected(question)"
(click)="navigateToResult(question)"
role="option">
<mat-icon class="result-icon">quiz</mat-icon>
<div class="result-content">
<div class="result-title" [innerHTML]="question.highlight || question.title"></div>
<div class="result-meta">
@if (question.category) {
<span class="meta-item">
<mat-icon>category</mat-icon>
{{ question.category }}
</span>
}
@if (question.difficulty) {
<mat-chip [color]="getDifficultyColor(question.difficulty)" class="difficulty-chip">
{{ question.difficulty }}
</mat-chip>
}
</div>
</div>
<mat-icon class="navigate-icon">chevron_right</mat-icon>
</div>
}
</div>
}
<!-- Quizzes Section -->
@if (searchResults().quizzes.length > 0) {
@if (searchResults().categories.length > 0 || searchResults().questions.length > 0) {
<mat-divider></mat-divider>
}
<div class="results-section">
<div class="section-header">
<mat-icon>assessment</mat-icon>
<span>Quiz History</span>
<span class="count">{{ searchResults().quizzes.length }}</span>
</div>
@for (quiz of searchResults().quizzes; track quiz.id) {
<div
class="result-item"
[class.selected]="isSelected(quiz)"
(click)="navigateToResult(quiz)"
role="option">
<mat-icon class="result-icon">assessment</mat-icon>
<div class="result-content">
<div class="result-title">{{ quiz.title }}</div>
@if (quiz.description) {
<div class="result-description">{{ quiz.description }}</div>
}
</div>
<mat-icon class="navigate-icon">chevron_right</mat-icon>
</div>
}
</div>
}
<!-- See All Results Link -->
<mat-divider></mat-divider>
<div class="see-all-link">
<button mat-button color="primary" (click)="viewAllResults()">
<mat-icon>open_in_new</mat-icon>
See all {{ searchResults().totalResults }} results
</button>
</div>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,352 @@
.search-container {
position: relative;
width: 100%;
max-width: 600px;
@media (max-width: 768px) {
max-width: 100%;
}
}
// Search Input
.search-input-wrapper {
position: relative;
.search-field {
width: 100%;
::ng-deep {
.mat-mdc-form-field-infix {
padding: 0.5rem 0;
}
.mat-mdc-text-field-wrapper {
padding: 0;
}
input {
font-size: 0.95rem;
}
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
}
}
// Search Dropdown
.search-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
background: var(--surface-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 500px;
overflow-y: auto;
z-index: 1000;
animation: dropdownSlide 0.2s ease-out;
@media (max-width: 768px) {
max-height: 400px;
}
@keyframes dropdownSlide {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-color);
border-radius: 3px;
&:hover {
background: var(--scrollbar-hover-color);
}
}
}
// Loading State
.search-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
gap: 1rem;
p {
margin: 0;
font-size: 0.95rem;
color: var(--text-secondary);
}
}
// Empty State
.search-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
gap: 0.75rem;
text-align: center;
mat-icon {
font-size: 3rem;
width: 3rem;
height: 3rem;
color: var(--text-disabled);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
strong {
color: var(--primary-color);
}
}
.hint {
font-size: 0.875rem;
color: var(--text-secondary);
}
}
// Results
.search-results {
padding: 0.5rem 0;
}
.results-section {
padding: 0.5rem 0;
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
mat-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
}
.count {
margin-left: auto;
padding: 0.125rem 0.5rem;
background-color: var(--chip-background);
border-radius: 12px;
font-size: 0.75rem;
}
}
}
// Result Item
.result-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover,
&.selected {
background-color: var(--hover-background);
}
&.selected {
border-left: 3px solid var(--primary-color);
padding-left: calc(1rem - 3px);
}
.result-icon {
flex-shrink: 0;
font-size: 1.5rem;
width: 1.5rem;
height: 1.5rem;
color: var(--icon-color);
}
.result-content {
flex: 1;
min-width: 0;
.result-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
margin-bottom: 0.25rem;
// Truncate long titles
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
::ng-deep mark {
background-color: var(--highlight-background);
color: var(--highlight-text);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-weight: 600;
}
}
.result-description {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
margin-top: 0.25rem;
// Truncate long descriptions
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.result-meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.difficulty-chip {
height: 20px;
font-size: 0.75rem;
padding: 0 0.5rem;
::ng-deep .mdc-evolution-chip__action--primary {
padding: 0 0.5rem;
}
}
}
}
.navigate-icon {
flex-shrink: 0;
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
color: var(--text-disabled);
}
}
// See All Link
.see-all-link {
padding: 0.5rem 1rem;
text-align: center;
button {
width: 100%;
justify-content: center;
mat-icon {
margin-right: 0.5rem;
}
}
}
// Dark Mode
@media (prefers-color-scheme: dark) {
.search-container {
--surface-color: #1e1e1e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-disabled: #606060;
--primary-color: #2196f3;
--icon-color: #90caf9;
--hover-background: rgba(255, 255, 255, 0.08);
--chip-background: rgba(255, 255, 255, 0.1);
--highlight-background: rgba(33, 150, 243, 0.3);
--highlight-text: #ffffff;
--scrollbar-color: rgba(255, 255, 255, 0.2);
--scrollbar-hover-color: rgba(255, 255, 255, 0.3);
}
}
// Light Mode
@media (prefers-color-scheme: light) {
.search-container {
--surface-color: #ffffff;
--text-primary: #212121;
--text-secondary: #757575;
--text-disabled: #bdbdbd;
--primary-color: #1976d2;
--icon-color: #1976d2;
--hover-background: rgba(0, 0, 0, 0.04);
--chip-background: rgba(0, 0, 0, 0.08);
--highlight-background: rgba(33, 150, 243, 0.2);
--highlight-text: #0d47a1;
--scrollbar-color: rgba(0, 0, 0, 0.2);
--scrollbar-hover-color: rgba(0, 0, 0, 0.3);
}
}
// Accessibility
@media (prefers-reduced-motion: reduce) {
.search-dropdown {
animation: none;
}
.result-item {
transition: none;
}
}
// Focus styles
.search-field {
::ng-deep input:focus {
outline: none;
}
}
.result-item:focus {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}

View File

@@ -0,0 +1,313 @@
import { Component, OnInit, inject, signal, effect, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDividerModule } from '@angular/material/divider';
import { MatChipsModule } from '@angular/material/chips';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { SearchService, SearchResultItem, SearchResultType } from '../../../core/services/search.service';
/**
* SearchComponent
*
* Global search component for searching across questions, categories, and quizzes.
*
* Features:
* - Debounced search input (500ms)
* - Dropdown results with grouped sections
* - Keyboard navigation (arrow keys, enter, escape)
* - Click outside to close
* - Loading indicator
* - Empty state
* - Result highlighting
* - "See All Results" link
* - Responsive design
*/
@Component({
selector: 'app-search',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatProgressSpinnerModule,
MatDividerModule,
MatChipsModule
],
templateUrl: './search.component.html',
styleUrl: './search.component.scss'
})
export class SearchComponent implements OnInit {
private readonly searchService = inject(SearchService);
private readonly router = inject(Router);
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
// Service signals
readonly searchResults = this.searchService.searchResults;
readonly isSearching = this.searchService.isSearching;
readonly hasSearched = this.searchService.hasSearched;
// Component state
readonly searchQuery = signal<string>('');
readonly showDropdown = signal<boolean>(false);
readonly selectedIndex = signal<number>(-1);
// Search input subject for debouncing
private searchSubject = new Subject<string>();
// Flattened results for keyboard navigation
private flatResults: SearchResultItem[] = [];
ngOnInit(): void {
this.setupSearchDebounce();
this.setupClickOutside();
}
/**
* Setup debounced search
*/
private setupSearchDebounce(): void {
this.searchSubject
.pipe(
debounceTime(500),
distinctUntilChanged()
)
.subscribe((query) => {
if (query.trim().length >= 2) {
this.searchService.search(query).subscribe(() => {
this.showDropdown.set(true);
this.updateFlatResults();
});
} else {
this.clearSearch();
}
});
}
/**
* Setup click outside to close dropdown
*/
private setupClickOutside(): void {
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const searchContainer = document.querySelector('.search-container');
if (searchContainer && !searchContainer.contains(target)) {
this.closeDropdown();
}
});
}
/**
* Handle search input change
*/
onSearchInput(query: string): void {
this.searchQuery.set(query);
this.searchSubject.next(query);
if (query.trim().length < 2) {
this.closeDropdown();
}
}
/**
* Handle keyboard navigation
*/
onKeyDown(event: KeyboardEvent): void {
const key = event.key;
if (!this.showDropdown()) return;
switch (key) {
case 'ArrowDown':
event.preventDefault();
this.navigateDown();
break;
case 'ArrowUp':
event.preventDefault();
this.navigateUp();
break;
case 'Enter':
event.preventDefault();
this.selectCurrentResult();
break;
case 'Escape':
event.preventDefault();
this.closeDropdown();
break;
}
}
/**
* Navigate down in results
*/
private navigateDown(): void {
const maxIndex = this.flatResults.length - 1;
if (this.selectedIndex() < maxIndex) {
this.selectedIndex.update(i => i + 1);
this.scrollToSelected();
}
}
/**
* Navigate up in results
*/
private navigateUp(): void {
if (this.selectedIndex() > 0) {
this.selectedIndex.update(i => i - 1);
this.scrollToSelected();
}
}
/**
* Select current highlighted result
*/
private selectCurrentResult(): void {
const index = this.selectedIndex();
if (index >= 0 && index < this.flatResults.length) {
this.navigateToResult(this.flatResults[index]);
}
}
/**
* Scroll to selected result in dropdown
*/
private scrollToSelected(): void {
setTimeout(() => {
const selected = document.querySelector('.result-item.selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, 0);
}
/**
* Update flat results array for keyboard navigation
*/
private updateFlatResults(): void {
const results = this.searchResults();
this.flatResults = [
...results.categories,
...results.questions,
...results.quizzes
];
this.selectedIndex.set(-1);
}
/**
* Navigate to search result
*/
navigateToResult(result: SearchResultItem): void {
if (result.url) {
this.router.navigate([result.url]);
this.closeDropdown();
this.clearSearch();
}
}
/**
* Navigate to full search results page
*/
viewAllResults(): void {
this.router.navigate(['/search'], {
queryParams: { q: this.searchQuery() }
});
this.closeDropdown();
}
/**
* Clear search
*/
clearSearch(): void {
this.searchQuery.set('');
this.searchService.clearResults();
this.showDropdown.set(false);
this.selectedIndex.set(-1);
this.flatResults = [];
}
/**
* Close dropdown
*/
closeDropdown(): void {
this.showDropdown.set(false);
this.selectedIndex.set(-1);
}
/**
* Focus search input
*/
focusSearch(): void {
this.searchInput?.nativeElement.focus();
}
/**
* Get icon for result type
*/
getTypeIcon(type: SearchResultType): string {
switch (type) {
case 'question':
return 'quiz';
case 'category':
return 'category';
case 'quiz':
return 'assessment';
default:
return 'search';
}
}
/**
* Get chip color for difficulty
*/
getDifficultyColor(difficulty?: string): string {
if (!difficulty) return '';
switch (difficulty.toLowerCase()) {
case 'easy':
return 'primary';
case 'medium':
return 'accent';
case 'hard':
return 'warn';
default:
return '';
}
}
/**
* Check if result is selected
*/
isSelected(result: SearchResultItem): boolean {
const index = this.selectedIndex();
if (index < 0) return false;
return this.flatResults[index]?.id === result.id &&
this.flatResults[index]?.type === result.type;
}
/**
* Check if there are any results
*/
hasResults(): boolean {
const results = this.searchResults();
return results.totalResults > 0;
}
/**
* Check if search is empty
*/
isEmptySearch(): boolean {
return this.hasSearched() && !this.hasResults() && !this.isSearching();
}
}