Feature: calendar widget (#2077)

* Implemented calendar

Signed-off-by: Denis Papec <denis.papec@gmail.com>

* Added lidarr events to calendar

Signed-off-by: Denis Papec <denis.papec@gmail.com>

* Added radarr events to calendar

Signed-off-by: Denis Papec <denis.papec@gmail.com>

* Added readarr events to calendar

Signed-off-by: Denis Papec <denis.papec@gmail.com>

* Added sonarr events to calendar

Signed-off-by: Denis Papec <denis.papec@gmail.com>

* fix sonarr series title

* integrations

* fix bad setstate call

* handle user sets includeSeries: false for sonarr

* Translate radarr release strings

* Support all widths

* readarr get author

* Finished first day in week config

Signed-off-by: Denis Papec <denis.papec@gmail.com>

---------

Signed-off-by: Denis Papec <denis.papec@gmail.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
pull/2080/head
Denis Papec 1 year ago committed by GitHub
parent 855f12e4c1
commit 4cd4103edf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

9
package-lock.json generated

@ -18,6 +18,7 @@
"i18next": "^21.9.2", "i18next": "^21.9.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.4.1", "json-rpc-2.0": "^1.4.1",
"luxon": "^3.4.3",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"minecraft-ping-js": "^1.0.2", "minecraft-ping-js": "^1.0.2",
"next": "^12.3.1", "next": "^12.3.1",
@ -4098,6 +4099,14 @@
"node": ">=10" "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": { "node_modules/memory-cache": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",

@ -20,6 +20,7 @@
"i18next": "^21.9.2", "i18next": "^21.9.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.4.1", "json-rpc-2.0": "^1.4.1",
"luxon": "^3.4.3",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"minecraft-ping-js": "^1.0.2", "minecraft-ping-js": "^1.0.2",
"next": "^12.3.1", "next": "^12.3.1",

@ -35,6 +35,9 @@ dependencies:
json-rpc-2.0: json-rpc-2.0:
specifier: ^1.4.1 specifier: ^1.4.1
version: 1.5.1 version: 1.5.1
luxon:
specifier: ^3.4.3
version: 3.4.3
memory-cache: memory-cache:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
@ -2646,6 +2649,11 @@ packages:
dependencies: dependencies:
yallist: 4.0.0 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: /memory-cache@0.2.0:
resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==} resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==}
dev: false dev: false

@ -748,5 +748,10 @@
"seemsdown": "Seems Down", "seemsdown": "Seems Down",
"down": "Down", "down": "Down",
"unknown": "Unknown" "unknown": "Unknown"
},
"calendar": {
"inCinemas": "In cinemas",
"physicalRelease": "Physical release",
"digitalRelease": "Digital release"
} }
} }

@ -12,6 +12,7 @@ import { ColorProvider } from "utils/contexts/color";
import { ThemeProvider } from "utils/contexts/theme"; import { ThemeProvider } from "utils/contexts/theme";
import { SettingsProvider } from "utils/contexts/settings"; import { SettingsProvider } from "utils/contexts/settings";
import { TabProvider } from "utils/contexts/tab"; import { TabProvider } from "utils/contexts/tab";
import { EventProvider, ShowDateProvider } from "utils/contexts/calendar";
function MyApp({ Component, pageProps }) { function MyApp({ Component, pageProps }) {
return ( return (
@ -28,7 +29,11 @@ function MyApp({ Component, pageProps }) {
<ThemeProvider> <ThemeProvider>
<SettingsProvider> <SettingsProvider>
<TabProvider> <TabProvider>
<EventProvider>
<ShowDateProvider>
<Component {...pageProps} /> <Component {...pageProps} />
</ShowDateProvider>
</EventProvider>
</TabProvider> </TabProvider>
</SettingsProvider> </SettingsProvider>
</ThemeProvider> </ThemeProvider>

@ -357,6 +357,7 @@ export function cleanServiceGroups(groups) {
method, // openmediavault widget method, // openmediavault widget
mappings, // customapi widget mappings, // customapi widget
refreshInterval, refreshInterval,
integrations, // calendar widget
} = cleanedService.widget; } = cleanedService.widget;
let fieldsList = fields; let fieldsList = fields;
@ -440,6 +441,9 @@ export function cleanServiceGroups(groups) {
if (mappings) cleanedService.widget.mappings = mappings; if (mappings) cleanedService.widget.mappings = mappings;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
} }
if (type === "calendar") {
if (integrations) cleanedService.widget.integrations = integrations;
}
} }
return cleanedService; return cleanedService;

@ -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 <EventContext.Provider value={value}>{children}</EventContext.Provider>;
}
export function ShowDateProvider({ initialDate, children }) {
const [showDate, setShowDate] = useState(null);
if (initialDate) {
setShowDate(initialDate);
}
const value = useMemo(() => ({ showDate, setShowDate }), [showDate]);
return <ShowDateContext.Provider value={value}>{children}</ShowDateContext.Provider>;
}

