Feature: Implement iCal integration for calendar, improve styling (#2376)

* Feature: Implement iCal integration, improve calendar/agenda styling

* Delete calendar.jsx

* Calendar proxy handler

* code style

* Add some basic error handling

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Denis Papec 2023-11-25 16:17:25 +00:00 committed by GitHub
parent 518ed7fc4e
commit 95d66707f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 251 additions and 115 deletions

View file

@ -12,7 +12,6 @@ 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 } from "utils/contexts/calendar";
function MyApp({ Component, pageProps }) {
return (
@ -32,9 +31,7 @@ function MyApp({ Component, pageProps }) {
<ThemeProvider>
<SettingsProvider>
<TabProvider>
<EventProvider>
<Component {...pageProps} />
</EventProvider>
<Component {...pageProps} />
</TabProvider>
</SettingsProvider>
</ThemeProvider>

View file

@ -351,6 +351,7 @@ export function cleanServiceGroups(groups) {
firstDayInWeek,
integrations,
maxEvents,
showTime,
previousDays,
view,
@ -519,6 +520,7 @@ export function cleanServiceGroups(groups) {
if (view) cleanedService.widget.view = view;
if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
if (previousDays) cleanedService.widget.previousDays = previousDays;
if (showTime) cleanedService.widget.showTime = showTime;
}
}

View file

@ -1,15 +0,0 @@
import { createContext, useState, useMemo } from "react";
export const EventContext = 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>;
}

View file

@ -1,45 +1,11 @@
import { useContext, useState } from "react";
import { DateTime } from "luxon";
import classNames from "classnames";
import { useTranslation } from "next-i18next";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { EventContext } from "../../utils/contexts/calendar";
import Event from "./event";
export function Event({ event, colorVariants, showDate = false }) {
const [hover, setHover] = useState(false);
const { i18n } = useTranslation();
return (
<div
className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs text-left h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
onMouseEnter={() => setHover(!hover)}
onMouseLeave={() => setHover(!hover)}
>
<span className="ml-2 w-10">
<span>
{showDate &&
event.date.setLocale(i18n.language).startOf("day").toLocaleString({ month: "short", day: "numeric" })}
</span>
</span>
<span className="ml-2 h-2 w-2">
<span className={classNames("block w-2 h-2 rounded", colorVariants[event.color] ?? "gray")} />
</span>
<div className="ml-2 h-5 text-left relative truncate" style={{ width: "70%" }}>
<div className="absolute mt-0.5 text-xs">{hover && event.additional ? event.additional : event.title}</div>
</div>
{event.isCompleted && (
<span className="text-xs mr-1 ml-auto z-10">
<IoMdCheckmarkCircleOutline />
</span>
)}
</div>
);
}
export default function Agenda({ service, colorVariants, showDate }) {
export default function Agenda({ service, colorVariants, events, showDate }) {
const { widget } = service;
const { events } = useContext(EventContext);
const { t } = useTranslation();
if (!showDate) {
@ -59,10 +25,8 @@ export default function Agenda({ service, colorVariants, showDate }) {
if (!eventsArray.length) {
return (
<div className="text-center">
<div className="p-2 ">
<div
className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}
>
<div className="pl-2 pr-2">
<div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
<Event
key="no-event"
event={{
@ -82,16 +46,17 @@ export default function Agenda({ service, colorVariants, showDate }) {
const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf("day").ts === d));
return (
<div className="p-2">
<div className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}>
<div className="pl-1 pr-1 pb-1">
<div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
{eventsByDay.map((eventsDay, i) => (
<div key={days[i]}>
{eventsDay.map((event, j) => (
<Event
key={`event${event.title}-${event.date}`}
key={`event-agenda-${event.title}-${event.date}-${event.additional}`}
event={event}
colorVariants={colorVariants}
showDate={j === 0}
showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
/>
))}
</div>

View file

@ -40,6 +40,7 @@ export default function Component({ service }) {
const { widget } = service;
const { i18n } = useTranslation();
const [showDate, setShowDate] = useState(null);
const [events, setEvents] = useState({});
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
const { settings } = useContext(SettingsContext);
@ -69,9 +70,9 @@ export default function Component({ service }) {
?.filter((integration) => integration?.type)
.map((integration) => ({
service: dynamic(() => import(`./integrations/${integration.type}`)),
widget: integration,
widget: { ...widget, ...integration },
})) ?? [],
[widget.integrations],
[widget],
);
return (
@ -80,13 +81,14 @@ export default function Component({ service }) {
<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;
const key = `integration-${integration.widget.type}-${integration.widget.service_name}-${integration.widget.service_group}-${integration.widget.name}`;
return (
<Integration
key={key}
config={integration.widget}
params={params}
setEvents={setEvents}
hideErrors={settings.hideErrors}
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
/>
@ -95,8 +97,10 @@ export default function Component({ service }) {
</div>
{(!widget?.view || widget?.view === "monthly") && (
<Monthly
key={`monthly-${showDate?.toFormat("yyyy-MM-dd")}`}
service={service}
colorVariants={colorVariants}
events={events}
showDate={showDate}
setShowDate={setShowDate}
className="flex"
@ -104,8 +108,10 @@ export default function Component({ service }) {
)}
{widget?.view === "agenda" && (
<Agenda
key={`agenda-${showDate?.toFormat("yyyy-MM-dd")}`}
service={service}
colorVariants={colorVariants}
events={events}
showDate={showDate}
setShowDate={setShowDate}
className="flex"

View file

@ -0,0 +1,41 @@
import { useState } from "react";
import { useTranslation } from "next-i18next";
import { DateTime } from "luxon";
import classNames from "classnames";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
export default function Event({ event, colorVariants, showDate = false, showTime = false, showDateColumn = true }) {
const [hover, setHover] = useState(false);
const { i18n } = useTranslation();
return (
<div
className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
onMouseEnter={() => setHover(!hover)}
onMouseLeave={() => setHover(!hover)}
key={`event-${event.title}-${event.date}-${event.additional}`}
>
{showDateColumn && (
<span className="ml-2 w-10">
<span>
{(showDate || showTime) &&
event.date
.setLocale(i18n.language)
.toLocaleString(showTime ? DateTime.TIME_24_SIMPLE : { month: "short", day: "numeric" })}
</span>
</span>
)}
<span className="ml-2 h-2 w-2">
<span className={classNames("block w-2 h-2 rounded", colorVariants[event.color] ?? "gray")} />
</span>
<div className="ml-2 h-5 text-left relative truncate" style={{ width: "70%" }}>
<div className="absolute mt-0.5 text-xs">{hover && event.additional ? event.additional : event.title}</div>
</div>
{event.isCompleted && (
<span className="text-xs mr-1 ml-auto z-10">
<IoMdCheckmarkCircleOutline />
</span>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import { DateTime } from "luxon";
import { parseString } from "cal-parser";
import { useEffect } from "react";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
import Error from "../../../components/services/widget/error";
export default function Integration({ config, params, setEvents, hideErrors }) {
const { t } = useTranslation();
const { data: icalData, error: icalError } = useWidgetAPI(config, config.name, {
refreshInterval: 300000, // 5 minutes
});
useEffect(() => {
let parsedIcal;
if (!icalError && icalData && !icalData.error) {
parsedIcal = parseString(icalData.data);
if (parsedIcal.events.length === 0) {
icalData.error = { message: `'${config.name}': ${t("calendar.noEventsFound")}` };
}
}
if (icalError || !parsedIcal) {
return;
}
const eventsToAdd = {};
const events = parsedIcal?.getEventsBetweenDates(
DateTime.fromISO(params.start).toJSDate(),
DateTime.fromISO(params.end).toJSDate(),
);
events?.forEach((event) => {
let title = `${event?.summary?.value}`;
if (config?.params?.showName) {
title = `${config.name}: ${title}`;
}
event.matchingDates.forEach((date) => {
eventsToAdd[event?.uid?.value] = {
title,
date: DateTime.fromJSDate(date),
color: config?.color ?? "zinc",
isCompleted: DateTime.fromJSDate(date) < DateTime.now(),
additional: event.location?.value,
type: "ical",
};
});
});
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
}, [icalData, icalError, config, params, setEvents, t]);
const error = icalError ?? icalData?.error;
return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
}

View file

@ -1,12 +1,10 @@
import { DateTime } from "luxon";
import { useContext, useEffect } from "react";
import { 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, hideErrors = false }) {
const { setEvents } = useContext(EventContext);
export default function Integration({ config, params, setEvents, hideErrors = false }) {
const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
...params,
includeArtist: "false",

View file

@ -1,14 +1,12 @@
import { DateTime } from "luxon";
import { useEffect, useContext } from "react";
import { useEffect } 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, hideErrors = false }) {
export default function Integration({ config, params, setEvents, hideErrors = false }) {
const { t } = useTranslation();
const { setEvents } = useContext(EventContext);
const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
...params,
...(config?.params ?? {}),

View file

@ -1,12 +1,10 @@
import { DateTime } from "luxon";
import { useEffect, useContext } from "react";
import { 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, hideErrors = false }) {
const { setEvents } = useContext(EventContext);
export default function Integration({ config, params, setEvents, hideErrors = false }) {
const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
...params,
includeAuthor: "true",

View file

@ -1,12 +1,10 @@
import { DateTime } from "luxon";
import { useEffect, useContext } from "react";
import { 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, hideErrors = false }) {
const { setEvents } = useContext(EventContext);
export default function Integration({ config, params, setEvents, hideErrors = false }) {
const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
...params,
includeSeries: "true",

View file

@ -1,10 +1,9 @@
import { useContext, useMemo } from "react";
import { useMemo } from "react";
import { DateTime, Info } from "luxon";
import classNames from "classnames";
import { useTranslation } from "next-i18next";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { EventContext } from "../../utils/contexts/calendar";
import Event from "./event";
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";
@ -32,11 +31,11 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
// selected same day style
style +=
displayDate.toFormat("MM-dd-yyyy") === showDate.toFormat("MM-dd-yyyy")
displayDate.startOf("day").ts === showDate.startOf("day").ts
? "text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md "
: "";
if (displayDate.toFormat("MM-dd-yyyy") === currentDate.toFormat("MM-dd-yyyy")) {
if (displayDate.startOf("day").ts === currentDate.startOf("day").ts) {
// today style
style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
} else {
@ -61,7 +60,7 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
.slice(0, 4)
.map((event) => (
<span
key={event.date.toLocaleString() + event.color + event.title}
key={`${event.date.ts}+${event.color}-${event.title}-${event.additional}`}
className={classNames("inline-flex h-1 w-1 m-0.5 rounded", colorVariants[event.color] ?? "gray")}
/>
))}
@ -70,25 +69,6 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
);
}
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}
{event.additional ? ` - ${event.additional}` : ""}
</span>
{event.isCompleted && (
<span className="text-right text-xs flex justify-end mr-1 mt-1 z-10 ">
<IoMdCheckmarkCircleOutline />
</span>
)}
</div>
);
}
const dayInWeekId = {
monday: 1,
tuesday: 2,
@ -99,10 +79,9 @@ const dayInWeekId = {
sunday: 7,
};
export default function Monthly({ service, colorVariants, showDate, setShowDate }) {
export default function Monthly({ service, colorVariants, events, showDate, setShowDate }) {
const { widget } = service;
const { i18n } = useTranslation();
const { events } = useContext(EventContext);
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
const dayNames = Info.weekdays("short", { locale: i18n.language });
@ -161,7 +140,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
</span>
</div>
<div className="p-2 w-full">
<div className="pl-1 pr-1 pb-1 w-full">
<div className="flex justify-between flex-wrap">
{dayNames.map((name) => (
<span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>
@ -172,7 +151,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
<div
className={classNames(
"flex justify-between flex-wrap",
"flex justify-between flex-wrap pb-1",
!eventsArray.length && widget?.integrations?.length && "animate-pulse",
)}
>
@ -191,12 +170,18 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
)}
</div>
<div className="flex flex-col pt-1 pb-1">
<div className="flex flex-col">
{eventsArray
?.filter((event) => showDate.startOf("day").toUnixInteger() === event.date?.startOf("day").toUnixInteger())
?.filter((event) => showDate.startOf("day").ts === event.date?.startOf("day").ts)
.slice(0, widget?.maxEvents ?? 10)
.map((event) => (
<Event key={`event${event.title}-${event.additional}`} event={event} />
<Event
key={`event-monthly-${event.title}-${event.date}-${event.additional}`}
event={event}
colorVariants={colorVariants}
showDateColumn={widget?.showTime ?? false}
showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
/>
))}
</div>
</div>

View file

@ -0,0 +1,33 @@
import getServiceWidget from "utils/config/service-helpers";
import { httpProxy } from "utils/proxy/http";
import createLogger from "utils/logger";
const logger = createLogger("calendarProxyHandler");
export default async function calendarProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const integration = widget.integrations?.find((i) => i.name === endpoint);
if (integration) {
if (!integration.url) {
return res.status(403).json({ error: "No integration URL specified" });
}
const [status, contentType, data] = await httpProxy(integration.url);
if (contentType) res.setHeader("Content-Type", contentType);
if (status !== 200) {
logger.debug(`HTTTP ${status} retrieving data from integration URL ${integration.url} : ${data}`);
return res.status(status).send(data);
}
return res.status(status).json({ data: data.toString() });
}
}
return res.status(400).json({ error: "Invalid integration" });
}

View file

@ -0,0 +1,8 @@
import calendarProxyHandler from "./proxy";
const widget = {
api: "{url}",
proxyHandler: calendarProxyHandler,
};
export default widget;

View file

@ -6,6 +6,7 @@ import autobrr from "./autobrr/widget";
import azuredevops from "./azuredevops/widget";
import bazarr from "./bazarr/widget";
import caddy from "./caddy/widget";
import calendar from "./calendar/widget";
import calibreweb from "./calibreweb/widget";
import changedetectionio from "./changedetectionio/widget";
import channelsdvrserver from "./channelsdvrserver/widget";
@ -131,6 +132,7 @@ const widgets = {
homeassistant,
homebridge,
healthchecks,
ical: calendar,
immich,
jackett,
jdownloader,