diff --git a/package-lock.json b/package-lock.json
index 1a61ea51..d2d9bd42 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 f6e39a97..6a33b5ed 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 c84dfffb..6ff7151a 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 16648977..813c8e2a 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 7b93b005..2a1f4c74 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 775977f3..543bf5ee 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 00000000..70616d5f
--- /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 93037dc5..d62cc341 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 00000000..a7b44ff2
--- /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 00000000..4bd42775
--- /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 00000000..f1cfd4ab
--- /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 00000000..5c3bfbba
--- /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 00000000..fd609039
--- /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 00000000..383d9881
--- /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 66044406..9d311b97 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 2f036726..f7e26682 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 3373975e..9ea46617 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 75a5e817..58cc09c4 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 7f658eb1..5f393c58 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"],
+ },
},
};