feat: add calendar widget (#663)

* feat: add calendar widget

* feat: add artifacts to gitignore
This commit is contained in:
Manuel
2024-07-02 12:13:13 +02:00
committed by GitHub
parent 83ee03b192
commit dba97a3bd6
37 changed files with 707 additions and 9 deletions

View File

@@ -0,0 +1,3 @@
.badge {
transform: translateX(-50%);
}

View File

@@ -0,0 +1,109 @@
import {
Badge,
Box,
Button,
darken,
Group,
Image,
lighten,
ScrollArea,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
return (
<ScrollArea
offsetScrollbars
pt={5}
w={400}
styles={{
viewport: {
maxHeight: 450,
},
}}
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
<Box pos={"relative"} w={70} h={120}>
<Image src={event.thumbnail} w={70} h={120} radius={"sm"} />
{event.mediaInformation?.type === "tv" && (
<Badge
pos={"absolute"}
bottom={-6}
left={"50%"}
className={classes.badge}
>{`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}</Badge>
)}
</Box>
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify={"space-between"} align={"start"} mb={"xs"} wrap="nowrap">
<Stack gap={0}>
{event.subName && (
<Text lineClamp={1} size="sm">
{event.subName}
</Text>
)}
<Text fw={"bold"} lineClamp={1}>
{event.name}
</Text>
</Stack>
<Group gap={3} wrap="nowrap">
<IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"}>{dayjs(event.date.toString()).format("HH:mm")}</Text>
</Group>
</Group>
{event.description && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</Stack>
</ScrollArea>
);
};

View File

@@ -0,0 +1,89 @@
import { Container, Popover, useMantineTheme } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import type { CalendarEvent } from "@homarr/integrations/types";
import { CalendarEventList } from "./calendar-event-list";
interface CalendarDayProps {
date: Date;
events: CalendarEvent[];
disabled: boolean;
}
export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
const [opened, { close, open }] = useDisclosure(false);
const { primaryColor } = useMantineTheme();
return (
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{
transition: "pop",
}}
onClose={close}
opened={opened}
disabled={disabled}
>
<Popover.Target>
<Container
onClick={events.length > 0 && !opened ? open : close}
h="100%"
w="100%"
p={0}
m={0}
bd={`1cqmin solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{
alignContent: "center",
borderRadius: "3.5cqmin",
cursor: events.length === 0 || disabled ? "default" : "pointer",
}}
>
<div
style={{
textAlign: "center",
whiteSpace: "nowrap",
fontSize: "5cqmin",
lineHeight: "5cqmin",
paddingTop: "1.25cqmin",
}}
>
{date.getDate()}
</div>
<NotificationIndicator events={events} />
</Container>
</Popover.Target>
<Popover.Dropdown>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
);
};
interface NotificationIndicatorProps {
events: CalendarEvent[];
}
const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
return (
<Container h="0.7cqmin" w="80%" display="flex" p={0} style={{ flexDirection: "row", justifyContent: "center" }}>
{notificationEvents.map((notificationEvent) => {
return (
<Container
key={notificationEvent}
bg={notificationEvent}
h="100%"
mx="0.25cqmin"
p={0}
style={{ flex: 1, borderRadius: "1000px" }}
/>
);
})}
</Container>
);
};

View File

@@ -0,0 +1,5 @@
.calendar div[data-month-level] {
width: 100%;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const params = useParams();
const locale = params.locale as string;
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={setMonth}
onNextMonth={setMonth}
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
w="100%"
h="100%"
static={isEditMode}
className={classes.calendar}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
height: "12cqmin",
width: "12cqmin",
borderRadius: "3.5cqmin",
},
calendarHeaderLevel: {
height: "12cqmin",
fontSize: "6cqmin",
pointerEvents: "none",
},
levelsGroup: {
height: "100%",
padding: "2.5cqmin",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
day: {
width: "12cqmin",
height: "12cqmin",
borderRadius: "3.5cqmin",
},
monthCell: {
textAlign: "center",
},
month: {
height: "100%",
},
weekday: {
fontSize: "5.5cqmin",
padding: 0,
},
}}
renderDay={(date) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { IconCalendar } from "@tabler/icons-react";
import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
options: optionsBuilder.from((factory) => ({
filterPastMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
filterFutureMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
})),
supportedIntegrations: ["sonarr", "radarr", "lidarr", "readarr"],
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,35 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
if (!itemId) {
return {
initialData: [],
};
}
try {
const data = await api.widget.calendar.findAllEvents({
integrationIds,
itemId,
});
return {
initialData: data
.filter(
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null && item !== undefined,
)
.flatMap((item) => item.data),
};
} catch (error) {
return {
initialData: [],
};
}
}

View File

@@ -79,6 +79,7 @@ export interface WidgetDefinition {
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
itemId: string | undefined; // undefined when in preview mode
}
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
@@ -90,7 +91,6 @@ type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] ext
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
itemId: string | undefined; // undefined when in preview mode
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
width: number;

View File

@@ -6,6 +6,7 @@ import { Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import * as app from "./app";
import * as calendar from "./calendar";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleSummary from "./dns-hole/summary";
@@ -33,6 +34,7 @@ export const widgetImports = {
dnsHoleSummary,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
calendar,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -45,6 +45,7 @@ const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const data = await loader.default({
...item,
options: optionsWithDefault as never,
itemId: item.id,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};