diff --git a/package-lock.json b/package-lock.json index 1a61ea51f..d2d9bd423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "i18next": "^21.9.2", "js-yaml": "^4.1.0", "json-rpc-2.0": "^1.4.1", + "luxon": "^3.4.3", "memory-cache": "^0.2.0", "minecraft-ping-js": "^1.0.2", "next": "^12.3.1", @@ -4098,6 +4099,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "engines": { + "node": ">=12" + } + }, "node_modules/memory-cache": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", diff --git a/package.json b/package.json index f6e39a977..6a33b5ed9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "i18next": "^21.9.2", "js-yaml": "^4.1.0", "json-rpc-2.0": "^1.4.1", + "luxon": "^3.4.3", "memory-cache": "^0.2.0", "minecraft-ping-js": "^1.0.2", "next": "^12.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c84dfffb8..6ff7151a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: json-rpc-2.0: specifier: ^1.4.1 version: 1.5.1 + luxon: + specifier: ^3.4.3 + version: 3.4.3 memory-cache: specifier: ^0.2.0 version: 0.2.0 @@ -2646,6 +2649,11 @@ packages: dependencies: yallist: 4.0.0 + /luxon@3.4.3: + resolution: {integrity: sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==} + engines: {node: '>=12'} + dev: false + /memory-cache@0.2.0: resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==} dev: false diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 16648977b..813c8e2a5 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -748,5 +748,10 @@ "seemsdown": "Seems Down", "down": "Down", "unknown": "Unknown" + }, + "calendar": { + "inCinemas": "In cinemas", + "physicalRelease": "Physical release", + "digitalRelease": "Digital release" } } diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 7b93b0050..2a1f4c743 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -12,6 +12,7 @@ import { ColorProvider } from "utils/contexts/color"; import { ThemeProvider } from "utils/contexts/theme"; import { SettingsProvider } from "utils/contexts/settings"; import { TabProvider } from "utils/contexts/tab"; +import { EventProvider, ShowDateProvider } from "utils/contexts/calendar"; function MyApp({ Component, pageProps }) { return ( @@ -28,7 +29,11 @@ function MyApp({ Component, pageProps }) { - + + + + + diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 775977f3e..543bf5ee4 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -357,6 +357,7 @@ export function cleanServiceGroups(groups) { method, // openmediavault widget mappings, // customapi widget refreshInterval, + integrations, // calendar widget } = cleanedService.widget; let fieldsList = fields; @@ -440,6 +441,9 @@ export function cleanServiceGroups(groups) { if (mappings) cleanedService.widget.mappings = mappings; if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; } + if (type === "calendar") { + if (integrations) cleanedService.widget.integrations = integrations; + } } return cleanedService; diff --git a/src/utils/contexts/calendar.jsx b/src/utils/contexts/calendar.jsx new file mode 100644 index 000000000..70616d5ff --- /dev/null +++ b/src/utils/contexts/calendar.jsx @@ -0,0 +1,28 @@ +import { createContext, useState, useMemo } from "react"; + +export const EventContext = createContext(); +export const ShowDateContext = createContext(); + +export function EventProvider({ initialEvent, children }) { + const [events, setEvents] = useState({}); + + if (initialEvent) { + setEvents(initialEvent); + } + + const value = useMemo(() => ({ events, setEvents }), [events]); + + return {children}; +} + +export function ShowDateProvider({ initialDate, children }) { + const [showDate, setShowDate] = useState(null); + + if (initialDate) { + setShowDate(initialDate); + } + + const value = useMemo(() => ({ showDate, setShowDate }), [showDate]); + + return {children}; +} diff --git a/src/utils/proxy/handlers/generic.js b/src/utils/proxy/handlers/generic.js index 93037dc57..d62cc341f 100644 --- a/src/utils/proxy/handlers/generic.js +++ b/src/utils/proxy/handlers/generic.js @@ -18,7 +18,8 @@ export default async function genericProxyHandler(req, res, map) { } if (widget) { - const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget })); + // if there are more than one question marks, replace others to & + const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, '&')); const headers = req.extraHeaders ?? widget.headers ?? {}; diff --git a/src/widgets/calendar/component.jsx b/src/widgets/calendar/component.jsx new file mode 100644 index 000000000..a7b44ff2d --- /dev/null +++ b/src/widgets/calendar/component.jsx @@ -0,0 +1,47 @@ +import { useContext, useMemo } from "react"; +import dynamic from "next/dynamic"; + +import { ShowDateContext } from "../../utils/contexts/calendar"; + +import MonthlyView from "./monthly-view"; + +import Container from "components/services/widget/container"; + +export default function Component({ service }) { + const { widget } = service; + const { showDate } = useContext(ShowDateContext); + + // params for API fetch + const params = useMemo(() => { + if (!showDate) { + return {}; + } + + return { + start: showDate.minus({months: 3}).toFormat("yyyy-MM-dd"), + end: showDate.plus({months: 3}).toFormat("yyyy-MM-dd"), + unmonitored: 'false', + }; + }, [showDate]); + + // Load active integrations + const integrations = useMemo(() => widget.integrations?.map(integration => ({ + service: dynamic(() => import(`./integrations/${integration?.type}`)), + widget: integration, + })) ?? [], [widget.integrations]); + + return +
+
+ {integrations.map(integration => { + const Integration = integration.service; + const key = integration.widget.type + integration.widget.service_name + integration.widget.service_group; + + return + })} +
+ +
+
; +} diff --git a/src/widgets/calendar/integrations/lidarr.jsx b/src/widgets/calendar/integrations/lidarr.jsx new file mode 100644 index 000000000..4bd427757 --- /dev/null +++ b/src/widgets/calendar/integrations/lidarr.jsx @@ -0,0 +1,36 @@ +import { DateTime } from "luxon"; +import { useContext, useEffect } from "react"; + +import useWidgetAPI from "../../../utils/proxy/use-widget-api"; +import { EventContext } from "../../../utils/contexts/calendar"; +import Error from "../../../components/services/widget/error"; + +export default function Integration({ config, params }) { + const { setEvents } = useContext(EventContext); + const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", + { ...params, includeArtist: 'false', ...config?.params ?? {} } + ); + + useEffect(() => { + if (!lidarrData || lidarrError) { + return; + } + + const eventsToAdd = {}; + + lidarrData?.forEach(event => { + const title = `${event.artist.artistName} - ${event.title}`; + + eventsToAdd[title] = { + title, + date: DateTime.fromISO(event.releaseDate), + color: config?.color ?? 'green' + }; + }) + + setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); + }, [lidarrData, lidarrError, config, setEvents]); + + const error = lidarrError ?? lidarrData?.error; + return error && +} diff --git a/src/widgets/calendar/integrations/radarr.jsx b/src/widgets/calendar/integrations/radarr.jsx new file mode 100644 index 000000000..f1cfd4abd --- /dev/null +++ b/src/widgets/calendar/integrations/radarr.jsx @@ -0,0 +1,49 @@ +import { DateTime } from "luxon"; +import { useEffect, useContext } from "react"; +import { useTranslation } from "next-i18next"; + +import useWidgetAPI from "../../../utils/proxy/use-widget-api"; +import { EventContext } from "../../../utils/contexts/calendar"; +import Error from "../../../components/services/widget/error"; + +export default function Integration({ config, params }) { + const { t } = useTranslation(); + const { setEvents } = useContext(EventContext); + const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", + { ...params, ...config?.params ?? {} } + ); + useEffect(() => { + if (!radarrData || radarrError) { + return; + } + + const eventsToAdd = {}; + + radarrData?.forEach(event => { + const cinemaTitle = `${event.title} - ${t("calendar.inCinemas")}`; + const physicalTitle = `${event.title} - ${t("calendar.physicalRelease")}`; + const digitalTitle = `${event.title} - ${t("calendar.digitalRelease")}`; + + eventsToAdd[cinemaTitle] = { + title: cinemaTitle, + date: DateTime.fromISO(event.inCinemas), + color: config?.color ?? 'amber' + }; + eventsToAdd[physicalTitle] = { + title: physicalTitle, + date: DateTime.fromISO(event.physicalRelease), + color: config?.color ?? 'cyan' + }; + eventsToAdd[digitalTitle] = { + title: digitalTitle, + date: DateTime.fromISO(event.digitalRelease), + color: config?.color ?? 'emerald' + }; + }) + + setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); + }, [radarrData, radarrError, config, setEvents, t]); + + const error = radarrError ?? radarrData?.error; + return error && +} diff --git a/src/widgets/calendar/integrations/readarr.jsx b/src/widgets/calendar/integrations/readarr.jsx new file mode 100644 index 000000000..5c3bfbba7 --- /dev/null +++ b/src/widgets/calendar/integrations/readarr.jsx @@ -0,0 +1,37 @@ +import { DateTime } from "luxon"; +import { useEffect, useContext } from "react"; + +import useWidgetAPI from "../../../utils/proxy/use-widget-api"; +import { EventContext } from "../../../utils/contexts/calendar"; +import Error from "../../../components/services/widget/error"; + +export default function Integration({ config, params }) { + const { setEvents } = useContext(EventContext); + const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", + { ...params, includeAuthor: 'true', ...config?.params ?? {} }, + ); + + useEffect(() => { + if (!readarrData || readarrError) { + return; + } + + const eventsToAdd = {}; + + readarrData?.forEach(event => { + const authorName = event.author?.authorName ?? event.authorTitle.replace(event.title, ''); + const title = `${authorName} - ${event.title} ${event?.seriesTitle ? `(${event.seriesTitle})` : ''} `; + + eventsToAdd[title] = { + title, + date: DateTime.fromISO(event.releaseDate), + color: config?.color ?? 'rose' + }; + }) + + setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); + }, [readarrData, readarrError, config, setEvents]); + + const error = readarrError ?? readarrData?.error; + return error && +} diff --git a/src/widgets/calendar/integrations/sonarr.jsx b/src/widgets/calendar/integrations/sonarr.jsx new file mode 100644 index 000000000..fd6090391 --- /dev/null +++ b/src/widgets/calendar/integrations/sonarr.jsx @@ -0,0 +1,36 @@ +import { DateTime } from "luxon"; +import { useEffect, useContext } from "react"; + +import useWidgetAPI from "../../../utils/proxy/use-widget-api"; +import { EventContext } from "../../../utils/contexts/calendar"; +import Error from "../../../components/services/widget/error"; + +export default function Integration({ config, params }) { + const { setEvents } = useContext(EventContext); + const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", + { ...params, includeSeries: 'true', includeEpisodeFile: 'false', includeEpisodeImages: 'false', ...config?.params ?? {} } + ); + + useEffect(() => { + if (!sonarrData || sonarrError) { + return; + } + + const eventsToAdd = {}; + + sonarrData?.forEach(event => { + const title = `${event.series.title ?? event.title} - S${event.seasonNumber}E${event.episodeNumber}`; + + eventsToAdd[title] = { + title, + date: DateTime.fromISO(event.airDateUtc), + color: config?.color ?? 'teal' + }; + }) + + setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); + }, [sonarrData, sonarrError, config, setEvents]); + + const error = sonarrError ?? sonarrData?.error; + return error && +} diff --git a/src/widgets/calendar/monthly-view.jsx b/src/widgets/calendar/monthly-view.jsx new file mode 100644 index 000000000..383d9881d --- /dev/null +++ b/src/widgets/calendar/monthly-view.jsx @@ -0,0 +1,153 @@ +import { useContext, useEffect, useMemo } from "react"; +import { DateTime, Info } from "luxon"; +import classNames from "classnames"; +import { useTranslation } from "next-i18next"; + +import { EventContext, ShowDateContext } from "../../utils/contexts/calendar"; + +const colorVariants = { + // https://tailwindcss.com/docs/content-configuration#dynamic-class-names + amber: "bg-amber-500", blue: "bg-blue-500", cyan: "bg-cyan-500", + emerald: "bg-emerald-500", fuchsia: "bg-fuchsia-500", gray: "bg-gray-500", + green: "bg-green-500", indigo: "bg-indigo-500", lime: "bg-lime-500", + neutral: "bg-neutral-500", orange: "bg-orange-500", pink: "bg-pink-500", + purple: "bg-purple-500", red: "bg-red-500", rose: "bg-rose-500", + sky: "bg-sky-500", slate: "bg-slate-500", stone: "bg-stone-500", + teal: "bg-teal-500", violet: "bg-violet-500", white: "bg-white-500", + yellow: "bg-yellow-500", zinc: "bg-zinc-500", +} + +const cellStyle = "relative w-10 flex items-center justify-center flex-col"; +const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer" + +export function Day({ weekNumber, weekday, events }) { + const currentDate = DateTime.now(); + const { showDate, setShowDate } = useContext(ShowDateContext); + + const cellDate = showDate.set({ weekday, weekNumber }).startOf("day"); + const filteredEvents = events?.filter(event => event.date?.startOf("day").toUnixInteger() === cellDate.toUnixInteger()); + + const dayStyles = (displayDate) => { + let style = "h-9 "; + + if ([6,7].includes(displayDate.weekday)) { + // weekend style + style += "text-red-500 "; + // different month style + style += displayDate.month !== showDate.month ? "text-red-500/40 " : ""; + } else if (displayDate.month !== showDate.month) { + // different month style + style += "text-gray-500 "; + } + + // selected same day style + style += displayDate.toFormat("MM-dd-yyyy") === showDate.toFormat("MM-dd-yyyy") ? "text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md " : ""; + + if (displayDate.toFormat("MM-dd-yyyy") === currentDate.toFormat("MM-dd-yyyy")) { + // today style + style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md "; + } else { + style += "hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer "; + } + + return style; + } + + return +} + +export function Event({ event }) { + return
{event.title} +
+} + +const dayInWeekId = { + monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 +}; + + +export default function MonthlyView({ service }) { + const { widget } = service; + const { i18n } = useTranslation(); + const { showDate, setShowDate } = useContext(ShowDateContext); + const { events } = useContext(EventContext); + const currentDate = DateTime.now().setLocale(i18n.language).startOf("day"); + + useEffect(() => { + if (!showDate) { + setShowDate(currentDate); + } + }) + + const dayNames = Info.weekdays("short", { locale: i18n.language }); + + const firstDayInCalendar = widget?.firstDayInCalendar ? widget?.firstDayInCalendar?.toLowerCase() : "monday"; + for (let i = 1; i < dayInWeekId[firstDayInCalendar]; i+=1) { + dayNames.push(dayNames.shift()); + } + + const daysInWeek = useMemo(() => [ ...Array(7).keys() ].map( i => i + dayInWeekId[firstDayInCalendar] + ), [(firstDayInCalendar)]); + + if (!showDate) { + return
; + } + + const firstWeek = DateTime.local(showDate.year, showDate.month, 1).setLocale(i18n.language); + + let weekNumbers = [ ...Array(Math.ceil(5) + 1).keys() ] + .map(i => firstWeek.weekNumber+i); + + if (weekNumbers.includes(55)) { + // if we went too far with the weeks, it's the beginning of the year + weekNumbers = weekNumbers.map(weekNum => weekNum-52 ); + } + + const eventsArray = Object.keys(events).map(eventKey => events[eventKey]); + + return
+
+ + { showDate.setLocale(i18n.language).toFormat("MMMM y") } + +
+ +
+
+ { dayNames.map(name => {name}) } +
+ +
{weekNumbers.map(weekNumber => + daysInWeek.map(dayInWeek => + + ) + )} +
+ +
+ {eventsArray?.filter(event => showDate.startOf("day").toUnixInteger() === event.date?.startOf("day").toUnixInteger()) + .map(event => )} +
+
+
+} diff --git a/src/widgets/components.js b/src/widgets/components.js index 660444065..9d311b977 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -9,6 +9,7 @@ const components = { azuredevops: dynamic(() => import("./azuredevops/component")), bazarr: dynamic(() => import("./bazarr/component")), caddy: dynamic(() => import("./caddy/component")), + calendar: dynamic(() => import("./calendar/component")), calibreweb: dynamic(() => import("./calibreweb/component")), changedetectionio: dynamic(() => import("./changedetectionio/component")), channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")), diff --git a/src/widgets/lidarr/widget.js b/src/widgets/lidarr/widget.js index 2f0367268..f7e266823 100644 --- a/src/widgets/lidarr/widget.js +++ b/src/widgets/lidarr/widget.js @@ -14,6 +14,10 @@ const widget = { "queue/status": { endpoint: "queue/status", }, + calendar: { + endpoint: "calendar", + params: ["start", "end", "unmonitored", "includeArtist"], + }, }, }; diff --git a/src/widgets/radarr/widget.js b/src/widgets/radarr/widget.js index 3373975e1..9ea466172 100644 --- a/src/widgets/radarr/widget.js +++ b/src/widgets/radarr/widget.js @@ -52,6 +52,10 @@ const widget = { return 0; }) }, + calendar: { + endpoint: "calendar", + params: ["start", "end", "unmonitored"], + }, }, }; diff --git a/src/widgets/readarr/widget.js b/src/widgets/readarr/widget.js index 75a5e8174..58cc09c4c 100644 --- a/src/widgets/readarr/widget.js +++ b/src/widgets/readarr/widget.js @@ -18,6 +18,10 @@ const widget = { "wanted/missing": { endpoint: "wanted/missing", }, + calendar: { + endpoint: "calendar", + params: ["start", "end", "unmonitored", "includeAuthor"], + }, }, }; diff --git a/src/widgets/sonarr/widget.js b/src/widgets/sonarr/widget.js index 7f658eb19..5f393c583 100644 --- a/src/widgets/sonarr/widget.js +++ b/src/widgets/sonarr/widget.js @@ -57,7 +57,11 @@ const widget = { } return 0; }) - } + }, + calendar: { + endpoint: "calendar", + params: ["start", "end", "unmonitored", "includeSeries", "includeEpisodeFile", "includeEpisodeImages"], + }, }, };