feat: multiple improvements to library functionality #50

Merged
naomi merged 12 commits from feat/tickets into main 2026-02-19 16:52:43 -08:00
9 changed files with 283 additions and 1 deletions
Showing only changes of commit 41ade975f9 - Show all commits
+6
View File
@@ -2,6 +2,7 @@ import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
APP_INITIALIZER,
ErrorHandler,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
@@ -9,12 +10,17 @@ import { appRoutes } from './app.routes';
import { AuthService } from './services/auth.service';
import { AuthInterceptor } from './interceptors/auth.interceptor';
import { initializeAuth } from './initializers/auth.initializer';
import { GlobalErrorHandler } from './services/global-error-handler.service';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
{
provide: ErrorHandler,
useClass: GlobalErrorHandler
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
+1
View File
@@ -3,3 +3,4 @@
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
<app-toast></app-toast>
+2 -1
View File
@@ -2,10 +2,11 @@ import { Component, inject, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
import { ToastComponent } from './components/toast/toast.component';
import { AnalyticsService } from './services/analytics.service';
@Component({
imports: [RouterModule, HeaderComponent, FooterComponent],
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss',
@@ -0,0 +1,91 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
animation: slideIn 0.3s ease-out;
border: 2px solid;
background-color: var(--witch-purple);
color: var(--moon-white);
}
.toast-error {
border-color: #ff4444;
background-color: rgba(255, 68, 68, 0.4);
}
.toast-success {
border-color: #44ff88;
background-color: rgba(68, 255, 136, 0.4);
}
.toast-info {
border-color: var(--witch-lavender);
background-color: rgba(200, 162, 200, 0.4);
}
.toast-warning {
border-color: #ffaa44;
background-color: rgba(255, 170, 68, 0.4);
}
.toast-icon {
font-size: 20px;
flex-shrink: 0;
}
.toast-message {
flex: 1;
word-wrap: break-word;
}
.toast-close {
background: none;
border: none;
color: var(--moon-white);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.toast-close:hover {
transform: scale(1.2);
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@@ -0,0 +1,22 @@
<!--
@copyright 2026 NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
-->
<div class="toast-container">
@for (toast of toastService.toastList(); track toast.id) {
<div class="toast toast-{{ toast.type }}" (click)="toastService.remove(toast.id)">
<div class="toast-icon">
@switch (toast.type) {
@case ('error') { ❌ }
@case ('success') { âś… }
@case ('info') { ℹ️ }
@case ('warning') { ⚠️ }
}
</div>
<div class="toast-message">{{ toast.message }}</div>
<button class="toast-close" (click)="toastService.remove(toast.id)">Ă—</button>
</div>
}
</div>
@@ -0,0 +1,18 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject } from '@angular/core';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
templateUrl: './toast.component.html',
styleUrls: ['./toast.component.css']
})
export class ToastComponent {
public toastService = inject(ToastService);
}
@@ -16,11 +16,13 @@ import {
import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ToastService } from '../services/toast.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private router = inject(Router);
private http = inject(HttpClient);
private toast = inject(ToastService);
private isRefreshing = false;
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
@@ -36,6 +38,9 @@ export class AuthInterceptor implements HttpInterceptor {
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
return this.handle401Error(authReq, next);
}
// Show toast for other HTTP errors
this.showErrorToast(error);
return throwError(() => error);
})
);
@@ -59,6 +64,7 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((err) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(false);
this.toast.error('Your session has expired. Please log in again.');
this.router.navigate(['/']);
return throwError(() => err);
})
@@ -76,4 +82,28 @@ export class AuthInterceptor implements HttpInterceptor {
})
);
}
private showErrorToast(error: HttpErrorResponse): void {
let message = 'Something went wrong. Please try again.';
switch (error.status) {
case 400:
message = error.error?.message || 'Invalid request. Please check your input.';
break;
case 403:
message = 'You do not have permission to perform this action.';
break;
case 404:
message = 'Resource not found.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
case 0:
message = 'Network error. Please check your connection.';
break;
}
this.toast.error(message);
}
}
@@ -0,0 +1,43 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { ToastService } from './toast.service';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: Error): void {
console.error('Global error caught:', error);
// Show user-friendly error message
const message = this.getUserFriendlyMessage(error);
this.toast.error(message);
}
private getUserFriendlyMessage(error: Error): string {
// Check for common error types
if (error.message.includes('Http failure')) {
return 'Network error. Please check your connection.';
}
if (error.message.includes('401') || error.message.includes('403')) {
return 'Your session has expired. Please refresh the page.';
}
if (error.message.includes('404')) {
return 'Resource not found.';
}
if (error.message.includes('500')) {
return 'Server error. Please try again later.';
}
// Generic error message
return 'Something went wrong. Please try again.';
}
}
@@ -0,0 +1,70 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
message: string;
type: 'error' | 'success' | 'info' | 'warning';
duration: number;
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
private toasts = signal<Toast[]>([]);
public readonly toastList = this.toasts.asReadonly();
private nextId = 0;
/**
* Show an error toast notification.
*/
error(message: string, duration = 5000): void {
this.addToast(message, 'error', duration);
}
/**
* Show a success toast notification.
*/
success(message: string, duration = 3000): void {
this.addToast(message, 'success', duration);
}
/**
* Show an info toast notification.
*/
info(message: string, duration = 3000): void {
this.addToast(message, 'info', duration);
}
/**
* Show a warning toast notification.
*/
warning(message: string, duration = 4000): void {
this.addToast(message, 'warning', duration);
}
/**
* Remove a toast by ID.
*/
remove(id: number): void {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
private addToast(message: string, type: Toast['type'], duration: number): void {
const id = this.nextId++;
const toast: Toast = { id, message, type, duration };
this.toasts.update(toasts => [...toasts, toast]);
// Auto-remove after duration
setTimeout(() => {
this.remove(id);
}, duration);
}
}