feat: add calendar widget (#663)
* feat: add calendar widget * feat: add artifacts to gitignore
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.badge {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
109
packages/widgets/src/calendar/calendar-event-list.tsx
Normal file
109
packages/widgets/src/calendar/calendar-event-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
packages/widgets/src/calendar/calender-day.tsx
Normal file
89
packages/widgets/src/calendar/calender-day.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
packages/widgets/src/calendar/component.module.css
Normal file
5
packages/widgets/src/calendar/component.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.calendar div[data-month-level] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
72
packages/widgets/src/calendar/component.tsx
Normal file
72
packages/widgets/src/calendar/component.tsx
Normal 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} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
packages/widgets/src/calendar/index.ts
Normal file
23
packages/widgets/src/calendar/index.ts
Normal 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"));
|
||||
35
packages/widgets/src/calendar/serverData.ts
Normal file
35
packages/widgets/src/calendar/serverData.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user