feat: add calendar widget (#663)
* feat: add calendar widget * feat: add artifacts to gitignore
This commit is contained in:
@@ -49,6 +49,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
secrets: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,3 +75,49 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
|
||||
return publicProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations = await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
secrets: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
|
||||
integration.items.some((item) => item.itemId === input.itemId),
|
||||
);
|
||||
|
||||
if (dbIntegrationWithItem.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration for item was not found",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrationWithItem.map(({ secrets, kind, ...rest }) => ({
|
||||
...rest,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
20
packages/api/src/router/widgets/calendar.ts
Normal file
20
packages/api/src/router/widgets/calendar.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("sonarr", "radarr", "readarr", "lidarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.flatMap(async (integration) => {
|
||||
for (const item of integration.items) {
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
|
||||
return await cache.getAsync();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
import { appRouter } from "./app";
|
||||
import { calendarRouter } from "./calendar";
|
||||
import { dnsHoleRouter } from "./dns-hole";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
@@ -11,4 +12,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
calendar: calendarRouter,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { analyticsJob } from "./jobs/analytics";
|
||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
@@ -9,6 +10,7 @@ export const jobGroup = createCronJobGroup({
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
57
packages/cron-jobs/src/jobs/integrations/media-organizer.ts
Normal file
57
packages/cron-jobs/src/jobs/integrations/media-organizer.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import dayjs from "dayjs";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { decryptSecret } from "@homarr/common";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { SonarrIntegration } from "@homarr/integrations";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../../widgets";
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await db.query.items.findMany({
|
||||
where: eq(items.kind, "calendar"),
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const integration of itemForIntegration.integrations) {
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
|
||||
|
||||
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
|
||||
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
|
||||
|
||||
const sonarr = new SonarrIntegration({
|
||||
...integration.integration,
|
||||
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const events = await sonarr.getCalendarEventsAsync(start, end);
|
||||
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(itemForIntegration.id, integration.integrationId);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,5 +8,6 @@ export const widgetKinds = [
|
||||
"dnsHoleSummary",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"calendar",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import type { IntegrationInput } from "./integration";
|
||||
|
||||
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
||||
return new PiHoleIntegration(integration);
|
||||
case "homeAssistant":
|
||||
return new HomeAssistantIntegration(integration);
|
||||
case "sonarr":
|
||||
return new SonarrIntegration(integration);
|
||||
default:
|
||||
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
|
||||
}
|
||||
|
||||
20
packages/integrations/src/calendar-types.ts
Normal file
20
packages/integrations/src/calendar-types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface CalendarEvent {
|
||||
name: string;
|
||||
subName: string;
|
||||
date: Date;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
mediaInformation?: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
};
|
||||
links: {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string | undefined;
|
||||
notificationColor?: string | undefined;
|
||||
isDark: boolean | undefined;
|
||||
logo: string;
|
||||
}[];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// General integrations
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
|
||||
// Helpers
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { appendPath } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
|
||||
export class SonarrIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
private readonly priorities: z.infer<typeof sonarCalendarEventSchema>["images"][number]["coverType"][] = [
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the events in the Sonarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = new URL(this.integration.url);
|
||||
url.pathname = "/api/v3/calendar";
|
||||
url.searchParams.append("start", start.toISOString());
|
||||
url.searchParams.append("end", end.toISOString());
|
||||
url.searchParams.append("includeSeries", "true");
|
||||
url.searchParams.append("includeEpisodeFile", "true");
|
||||
url.searchParams.append("includeEpisodeImages", "true");
|
||||
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const sonarCalendarEvents = await z.array(sonarCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return sonarCalendarEvents.map(
|
||||
(sonarCalendarEvent): CalendarEvent => ({
|
||||
name: sonarCalendarEvent.title,
|
||||
subName: sonarCalendarEvent.series.title,
|
||||
description: sonarCalendarEvent.series.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(sonarCalendarEvent),
|
||||
date: sonarCalendarEvent.airDateUtc,
|
||||
mediaInformation: {
|
||||
type: "tv",
|
||||
episodeNumber: sonarCalendarEvent.episodeNumber,
|
||||
seasonNumber: sonarCalendarEvent.seasonNumber,
|
||||
},
|
||||
links: this.getLinksForSonarCalendarEvent(sonarCalendarEvent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
{
|
||||
href: `${this.integration.url}/series/${event.series.titleSlug}`,
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "blue",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.series.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.series.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof sonarCalendarEventSchema>,
|
||||
): z.infer<typeof sonarCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images, ...event.series.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof sonarCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(appendPath(this.integration.url, "/api/ping"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const sonarCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
airDateUtc: z.string().transform((value) => new Date(value)),
|
||||
seasonNumber: z.number().min(0),
|
||||
episodeNumber: z.number().min(0),
|
||||
series: z.object({
|
||||
overview: z.string(),
|
||||
title: z.string(),
|
||||
titleSlug: z.string(),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
}),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./calendar-types";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
|
||||
export { createCacheChannel } from "./lib/channel";
|
||||
export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
|
||||
|
||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||
|
||||
@@ -168,6 +168,9 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
||||
};
|
||||
};
|
||||
|
||||
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
|
||||
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
|
||||
|
||||
const queueClient = createRedisConnection();
|
||||
|
||||
type WithId<TItem> = TItem & { _id: string };
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
},
|
||||
init: {
|
||||
title: "New Homarr installation",
|
||||
subtitle: "Please create the initial administator user",
|
||||
subtitle: "Please create the initial administrator user",
|
||||
},
|
||||
},
|
||||
field: {
|
||||
@@ -907,6 +907,18 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
calendar: {
|
||||
name: "Calendar",
|
||||
description: "Display events from your integrations in a calendar view within a certain relative time period",
|
||||
option: {
|
||||
filterPastMonths: {
|
||||
label: "Start from",
|
||||
},
|
||||
filterFutureMonths: {
|
||||
label: "End at",
|
||||
},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
name: "Weather",
|
||||
description: "Displays the current weather information of a set location.",
|
||||
@@ -1473,6 +1485,9 @@ export default {
|
||||
ping: {
|
||||
label: "Pings",
|
||||
},
|
||||
mediaOrganizer: {
|
||||
label: "Media Organizers",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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