WIP
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 11000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 420px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
color: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
animation: toast-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
transform: translateY(-6px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-container',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="toast-stack">
|
||||
<div *ngFor="let toast of toastService.toasts()"
|
||||
class="toast"
|
||||
[class.toast-error]="toast.type === 'error'"
|
||||
[class.toast-success]="toast.type === 'success'"
|
||||
[class.toast-info]="toast.type === 'info'"
|
||||
(click)="toastService.dismiss(toast.id)">
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
<button type="button" class="toast-close" aria-label="Dismiss"
|
||||
(click)="toastService.dismiss(toast.id); $event.stopPropagation()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./toast-container.component.scss'],
|
||||
})
|
||||
export class ToastContainerComponent {
|
||||
constructor(public toastService: ToastService) {}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, from, map, Observable, of, switchMap, tap, throwError } from 'rxjs';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
/**
|
||||
* Surfaces every failed HTTP response as a toast so errors are never silent.
|
||||
* 401s are left to the auth interceptor (token refresh / redirect to login).
|
||||
*
|
||||
* Must be registered BEFORE the auth interceptor so its catchError runs LAST
|
||||
* (outermost) — i.e. after auth has had a chance to refresh-and-retry a 401.
|
||||
*/
|
||||
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const toastService = inject(ToastService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Handled by the auth interceptor (silent refresh or redirect to login).
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return resolveMessage(error).pipe(
|
||||
tap(message => toastService.error(message)),
|
||||
switchMap(() => throwError(() => error)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a human-readable message from the error response. The body can be a
|
||||
* parsed object, a string, or a Blob (when the request used responseType:'blob',
|
||||
* e.g. PDF / signature downloads) — Blobs must be read asynchronously.
|
||||
*/
|
||||
function resolveMessage(error: HttpErrorResponse): Observable<string> {
|
||||
const body = error.error;
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return from(body.text()).pipe(map(text => parseBodyText(text, error.status)));
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return of(parseBodyText(body, error.status));
|
||||
}
|
||||
if (body && typeof body === 'object') {
|
||||
const message = (body as { message?: unknown }).message;
|
||||
const title = (body as { title?: unknown }).title;
|
||||
if (typeof message === 'string' && message.length > 0) {
|
||||
return of(message);
|
||||
}
|
||||
if (typeof title === 'string' && title.length > 0) {
|
||||
return of(title);
|
||||
}
|
||||
}
|
||||
return of(defaultMessage(error.status));
|
||||
}
|
||||
|
||||
function parseBodyText(text: string, status: number): string {
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown; title?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message.length > 0) {
|
||||
return parsed.message;
|
||||
}
|
||||
if (typeof parsed.title === 'string' && parsed.title.length > 0) {
|
||||
return parsed.title;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the status-based default.
|
||||
}
|
||||
}
|
||||
return defaultMessage(status);
|
||||
}
|
||||
|
||||
function defaultMessage(status: number): string {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'Cannot reach the server / 無法連線到伺服器';
|
||||
case 400:
|
||||
return 'Invalid request / 請求格式錯誤';
|
||||
case 403:
|
||||
return 'You do not have permission for this action / 沒有權限執行此操作';
|
||||
case 404:
|
||||
return 'The requested item was not found / 找不到要求的資料';
|
||||
case 409:
|
||||
return 'This action conflicts with the current state / 操作與目前狀態衝突';
|
||||
case 422:
|
||||
return 'The submitted data is invalid / 提交的資料無效';
|
||||
case 500:
|
||||
return 'A server error occurred / 伺服器發生錯誤';
|
||||
default:
|
||||
return `Request failed (HTTP ${status}) / 請求失敗 (HTTP ${status})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ToastType = 'error' | 'success' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, dependency-free toast/notification store. A single
|
||||
* ToastContainerComponent (mounted in the app root) renders whatever this holds.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private counter = 0;
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
|
||||
error(message: string): void {
|
||||
this.show('error', message);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.show('success', message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.show('info', message);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts.update(list => list.filter(toast => toast.id !== id));
|
||||
}
|
||||
|
||||
private show(type: ToastType, message: string): void {
|
||||
const id = ++this.counter;
|
||||
this.toasts.update(list => [...list, { id, type, message }]);
|
||||
const autoDismissMs = type === 'error' ? 8000 : 4000;
|
||||
setTimeout(() => this.dismiss(id), autoDismissMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user