From dba97a3bd6d4b29c9079c9ee6ec98aa974614add Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:13:13 +0200 Subject: [PATCH] feat: add calendar widget (#663) * feat: add calendar widget * feat: add artifacts to gitignore --- .gitignore | 4 +- .vscode/settings.json | 9 +- apps/nextjs/public/images/apps/imdb.png | Bin 0 -> 497 bytes apps/nextjs/public/images/apps/lidarr.svg | 25 ++++ apps/nextjs/public/images/apps/radarr.svg | 1 + apps/nextjs/public/images/apps/readarr.svg | 1 + apps/nextjs/public/images/apps/sonarr.svg | 1 + apps/nextjs/public/images/apps/the-tvdb.svg | 9 ++ apps/nextjs/public/images/apps/tmdb.png | Bin 0 -> 6578 bytes apps/nextjs/public/images/apps/truenas.svg | 1 + apps/nextjs/public/images/apps/unraid-alt.svg | 1 + apps/tasks/package.json | 7 +- packages/api/src/middlewares/integration.ts | 47 ++++++ packages/api/src/router/widgets/calendar.ts | 20 +++ packages/api/src/router/widgets/index.ts | 2 + packages/cron-jobs/src/index.ts | 2 + .../src/jobs/integrations/media-organizer.ts | 57 ++++++++ packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 3 + packages/integrations/src/calendar-types.ts | 20 +++ packages/integrations/src/index.ts | 1 + .../sonarr/sonarr-integration.ts | 137 ++++++++++++++++++ packages/integrations/src/types.ts | 1 + packages/redis/src/index.ts | 2 +- packages/redis/src/lib/channel.ts | 3 + packages/translation/src/lang/en.ts | 17 ++- .../calendar/calendar-event-list.module.css | 3 + .../src/calendar/calendar-event-list.tsx | 109 ++++++++++++++ .../widgets/src/calendar/calender-day.tsx | 89 ++++++++++++ .../widgets/src/calendar/component.module.css | 5 + packages/widgets/src/calendar/component.tsx | 72 +++++++++ packages/widgets/src/calendar/index.ts | 23 +++ packages/widgets/src/calendar/serverData.ts | 35 +++++ packages/widgets/src/definition.ts | 2 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/server/runner.tsx | 1 + pnpm-lock.yaml | 3 + 37 files changed, 707 insertions(+), 9 deletions(-) create mode 100644 apps/nextjs/public/images/apps/imdb.png create mode 100644 apps/nextjs/public/images/apps/lidarr.svg create mode 100644 apps/nextjs/public/images/apps/radarr.svg create mode 100644 apps/nextjs/public/images/apps/readarr.svg create mode 100644 apps/nextjs/public/images/apps/sonarr.svg create mode 100644 apps/nextjs/public/images/apps/the-tvdb.svg create mode 100644 apps/nextjs/public/images/apps/tmdb.png create mode 100644 apps/nextjs/public/images/apps/truenas.svg create mode 100644 apps/nextjs/public/images/apps/unraid-alt.svg create mode 100644 packages/api/src/router/widgets/calendar.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/media-organizer.ts create mode 100644 packages/integrations/src/calendar-types.ts create mode 100644 packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts create mode 100644 packages/widgets/src/calendar/calendar-event-list.module.css create mode 100644 packages/widgets/src/calendar/calendar-event-list.tsx create mode 100644 packages/widgets/src/calendar/calender-day.tsx create mode 100644 packages/widgets/src/calendar/component.module.css create mode 100644 packages/widgets/src/calendar/component.tsx create mode 100644 packages/widgets/src/calendar/index.ts create mode 100644 packages/widgets/src/calendar/serverData.ts 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 0000000000000000000000000000000000000000..9565159a43cfe77978cc941ec36966c9541bca66 GIT binary patch literal 497 zcmVgt_OOb^r2bgL@91!Eh`QP7x9nAK%V<*o6h8isYB}JIPY;Rk3l0qQK zpA(EhY#&=P9bg1>fUO}Lm<>wZxEuhB9-24+l$b#5z9L?%4yaA#fwL!;Afg}^7zLp& z9F#=?$N{a{5R2FM3SxBt3^Oq?{69Kh7#;$wEC{*EL~g + + + + + + + + + + + + \ 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 @@ + + + Logo tvdb + + + + + + \ 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 0000000000000000000000000000000000000000..9f983b883fa15a3b34774844160e4fd1394b2d33 GIT binary patch literal 6578 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 literal 0 HcmV?d00001 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) => ( + + ))} + + )} + + + ))} + + + ); +}; 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