@ -18,7 +18,8 @@ export default async function genericProxyHandler(req, res, map) {
} }
if (widget) { 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 ?? {}; const headers = req.extraHeaders ?? widget.headers ?? {};

@ -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 <Container service={service}>
<div className="flex flex-col w-full">
<div className="sticky top-0">
{integrations.map(integration => {
const Integration = integration.service;
const key = integration.widget.type + integration.widget.service_name + integration.widget.service_group;
return <Integration key={key} config={integration.widget} params={params}
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12" />
})}
</div>
<MonthlyView service={service} className="flex"/>
</div>
</Container>;
}

@ -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 && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} />
}

@ -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 && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} />
}

@ -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 && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} />
}

@ -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 && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} />
}

@ -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 <button
key={`day${weekday}${weekNumber}}`} type="button" className={classNames(dayStyles(cellDate), cellStyle)}
style={{ width: "14%" }} onClick={() => setShowDate(cellDate)}
>
{cellDate.day}
<span className="flex justify-center items-center absolute w-full -mb-6">
{filteredEvents && filteredEvents.slice(0, 4).map(event => <span
key={event.date.toLocaleString() + event.color + event.title}
className={classNames(
"inline-flex h-1 w-1 m-0.5 rounded",
colorVariants[event.color] ?? "gray"
)}
/>)}
</span>
</button>
}
export function Event({ event }) {
return <div
key={event.title}
className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
><span className="absolute left-2 text-left text-xs mt-[2px] truncate text-ellipsis" style={{width: '96%'}}>{event.title}</span>
</div>
}
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 <div className="w-full text-center" />;
}
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 <div className="w-full text-center">
<div className="flex-col">
<span><button type="button" onClick={ () => setShowDate(showDate.minus({ months: 1 }).startOf("day")) } className={classNames(monthButton)}>&lt;</button></span>
<span>{ showDate.setLocale(i18n.language).toFormat("MMMM y") }</span>
<span><button type="button" onClick={ () => setShowDate(showDate.plus({ months: 1 }).startOf("day")) } className={classNames(monthButton)}>&gt;</button></span>
</div>
<div className="p-2 w-full">
<div className="flex justify-between flex-wrap">
{ dayNames.map(name => <span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>{name}</span>) }
</div>
<div className={classNames(
"flex justify-between flex-wrap",
!eventsArray.length && widget?.integrations?.length && "animate-pulse"
)}>{weekNumbers.map(weekNumber =>
daysInWeek.map(dayInWeek =>
<Day key={`week${weekNumber}day${dayInWeek}}`} weekNumber={weekNumber} weekday={dayInWeek} events={eventsArray} />
)
)}
</div>
<div className="flex flex-col pt-1 pb-1">
{eventsArray?.filter(event => showDate.startOf("day").toUnixInteger() === event.date?.startOf("day").toUnixInteger())
.map(event => <Event key={`event${event.title}`} event={event} />)}
</div>
</div>
</div>
}

@ -9,6 +9,7 @@ const components = {
azuredevops: dynamic(() => import("./azuredevops/component")), azuredevops: dynamic(() => import("./azuredevops/component")),
bazarr: dynamic(() => import("./bazarr/component")), bazarr: dynamic(() => import("./bazarr/component")),
caddy: dynamic(() => import("./caddy/component")), caddy: dynamic(() => import("./caddy/component")),
calendar: dynamic(() => import("./calendar/component")),
calibreweb: dynamic(() => import("./calibreweb/component")), calibreweb: dynamic(() => import("./calibreweb/component")),
changedetectionio: dynamic(() => import("./changedetectionio/component")), changedetectionio: dynamic(() => import("./changedetectionio/component")),
channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")), channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")),

@ -14,6 +14,10 @@ const widget = {
"queue/status": { "queue/status": {
endpoint: "queue/status", endpoint: "queue/status",
}, },
calendar: {
endpoint: "calendar",
params: ["start", "end", "unmonitored", "includeArtist"],
},
}, },
}; };

@ -52,6 +52,10 @@ const widget = {
return 0; return 0;
}) })
}, },
calendar: {
endpoint: "calendar",
params: ["start", "end", "unmonitored"],
},
}, },
}; };

@ -18,6 +18,10 @@ const widget = {
"wanted/missing": { "wanted/missing": {
endpoint: "wanted/missing", endpoint: "wanted/missing",
}, },
calendar: {
endpoint: "calendar",
params: ["start", "end", "unmonitored", "includeAuthor"],
},
}, },
}; };

@ -57,7 +57,11 @@ const widget = {
} }
return 0; return 0;
}) })
} },
calendar: {
endpoint: "calendar",
params: ["start", "end", "unmonitored", "includeSeries", "includeEpisodeFile", "includeEpisodeImages"],
},
}, },
}; };

Loading…
Cancel
Save