feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,78 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } 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 { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const t = useScopedI18n("widget.notifications");
|
||||
const t = useI18n();
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const sortedNotifications = useMemo(
|
||||
() =>
|
||||
notificationIntegrations
|
||||
.flatMap((integration) => integration.data)
|
||||
.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 isAdvanced = displayMode === "advanced";
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
|
||||
<Stack w="100%" gap="xs">
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.integration.id} label={integration.error} multiline maw={360}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{integration.integration.name}: {t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{sortedNotifications.length > 0 ? (
|
||||
sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
component={notification.href ? "a" : "div"}
|
||||
href={notification.href}
|
||||
target={notification.href ? "_blank" : undefined}
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p="sm"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<Flex gap="sm" align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap="sm" direction="column" w="100%">
|
||||
{notification.title && (
|
||||
<Text fz="sm" lh="sm" lineClamp={2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
|
||||
{sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.compositeKey}
|
||||
component={notification.href ? "a" : "div"}
|
||||
href={notification.href}
|
||||
target={notification.href ? "_blank" : undefined}
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : "xs"}
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<Flex gap="sm" align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay date={notification.time} />
|
||||
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text c="dimmed" size="sm" lineClamp={isAdvanced ? 12 : 4} style={{ whiteSpace: "pre-line" }}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noItems")}
|
||||
{t("widget.notifications.noItems")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -80,15 +112,21 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
|
||||
);
|
||||
}
|
||||
|
||||
const InfoDisplay = ({ date }: { date: Date }) => {
|
||||
const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
|
||||
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
||||
|
||||
return (
|
||||
<Group gap={5} align={"center"}>
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{timeAgo}
|
||||
</Text>
|
||||
{source && <Text c="dimmed">•</Text>}
|
||||
{source && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{source}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,18 @@ import { IconMessage } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconMessage,
|
||||
queryKey: [["widget", "notifications", "getNotifications"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
widgetQueryInputMatches(input, {
|
||||
hideLogos: scope.options.hideLogos,
|
||||
integrationIds: scope.integrationIds,
|
||||
}),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
hideLogos: factory.switch({ defaultValue: false }),
|
||||
|
||||
Reference in new issue
Block a user