183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
|
import { IconClock } from "@tabler/icons-react";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useRequiredBoard } from "@homarr/boards/context";
|
|
import { useTimeAgo } from "@homarr/common";
|
|
import { useI18n } from "@homarr/translation/client";
|
|
|
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
|
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
import type { WidgetComponentProps } from "../definition";
|
|
import { getNotificationDisplay } from "./display";
|
|
|
|
export default function NotificationsWidget({
|
|
options,
|
|
integrationIds,
|
|
width,
|
|
height,
|
|
displayMode,
|
|
}: WidgetComponentProps<"notifications">) {
|
|
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
|
|
...options,
|
|
integrationIds,
|
|
});
|
|
const notificationData = getUsableWidgetQueryData(notificationsQuery);
|
|
const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]);
|
|
const { isPending } = notificationsQuery;
|
|
|
|
const t = useI18n("widget.notifications");
|
|
const tCommon = useI18n("common");
|
|
|
|
const board = useRequiredBoard();
|
|
|
|
const sortedNotifications = useMemo(
|
|
() =>
|
|
notificationIntegrations
|
|
.flatMap((integration) =>
|
|
integration.data.map((notification) => ({
|
|
...notification,
|
|
compositeKey: `${integration.integration.id}:${notification.id}`,
|
|
integrationName: integration.integration.name,
|
|
})),
|
|
)
|
|
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
|
|
[notificationIntegrations],
|
|
);
|
|
const failedIntegrations = notificationIntegrations.filter(
|
|
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
|
|
);
|
|
const isDense = width < 280 || height < 180;
|
|
const isRoomy = width >= 360 && height >= 220;
|
|
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
|
|
const notificationDisplay = getNotificationDisplay({
|
|
displayMode,
|
|
hideLogos: options.hideLogos,
|
|
isRoomy,
|
|
bodyLineClamp,
|
|
});
|
|
const columns = width >= 720 ? 2 : 1;
|
|
const spacing = isRoomy ? "sm" : "xs";
|
|
|
|
return (
|
|
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
|
<Stack w="100%" gap="xs">
|
|
{notificationsQuery.error && (
|
|
<Group justify="flex-end">
|
|
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("name")} />
|
|
</Group>
|
|
)}
|
|
{failedIntegrations.length > 0 && (
|
|
<Group gap={4} wrap="wrap">
|
|
{failedIntegrations.map((integration) => (
|
|
<Badge key={integration.integration.id} color="red" variant="light" size="xs">
|
|
{integration.integration.name}: {tCommon("error")}
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
)}
|
|
{isPending ? (
|
|
<Flex justify="center" align="center" mih={96} p="sm">
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{tCommon("action.loading")}
|
|
</Text>
|
|
</Flex>
|
|
) : sortedNotifications.length > 0 ? (
|
|
<SimpleGrid cols={columns} spacing={spacing} verticalSpacing={spacing}>
|
|
{sortedNotifications.map((notification) => {
|
|
const href = getSafeApplicationUrl(notification.href);
|
|
return (
|
|
<Card
|
|
key={notification.compositeKey}
|
|
component={href ? "a" : "div"}
|
|
href={href}
|
|
target={href ? "_blank" : undefined}
|
|
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
|
radius={board.itemRadius}
|
|
w="100%"
|
|
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
|
|
bg={columns > 1 ? undefined : "transparent"}
|
|
style={{
|
|
color: "inherit",
|
|
textDecoration: "none",
|
|
borderBottom:
|
|
columns > 1
|
|
? undefined
|
|
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
|
|
}}
|
|
>
|
|
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
|
{notificationDisplay.showLogos && notification.source?.iconUrl && (
|
|
<Avatar
|
|
src={notification.source.iconUrl}
|
|
alt={notification.source.name}
|
|
size={isDense ? "xs" : "sm"}
|
|
radius={board.itemRadius}
|
|
/>
|
|
)}
|
|
|
|
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
|
{notification.title && (
|
|
<Text fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
|
|
{notification.title}
|
|
</Text>
|
|
)}
|
|
<Text
|
|
c="dimmed"
|
|
size={isDense ? "xs" : "sm"}
|
|
lineClamp={notificationDisplay.bodyLineClamp}
|
|
style={{ whiteSpace: "pre-line" }}
|
|
>
|
|
{notification.body}
|
|
</Text>
|
|
|
|
<InfoDisplay
|
|
date={notification.time}
|
|
source={
|
|
notificationDisplay.showSource
|
|
? (notification.source?.name ?? notification.integrationName)
|
|
: undefined
|
|
}
|
|
dense={isDense}
|
|
/>
|
|
</Flex>
|
|
</Flex>
|
|
</Card>
|
|
);
|
|
})}
|
|
</SimpleGrid>
|
|
) : (
|
|
<Flex justify="center" align="center" mih={96} p="sm">
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{t("noItems")}
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
</Stack>
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; dense: boolean }) => {
|
|
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
|
|
|
return (
|
|
<Group gap={5} align="center" wrap="nowrap">
|
|
<IconClock aria-hidden size={dense ? 12 : "1rem"} color="var(--mantine-color-dimmed)" />
|
|
<Text size={dense ? "xs" : "sm"} c="dimmed">
|
|
{timeAgo}
|
|
</Text>
|
|
{source && <Text c="dimmed">•</Text>}
|
|
{source && (
|
|
<Text size="xs" c="dimmed" truncate="end">
|
|
{source}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
);
|
|
};
|