Add notification centre and realtime toasts
This commit is contained in:
1 parent
b400042244
commit
a85b99ceb6
5 files changed
+515
-12
No files matched your search
@@ -2,12 +2,13 @@ import { useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { navigationItems } from './navigation.js';
|
||||
import { ShellStatusPanel } from './shell-status-panel.js';
|
||||
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
|
||||
|
||||
export function AppShell() {
|
||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<NotificationProvider><div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
||||
<div className="brand">
|
||||
@@ -16,6 +17,7 @@ export function AppShell() {
|
||||
<strong>Reddit Bot</strong>
|
||||
<span>Operator console</span>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<nav aria-label="Primary navigation" id="primary-navigation">
|
||||
{navigationItems.map((item) => (
|
||||
@@ -45,21 +47,25 @@ export function AppShell() {
|
||||
<div className="workspace">
|
||||
<header className="mobile-header">
|
||||
<span>Reddit Bot</span>
|
||||
<button
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={isNavigationOpen}
|
||||
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
className="icon-button"
|
||||
onClick={() => setIsNavigationOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
<div className="mobile-header-actions">
|
||||
<NotificationBell />
|
||||
<button
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={isNavigationOpen}
|
||||
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
className="icon-button"
|
||||
onClick={() => setIsNavigationOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationViewport />
|
||||
</div></NotificationProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
color: #dfe9e3;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: .7rem;
|
||||
}
|
||||
.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
|
||||
.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||
.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
|
||||
.brand { justify-content: space-between; }
|
||||
.brand > div { margin-right: auto; }
|
||||
.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
|
||||
.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
|
||||
.notification-centre.is-open { visibility: visible; transform: translateX(0); }
|
||||
.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
|
||||
.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
|
||||
.notification-centre .eyebrow { margin-bottom: .25rem; }
|
||||
.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
|
||||
.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
|
||||
.notification-list li { border-bottom: 1px solid var(--border); }
|
||||
.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
|
||||
a.notification-entry:hover { background: var(--surface); }
|
||||
.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
|
||||
.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
|
||||
.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
|
||||
.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
|
||||
.notification-copy { display: grid; gap: .28rem; min-width: 0; }
|
||||
.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
|
||||
.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
|
||||
.notification-copy time { color: #71857b; font-size: .7rem; }
|
||||
.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
|
||||
.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
|
||||
.notification-clear:hover { color: #edf5f0; }
|
||||
.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
|
||||
.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
|
||||
.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
|
||||
.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
|
||||
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); pointer-events: none; }
|
||||
.notification-toast { position: relative; overflow: hidden; background: #15221c; border: 1px solid #355044; border-radius: .85rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .4); pointer-events: auto; animation: notification-arrive 220ms ease-out both; }
|
||||
.notification-toast.warning { background: #282112; border-color: #5b4922; }
|
||||
.notification-toast.error { background: #2d1715; border-color: #69332e; }
|
||||
.notification-toast > a, .notification-toast > div { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .75rem; padding: .9rem 2.75rem .9rem 1rem; color: inherit; text-decoration: none; }
|
||||
.notification-toast > a:hover { background: rgba(255, 255, 255, .035); }
|
||||
.notification-toast .notification-copy time { display: none; }
|
||||
.notification-toast > button { position: absolute; top: .5rem; right: .5rem; width: 1.8rem; height: 1.8rem; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: .35rem; font-size: 1.2rem; }
|
||||
@keyframes notification-arrive { from { opacity: 0; transform: translateY(.75rem) scale(.98); } }
|
||||
@media (prefers-reduced-motion: reduce) { .notification-centre, .notification-toast { transition: none; animation: none; } }
|
||||
@media (max-width: 47.999rem) { .brand .notification-bell { display: none; } .toast-stack { bottom: .75rem; } }
|
||||
@media (min-width: 48rem) { .mobile-header .notification-bell { display: none; } }
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { useDashboardRealtime } from './realtime-provider.js';
|
||||
import './notifications.css';
|
||||
|
||||
export type NotificationTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
occurredAt: string;
|
||||
tone: NotificationTone;
|
||||
href?: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export type NotificationDraft = Omit<NotificationItem, 'isRead'>;
|
||||
|
||||
interface NotificationContextValue {
|
||||
isOpen: boolean;
|
||||
notifications: readonly NotificationItem[];
|
||||
toasts: readonly NotificationItem[];
|
||||
unreadCount: number;
|
||||
clearAll(): void;
|
||||
dismissToast(id: string): void;
|
||||
setOpen(isOpen: boolean): void;
|
||||
}
|
||||
|
||||
interface RealtimeSubscription {
|
||||
subscribe(listener: (event: DashboardEvent) => void): () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'reddit-bot.notifications.v1';
|
||||
const MAX_NOTIFICATIONS = 50;
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
const StoredNotificationsSchema = z.array(z.strictObject({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
occurredAt: z.string(),
|
||||
tone: z.enum(['info', 'success', 'warning', 'error']),
|
||||
href: z.string().optional(),
|
||||
isRead: z.boolean(),
|
||||
}));
|
||||
|
||||
function notificationStorage(): Storage | undefined {
|
||||
try {
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' ');
|
||||
}
|
||||
|
||||
export function notificationForEvent(event: DashboardEvent): NotificationDraft | null {
|
||||
switch (event.type) {
|
||||
case 'reply.received':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `New reply from u/${event.payload.sender}`,
|
||||
message: event.payload.preview?.trim() || 'A new message arrived in this conversation.',
|
||||
tone: 'success',
|
||||
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
|
||||
};
|
||||
case 'message.sent':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Message sent to u/${event.payload.recipient}`,
|
||||
message: 'The message was delivered successfully.',
|
||||
tone: 'success',
|
||||
href: event.payload.conversationId
|
||||
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||
: '/conversations',
|
||||
};
|
||||
case 'message.failed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Message to u/${event.payload.recipient} failed`,
|
||||
message: event.payload.reason,
|
||||
tone: 'error',
|
||||
href: event.payload.conversationId
|
||||
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||
: '/operations',
|
||||
};
|
||||
case 'bot.status.changed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Bot ${event.payload.status}`,
|
||||
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
|
||||
tone: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'sync.completed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `${titleCase(event.payload.kind)} sync complete`,
|
||||
message: `${event.payload.processed.toLocaleString()} items processed.`,
|
||||
tone: 'success',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'sync.failed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `${titleCase(event.payload.kind)} sync failed`,
|
||||
message: event.payload.reason,
|
||||
tone: 'error',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'rate-limit.changed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: event.payload.limited ? 'Rate limit reached' : 'Rate limit cleared',
|
||||
message: event.payload.reason ?? (event.payload.limited
|
||||
? 'Sending will resume when the limit resets.'
|
||||
: 'The bot can send messages again.'),
|
||||
tone: event.payload.limited ? 'warning' : 'success',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'reddit.session.changed':
|
||||
if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: event.payload.status === 'authenticated' ? 'Reddit account connected' : 'Reddit account needs attention',
|
||||
message: event.payload.reason ?? (event.payload.username
|
||||
? `Signed in as u/${event.payload.username}.`
|
||||
: `Session status changed to ${event.payload.status}.`),
|
||||
tone: event.payload.status === 'authenticated' ? 'success' : 'error',
|
||||
href: '/settings',
|
||||
};
|
||||
case 'conversation.updated':
|
||||
case 'sync.started':
|
||||
case 'sync.progress':
|
||||
case 'log.created':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadNotifications(): NotificationItem[] {
|
||||
const storage = notificationStorage();
|
||||
if (!storage) return [];
|
||||
try {
|
||||
const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]');
|
||||
const parsed = StoredNotificationsSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationProviderProps extends PropsWithChildren {
|
||||
realtime?: RealtimeSubscription;
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) {
|
||||
const dashboardRealtime = useDashboardRealtime();
|
||||
const realtime = realtimeOverride ?? dashboardRealtime;
|
||||
const [isOpen, setIsOpenState] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>(loadNotifications);
|
||||
const [toasts, setToasts] = useState<NotificationItem[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const setOpen = useCallback((nextOpen: boolean) => {
|
||||
setIsOpenState(nextOpen);
|
||||
if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications));
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && notifications.some((item) => !item.isRead)) {
|
||||
setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||
}
|
||||
}, [isOpen, notifications]);
|
||||
|
||||
useEffect(() => realtime.subscribe((event) => {
|
||||
const draft = notificationForEvent(event);
|
||||
if (!draft) return;
|
||||
const item = { ...draft, isRead: false };
|
||||
setNotifications((current) => current.some((entry) => entry.id === item.id)
|
||||
? current
|
||||
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
|
||||
setToasts((current) => current.some((entry) => entry.id === item.id)
|
||||
? current
|
||||
: [...current, item].slice(-3));
|
||||
}), [realtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return undefined;
|
||||
const timers = toasts.map((toast) => globalThis.setTimeout(
|
||||
() => dismissToast(toast.id),
|
||||
toast.tone === 'error' ? 8_000 : 5_000,
|
||||
));
|
||||
return () => timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
}, [dismissToast, toasts]);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnEscape(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
}
|
||||
globalThis.addEventListener('keydown', closeOnEscape);
|
||||
return () => globalThis.removeEventListener('keydown', closeOnEscape);
|
||||
}, [setOpen]);
|
||||
|
||||
const value = useMemo<NotificationContextValue>(() => ({
|
||||
isOpen,
|
||||
notifications,
|
||||
toasts,
|
||||
unreadCount: notifications.filter((item) => !item.isRead).length,
|
||||
clearAll: () => setNotifications([]),
|
||||
dismissToast,
|
||||
setOpen,
|
||||
}), [dismissToast, isOpen, notifications, setOpen, toasts]);
|
||||
|
||||
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
|
||||
}
|
||||
|
||||
function useNotifications(): NotificationContextValue {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) throw new Error('Notifications must be used within NotificationProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationBell({ className = '' }: { className?: string }) {
|
||||
const { isOpen, setOpen, unreadCount } = useNotifications();
|
||||
return (
|
||||
<button
|
||||
aria-controls="notification-centre"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
||||
className={`notification-bell ${className}`.trim()}
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 ? <span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formattedTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
}
|
||||
|
||||
function NotificationContent({ item }: { item: NotificationItem }) {
|
||||
return (
|
||||
<>
|
||||
<span className={`notification-signal ${item.tone}`} aria-hidden="true" />
|
||||
<span className="notification-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.message}</span>
|
||||
<time dateTime={item.occurredAt}>{formattedTime(item.occurredAt)}</time>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationViewport() {
|
||||
const { clearAll, dismissToast, isOpen, notifications, setOpen, toasts } = useNotifications();
|
||||
return (
|
||||
<>
|
||||
{isOpen ? <button aria-label="Close notifications" className="notification-backdrop" onClick={() => setOpen(false)} type="button" /> : null}
|
||||
<aside aria-label="Notifications" className={isOpen ? 'notification-centre is-open' : 'notification-centre'} id="notification-centre">
|
||||
<header>
|
||||
<div>
|
||||
<span className="eyebrow">Activity inbox</span>
|
||||
<h2>Notifications</h2>
|
||||
</div>
|
||||
<button aria-label="Close notification centre" className="notification-close" onClick={() => setOpen(false)} type="button">×</button>
|
||||
</header>
|
||||
{notifications.length > 0 ? (
|
||||
<>
|
||||
<ol className="notification-list">
|
||||
{notifications.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.href ? (
|
||||
<Link className="notification-entry" onClick={() => setOpen(false)} to={item.href}>
|
||||
<NotificationContent item={item} />
|
||||
</Link>
|
||||
) : <div className="notification-entry"><NotificationContent item={item} /></div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<footer><button className="notification-clear" onClick={clearAll} type="button">Clear all</button></footer>
|
||||
</>
|
||||
) : (
|
||||
<div className="notification-empty">
|
||||
<span aria-hidden="true"><BellIcon /></span>
|
||||
<strong>All quiet</strong>
|
||||
<p>Replies, delivery updates, syncs, and account alerts will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<div aria-atomic="false" aria-live="polite" className="toast-stack">
|
||||
{toasts.map((toast) => (
|
||||
<div className={`notification-toast ${toast.tone}`} key={toast.id} role={toast.tone === 'error' ? 'alert' : 'status'}>
|
||||
{toast.href ? (
|
||||
<Link onClick={() => dismissToast(toast.id)} to={toast.href}><NotificationContent item={toast} /></Link>
|
||||
) : <div><NotificationContent item={toast} /></div>}
|
||||
<button aria-label={`Dismiss ${toast.title}`} onClick={() => dismissToast(toast.id)} type="button">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user