mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: add notification to packages
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Indicator,
|
||||
Loader,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Bell, CheckCheck } from "lucide-react";
|
||||
|
||||
/** Minimal shape the bell needs to render one row. */
|
||||
export interface NotificationBellItem {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
isRead: boolean;
|
||||
link?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationBellProps {
|
||||
items: NotificationBellItem[];
|
||||
unreadCount: number;
|
||||
loading?: boolean;
|
||||
/** Fired when the dropdown opens — a good place to refetch. */
|
||||
onOpen?: () => void;
|
||||
onItemClick?: (item: NotificationBellItem) => void;
|
||||
onMarkAllRead?: () => void;
|
||||
emptyLabel?: string;
|
||||
/** Extra className for the trigger button (e.g. the app's header "island"). */
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
const timeAgo = (iso: string): string => {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentational notification bell + dropdown. Owns no data or transport — the
|
||||
* hosting app wires react-query/websocket and passes items + handlers. Built on
|
||||
* Mantine primitives to match the freight app headers.
|
||||
*/
|
||||
export function NotificationBell({
|
||||
items,
|
||||
unreadCount,
|
||||
loading = false,
|
||||
onOpen,
|
||||
onItemClick,
|
||||
onMarkAllRead,
|
||||
emptyLabel = "You're all caught up",
|
||||
triggerClassName,
|
||||
}: NotificationBellProps) {
|
||||
const hasUnread = unreadCount > 0;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
width={360}
|
||||
position="bottom-end"
|
||||
offset={8}
|
||||
radius="md"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
color="red"
|
||||
size={16}
|
||||
offset={4}
|
||||
disabled={!hasUnread}
|
||||
label={unreadCount > 99 ? "99+" : unreadCount}
|
||||
styles={{ indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" } }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size={36}
|
||||
aria-label="Notifications"
|
||||
className={triggerClassName}
|
||||
>
|
||||
<Bell size={17} strokeWidth={1.8} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown p={0}>
|
||||
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm">
|
||||
Notifications
|
||||
</Text>
|
||||
{hasUnread && onMarkAllRead && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
leftSection={<CheckCheck size={13} />}
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Menu.Divider m={0} />
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Box py="xl" px="md">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={380} type="hover">
|
||||
<Stack gap={0}>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
px="sm"
|
||||
py="xs"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
style={{
|
||||
cursor: onItemClick ? "pointer" : "default",
|
||||
background: item.isRead
|
||||
? undefined
|
||||
: "var(--mantine-color-blue-light)",
|
||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap={8}>
|
||||
<Text fw={item.isRead ? 500 : 700} size="sm" lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{timeAgo(item.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="xs" lineClamp={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationBell;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { NotificationBell, default } from "./NotificationBell";
|
||||
export type {
|
||||
NotificationBellProps,
|
||||
NotificationBellItem,
|
||||
} from "./NotificationBell";
|
||||
@@ -28,6 +28,12 @@ export type { OperationDatePickerProps } from "./components/OperationDatePicker"
|
||||
export { CountdownTimer } from "./components/CountdownTimer";
|
||||
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||
|
||||
export { NotificationBell } from "./components/NotificationBell";
|
||||
export type {
|
||||
NotificationBellProps,
|
||||
NotificationBellItem,
|
||||
} from "./components/NotificationBell";
|
||||
|
||||
export { Badge } from "./components/badge";
|
||||
// export type { BadgeProps } from "./components/badge";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user