feat(ui): notification center with leveled toasts and error capture
- Replace the toast store with a notification store: levels (info/success/ warning/error/fatal), requiresInteraction, and a persisted history. - Move toasts to the top-right, styled per level, with manual dismiss. - Add a bell in the header opening a Notification Center (history, unread badge, 'needs attention' vs info, clear all). - Capture network failures and 5xx responses (api layer) and render crashes (ErrorBoundary) as notifications. - Sound + server-sourced events + per-account settings remain for 6b.
This commit is contained in:
parent
1a01657c1f
commit
42150edb92
9 changed files with 403 additions and 68 deletions
140
frontend/src/lib/notifications.ts
Normal file
140
frontend/src/lib/notifications.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Client-side notification store. Surfaces transient toasts AND keeps a history that
|
||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// callbacks are live-only and dropped on reload.
|
||||
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
export interface NotifAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
level: NotifLevel;
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: NotifAction;
|
||||
requiresInteraction: boolean;
|
||||
ts: number;
|
||||
read: boolean;
|
||||
dismissed: boolean; // transient toast surface closed (still kept in history)
|
||||
}
|
||||
|
||||
export interface NotifyInput {
|
||||
message: string;
|
||||
level?: NotifLevel;
|
||||
title?: string;
|
||||
action?: NotifAction;
|
||||
requiresInteraction?: boolean;
|
||||
}
|
||||
|
||||
const HISTORY_KEY = "subfeed.notifications";
|
||||
const MAX_HISTORY = 100;
|
||||
const DEFAULT_TTL = 9000;
|
||||
const ERROR_TTL = 15000;
|
||||
|
||||
let items: Notification[] = load();
|
||||
let listeners: Array<() => void> = [];
|
||||
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||
|
||||
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
||||
// emits, so we recompute these only when `items` changes.
|
||||
let cachedActive: Notification[] = [];
|
||||
let cachedReversed: Notification[] = [];
|
||||
let cachedUnread = 0;
|
||||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((n) => ({ ...n, action: undefined }) as Notification);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
const slim = items.map(({ action: _action, ...n }) => n);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
cachedActive = items.filter((n) => !n.dismissed);
|
||||
cachedReversed = [...items].reverse();
|
||||
cachedUnread = items.reduce((c, n) => c + (n.read ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
items = items.slice(-MAX_HISTORY);
|
||||
recompute();
|
||||
persist();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
recompute();
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function notify(input: NotifyInput): number {
|
||||
const id = counter++;
|
||||
const requiresInteraction = input.requiresInteraction ?? false;
|
||||
const level = input.level ?? "info";
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
id,
|
||||
level,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
action: input.action,
|
||||
requiresInteraction,
|
||||
ts: Date.now(),
|
||||
read: false,
|
||||
dismissed: false,
|
||||
},
|
||||
];
|
||||
emit();
|
||||
if (!requiresInteraction) {
|
||||
const ttl = level === "error" || level === "fatal" ? ERROR_TTL : DEFAULT_TTL;
|
||||
setTimeout(() => dismiss(id), ttl);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */
|
||||
export function toast(message: string, action?: NotifAction): number {
|
||||
return notify({ message, action });
|
||||
}
|
||||
|
||||
/** Close the transient toast surface; the entry stays in the center's history. */
|
||||
export function dismiss(id: number): void {
|
||||
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
||||
emit();
|
||||
}
|
||||
|
||||
export function markAllRead(): void {
|
||||
if (items.every((n) => n.read)) return;
|
||||
items = items.map((n) => (n.read ? n : { ...n, read: true }));
|
||||
emit();
|
||||
}
|
||||
|
||||
export function clearAll(): void {
|
||||
if (items.length === 0) return;
|
||||
items = [];
|
||||
emit();
|
||||
}
|
||||
|
||||
export const getActiveToasts = (): Notification[] => cachedActive;
|
||||
export const getNotifications = (): Notification[] => cachedReversed;
|
||||
export const getUnreadCount = (): number => cachedUnread;
|
||||
Loading…
Add table
Add a link
Reference in a new issue