generated from nhcarrigan/template
feat: toast messages for errors and such
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
|||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
provideBrowserGlobalErrorListeners,
|
provideBrowserGlobalErrorListeners,
|
||||||
APP_INITIALIZER,
|
APP_INITIALIZER,
|
||||||
|
ErrorHandler,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
|
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 { AuthService } from './services/auth.service';
|
||||||
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
||||||
import { initializeAuth } from './initializers/auth.initializer';
|
import { initializeAuth } from './initializers/auth.initializer';
|
||||||
|
import { GlobalErrorHandler } from './services/global-error-handler.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(appRoutes),
|
provideRouter(appRoutes),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
|
{
|
||||||
|
provide: ErrorHandler,
|
||||||
|
useClass: GlobalErrorHandler
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
useClass: AuthInterceptor,
|
useClass: AuthInterceptor,
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
|
<app-toast></app-toast>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Component, inject, OnInit } from '@angular/core';
|
|||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { HeaderComponent } from './components/header/header.component';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
import { FooterComponent } from './components/footer/footer.component';
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
|
import { ToastComponent } from './components/toast/toast.component';
|
||||||
import { AnalyticsService } from './services/analytics.service';
|
import { AnalyticsService } from './services/analytics.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [RouterModule, HeaderComponent, FooterComponent],
|
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
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 { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthInterceptor implements HttpInterceptor {
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
private isRefreshing = false;
|
private isRefreshing = false;
|
||||||
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
|
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')) {
|
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
|
||||||
return this.handle401Error(authReq, next);
|
return this.handle401Error(authReq, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show toast for other HTTP errors
|
||||||
|
this.showErrorToast(error);
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -59,6 +64,7 @@ export class AuthInterceptor implements HttpInterceptor {
|
|||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
this.isRefreshing = false;
|
this.isRefreshing = false;
|
||||||
this.refreshTokenSubject.next(false);
|
this.refreshTokenSubject.next(false);
|
||||||
|
this.toast.error('Your session has expired. Please log in again.');
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
return throwError(() => err);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user