diff --git a/.gitignore b/.gitignore
index 8327c8b84..f7d87aa53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,8 +14,8 @@ coverage
out/
next-env.d.ts
-# nest.js
-apps/nestjs/dist
+# artifacts
+packages/db/migrations/*/migrate.cjs
# nitro
.nitro/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 94ed7a3fc..7aea17723 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -8,7 +8,14 @@
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
- "cSpell.words": ["cqmin", "homarr", "superjson", "trpc", "Umami"],
+ "cSpell.words": [
+ "cqmin",
+ "homarr",
+ "Sonarr",
+ "superjson",
+ "trpc",
+ "Umami"
+ ],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
diff --git a/apps/nextjs/public/images/apps/imdb.png b/apps/nextjs/public/images/apps/imdb.png
new file mode 100644
index 000000000..9565159a4
Binary files /dev/null and b/apps/nextjs/public/images/apps/imdb.png differ
diff --git a/apps/nextjs/public/images/apps/lidarr.svg b/apps/nextjs/public/images/apps/lidarr.svg
new file mode 100644
index 000000000..41c5fb58a
--- /dev/null
+++ b/apps/nextjs/public/images/apps/lidarr.svg
@@ -0,0 +1,25 @@
+
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/radarr.svg b/apps/nextjs/public/images/apps/radarr.svg
new file mode 100644
index 000000000..93a4c9232
--- /dev/null
+++ b/apps/nextjs/public/images/apps/radarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/readarr.svg b/apps/nextjs/public/images/apps/readarr.svg
new file mode 100644
index 000000000..faae05f79
--- /dev/null
+++ b/apps/nextjs/public/images/apps/readarr.svg
@@ -0,0 +1 @@
+
diff --git a/apps/nextjs/public/images/apps/sonarr.svg b/apps/nextjs/public/images/apps/sonarr.svg
new file mode 100644
index 000000000..86c9243db
--- /dev/null
+++ b/apps/nextjs/public/images/apps/sonarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/the-tvdb.svg b/apps/nextjs/public/images/apps/the-tvdb.svg
new file mode 100644
index 000000000..b23711d36
--- /dev/null
+++ b/apps/nextjs/public/images/apps/the-tvdb.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/tmdb.png b/apps/nextjs/public/images/apps/tmdb.png
new file mode 100644
index 000000000..9f983b883
Binary files /dev/null and b/apps/nextjs/public/images/apps/tmdb.png differ
diff --git a/apps/nextjs/public/images/apps/truenas.svg b/apps/nextjs/public/images/apps/truenas.svg
new file mode 100644
index 000000000..c3d96ff70
--- /dev/null
+++ b/apps/nextjs/public/images/apps/truenas.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/nextjs/public/images/apps/unraid-alt.svg b/apps/nextjs/public/images/apps/unraid-alt.svg
new file mode 100644
index 000000000..7d695dadc
--- /dev/null
+++ b/apps/nextjs/public/images/apps/unraid-alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/tasks/package.json b/apps/tasks/package.json
index a24f297c8..c4848b872 100644
--- a/apps/tasks/package.json
+++ b/apps/tasks/package.json
@@ -19,19 +19,20 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
+ "@homarr/analytics": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
+ "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
- "@homarr/integrations": "workspace:^0.1.0",
- "@homarr/widgets": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
- "@homarr/analytics": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
+ "@homarr/widgets": "workspace:^0.1.0",
+ "dayjs": "^1.11.11",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"dotenv": "^16.4.5",
diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts
index 9895f50ad..64ef81699 100644
--- a/packages/api/src/middlewares/integration.ts
+++ b/packages/api/src/middlewares/integration.ts
@@ -49,6 +49,7 @@ export const createManyIntegrationMiddleware = (.
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
+ items: true,
},
});
@@ -74,3 +75,49 @@ export const createManyIntegrationMiddleware = (.
});
});
};
+
+export const createManyIntegrationOfOneItemMiddleware = (...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),
+ })),
+ })),
+ },
+ });
+ });
+};
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
new file mode 100644
index 000000000..b4bac4018
--- /dev/null
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -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(item.itemId, integration.id);
+ return await cache.getAsync();
+ }
+ }),
+ );
+ }),
+});
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index 7ece7bd62..f44f05572 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -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,
});
diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts
index c16bc32f7..0b0fb625d 100644
--- a/packages/cron-jobs/src/index.ts
+++ b/packages/cron-jobs/src/index.ts
@@ -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];
diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts
new file mode 100644
index 000000000..bfecfe603
--- /dev/null
+++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts
@@ -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["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(itemForIntegration.id, integration.integrationId);
+ await cache.setAsync(events);
+ }
+ }
+});
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index d36b93687..0d0388a21 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -8,5 +8,6 @@ export const widgetKinds = [
"dnsHoleSummary",
"smartHome-entityState",
"smartHome-executeAutomation",
+ "calendar",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
index 3730ee063..b36a2b668 100644
--- a/packages/integrations/src/base/creator.ts
+++ b/packages/integrations/src/base/creator.ts
@@ -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?`);
}
diff --git a/packages/integrations/src/calendar-types.ts b/packages/integrations/src/calendar-types.ts
new file mode 100644
index 000000000..f9b97b431
--- /dev/null
+++ b/packages/integrations/src/calendar-types.ts
@@ -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;
+ }[];
+}
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index fd241bae3..4162e9e06 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -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";
diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
new file mode 100644
index 000000000..fd1d38552
--- /dev/null
+++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
@@ -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["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 {
+ 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) => {
+ 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,
+ ): z.infer["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): string | undefined => {
+ const bestImage = this.chooseBestImage(event);
+ if (!bestImage) {
+ return undefined;
+ }
+ return bestImage.remoteUrl;
+ };
+
+ public async testConnectionAsync(): Promise {
+ 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,
+});
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index 981a5fcb0..3a688288f 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -1 +1,2 @@
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
+export * from "./calendar-types";
diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts
index 78300c3cf..4d89f8dc6 100644
--- a/packages/redis/src/index.ts
+++ b/packages/redis/src/index.ts
@@ -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 }>(
diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts
index 08288e5c7..74f9fbd12 100644
--- a/packages/redis/src/lib/channel.ts
+++ b/packages/redis/src/lib/channel.ts
@@ -168,6 +168,9 @@ export const createCacheChannel = (name: string, cacheDurationMs: number
};
};
+export const createItemWithIntegrationChannel = (itemId: string, integrationId: string) =>
+ createCacheChannel(`item:${itemId}:integration:${integrationId}`);
+
const queueClient = createRedisConnection();
type WithId = TItem & { _id: string };
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index 83b5d6ec2..ccb338e0b 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -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",
+ },
},
},
},
diff --git a/packages/widgets/src/calendar/calendar-event-list.module.css b/packages/widgets/src/calendar/calendar-event-list.module.css
new file mode 100644
index 000000000..8ff5d2908
--- /dev/null
+++ b/packages/widgets/src/calendar/calendar-event-list.module.css
@@ -0,0 +1,3 @@
+.badge {
+ transform: translateX(-50%);
+}
diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx
new file mode 100644
index 000000000..5c2115cd5
--- /dev/null
+++ b/packages/widgets/src/calendar/calendar-event-list.tsx
@@ -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 (
+
+
+ {events.map((event, eventIndex) => (
+
+
+
+ {event.mediaInformation?.type === "tv" && (
+ {`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}
+ )}
+
+
+
+
+ {event.subName && (
+
+ {event.subName}
+
+ )}
+
+ {event.name}
+
+
+
+
+ {dayjs(event.date.toString()).format("HH:mm")}
+
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+ {event.links.length > 0 && (
+
+ {event.links.map((link) => (
+ : undefined}
+ >
+ {link.name}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/widgets/src/calendar/calender-day.tsx b/packages/widgets/src/calendar/calender-day.tsx
new file mode 100644
index 000000000..95303a704
--- /dev/null
+++ b/packages/widgets/src/calendar/calender-day.tsx
@@ -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 (
+
+
+ 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",
+ }}
+ >
+
+ {date.getDate()}
+
+
+
+
+
+
+
+
+ );
+};
+
+interface NotificationIndicatorProps {
+ events: CalendarEvent[];
+}
+
+const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
+ const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
+ return (
+
+ {notificationEvents.map((notificationEvent) => {
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/packages/widgets/src/calendar/component.module.css b/packages/widgets/src/calendar/component.module.css
new file mode 100644
index 000000000..b3863ab63
--- /dev/null
+++ b/packages/widgets/src/calendar/component.module.css
@@ -0,0 +1,5 @@
+.calendar div[data-month-level] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx
new file mode 100644
index 000000000..02d19fd3f
--- /dev/null
+++ b/packages/widgets/src/calendar/component.tsx
@@ -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 (
+ {
+ const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
+ return ;
+ }}
+ />
+ );
+}
diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts
new file mode 100644
index 000000000..2adf42b13
--- /dev/null
+++ b/packages/widgets/src/calendar/index.ts
@@ -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"));
diff --git a/packages/widgets/src/calendar/serverData.ts b/packages/widgets/src/calendar/serverData.ts
new file mode 100644
index 000000000..147ba267a
--- /dev/null
+++ b/packages/widgets/src/calendar/serverData.ts
@@ -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, undefined> =>
+ item !== null && item !== undefined,
+ )
+ .flatMap((item) => item.data),
+ };
+ } catch (error) {
+ return {
+ initialData: [],
+ };
+ }
+}
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index 61a58a034..0ee0e36b9 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -79,6 +79,7 @@ export interface WidgetDefinition {
export interface WidgetProps {
options: inferOptionsFromDefinition>;
integrationIds: string[];
+ itemId: string | undefined; // undefined when in preview mode
}
type inferServerDataForKind = WidgetImports[TKind] extends {
@@ -90,7 +91,6 @@ type inferServerDataForKind = WidgetImports[TKind] ext
export type WidgetComponentProps = WidgetProps & {
serverData?: inferServerDataForKind;
} & {
- itemId: string | undefined; // undefined when in preview mode
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
width: number;
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 3049fae66..96d5e1711 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -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;
diff --git a/packages/widgets/src/server/runner.tsx b/packages/widgets/src/server/runner.tsx
index 63d588e86..53f960cfe 100644
--- a/packages/widgets/src/server/runner.tsx
+++ b/packages/widgets/src/server/runner.tsx
@@ -45,6 +45,7 @@ const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const data = await loader.default({
...item,
options: optionsWithDefault as never,
+ itemId: item.id,
});
return ;
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d48e2e149..f77aa9f05 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -300,6 +300,9 @@ importers:
'@homarr/widgets':
specifier: workspace:^0.1.0
version: link:../../packages/widgets
+ dayjs:
+ specifier: ^1.11.11
+ version: 1.11.11
dotenv:
specifier: ^16.4.5
version: 16.4.5