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
|
|
@ -1,29 +1,68 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import { dismiss, getToasts, subscribe } from "../lib/toast";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
dismiss,
|
||||
getActiveToasts,
|
||||
subscribe,
|
||||
type NotifLevel,
|
||||
} from "../lib/notifications";
|
||||
|
||||
export const LEVEL_STYLE: Record<
|
||||
NotifLevel,
|
||||
{ icon: typeof Info; color: string }
|
||||
> = {
|
||||
info: { icon: Info, color: "text-accent" },
|
||||
success: { icon: CheckCircle2, color: "text-emerald-400" },
|
||||
warning: { icon: AlertTriangle, color: "text-amber-400" },
|
||||
error: { icon: AlertCircle, color: "text-red-400" },
|
||||
fatal: { icon: XCircle, color: "text-red-500" },
|
||||
};
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useSyncExternalStore(subscribe, getToasts, getToasts);
|
||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="bg-surface border border-border rounded-xl shadow-2xl px-4 py-3 flex items-center gap-4 animate-[fadeIn_0.15s_ease]"
|
||||
>
|
||||
<span className="text-sm">{t.message}</span>
|
||||
{t.action && (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
||||
{toasts.map((t) => {
|
||||
const { icon: Icon, color } = LEVEL_STYLE[t.level];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="bg-surface border border-border rounded-xl shadow-2xl px-3 py-3 flex items-start gap-3 animate-[fadeIn_0.15s_ease]"
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{t.title && <div className="text-sm font-semibold">{t.title}</div>}
|
||||
<div className="text-sm break-words">{t.message}</div>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
className="text-accent text-sm font-semibold hover:underline"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
title="Dismiss"
|
||||
>
|
||||
{t.action.label}
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue