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 @@ + + + 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 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) => ( + + ))} + + )} + + + ))} + + + ); +}; 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