mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
feat: update the ui of the notification
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { CheckCheck, Inbox, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { NotificationItem } from "./NotificationItem";
|
||||
import type {
|
||||
NotificationItemData,
|
||||
ResolveNotificationVisual,
|
||||
} from "./types";
|
||||
|
||||
export interface NotificationDrawerProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Unread items (newest first), already flattened across pages. */
|
||||
unread: NotificationItemData[];
|
||||
/** Read items (newest first), already flattened across pages. */
|
||||
read: NotificationItemData[];
|
||||
unreadCount?: number;
|
||||
/** Initial load spinner (before any page resolves). */
|
||||
loading?: boolean;
|
||||
hasMoreUnread?: boolean;
|
||||
hasMoreRead?: boolean;
|
||||
loadingMoreUnread?: boolean;
|
||||
loadingMoreRead?: boolean;
|
||||
onLoadMoreUnread?: () => void;
|
||||
onLoadMoreRead?: () => void;
|
||||
onItemClick?: (item: NotificationItemData) => void;
|
||||
onMarkAllRead?: () => void;
|
||||
/** App-owned registry: `item` → icon + color. */
|
||||
resolveVisual?: ResolveNotificationVisual;
|
||||
emptyLabel?: string;
|
||||
title?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/** Fires `onVisible` when it scrolls into view within `root`. Infinite-scroll trigger. */
|
||||
function Sentinel({
|
||||
root,
|
||||
onVisible,
|
||||
}: {
|
||||
root: HTMLElement | null;
|
||||
onVisible: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cb = useRef(onVisible);
|
||||
cb.current = onVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) cb.current();
|
||||
},
|
||||
{ root, rootMargin: "160px" },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [root]);
|
||||
|
||||
return <div ref={ref} style={{ height: 1 }} aria-hidden />;
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
children,
|
||||
right,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
background: "var(--mantine-color-body)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.6 }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
{right}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="xs" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right slide-in notification center: an "Unread" section over an "Earlier"
|
||||
* (read) section, each with its own infinite-scroll trigger, in one scroll
|
||||
* surface. Presentational — the host wires data, paging, and the visual registry.
|
||||
*/
|
||||
export function NotificationDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
unread,
|
||||
read,
|
||||
unreadCount,
|
||||
loading = false,
|
||||
hasMoreUnread = false,
|
||||
hasMoreRead = false,
|
||||
loadingMoreUnread = false,
|
||||
loadingMoreRead = false,
|
||||
onLoadMoreUnread,
|
||||
onLoadMoreRead,
|
||||
onItemClick,
|
||||
onMarkAllRead,
|
||||
resolveVisual,
|
||||
emptyLabel = "You're all caught up",
|
||||
title = "Notifications",
|
||||
width = 420,
|
||||
}: NotificationDrawerProps) {
|
||||
const [viewport, setViewport] = useState<HTMLElement | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Capture the scroll viewport so the sentinels observe within it, not the page.
|
||||
useEffect(() => {
|
||||
if (opened) setViewport(viewportRef.current);
|
||||
}, [opened]);
|
||||
|
||||
const totalUnread = unreadCount ?? unread.length;
|
||||
const isEmpty = !loading && unread.length === 0 && read.length === 0;
|
||||
|
||||
const renderItem = (item: NotificationItemData) => (
|
||||
<NotificationItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
visual={resolveVisual?.(item)}
|
||||
onClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size={width}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.35, blur: 2 }}
|
||||
transitionProps={{ transition: "slide-left" }}
|
||||
styles={{
|
||||
content: { display: "flex", flexDirection: "column" },
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py="sm"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={700} size="md">
|
||||
{title}
|
||||
</Text>
|
||||
{totalUnread > 0 && (
|
||||
<Badge color="red" variant="filled" size="sm" radius="sm">
|
||||
{totalUnread > 99 ? "99+" : totalUnread}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{totalUnread > 0 && onMarkAllRead && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
leftSection={<CheckCheck size={13} />}
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size={32}
|
||||
aria-label="Close notifications"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
viewportRef={viewportRef}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{loading && unread.length === 0 && read.length === 0 ? (
|
||||
<LoadingRow />
|
||||
) : isEmpty ? (
|
||||
<Stack align="center" gap="xs" py={64} px="lg">
|
||||
<Inbox
|
||||
size={40}
|
||||
strokeWidth={1.4}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box pb="md">
|
||||
{unread.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>Unread</SectionLabel>
|
||||
<Stack gap={0}>{unread.map(renderItem)}</Stack>
|
||||
{hasMoreUnread && (
|
||||
<Sentinel
|
||||
root={viewport}
|
||||
onVisible={() => onLoadMoreUnread?.()}
|
||||
/>
|
||||
)}
|
||||
{loadingMoreUnread && <LoadingRow />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{read.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>Earlier</SectionLabel>
|
||||
<Stack gap={0}>{read.map(renderItem)}</Stack>
|
||||
{hasMoreRead && (
|
||||
<Sentinel
|
||||
root={viewport}
|
||||
onVisible={() => onLoadMoreRead?.()}
|
||||
/>
|
||||
)}
|
||||
{loadingMoreRead && <LoadingRow />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationDrawer;
|
||||
Reference in New Issue
Block a user