add changes
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
379
frontend/src/app/shared/components/error/error.component.ts
Normal file
379
frontend/src/app/shared/components/error/error.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
166
frontend/src/app/shared/components/search/search.component.html
Normal file
166
frontend/src/app/shared/components/search/search.component.html
Normal 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>
|
||||
352
frontend/src/app/shared/components/search/search.component.scss
Normal file
352
frontend/src/app/shared/components/search/search.component.scss
Normal 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;
|
||||
}
|
||||
313
frontend/src/app/shared/components/search/search.component.ts
Normal file
313
frontend/src/app/shared/components/search/search.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user