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:
parent
518ed7fc4e
commit
95d66707f5
20 changed files with 251 additions and 115 deletions
|
@ -15,13 +15,20 @@ widget:
|
||||||
firstDayInWeek: sunday # optional - defaults to monday
|
firstDayInWeek: sunday # optional - defaults to monday
|
||||||
view: monthly # optional - possible values monthly, agenda
|
view: monthly # optional - possible values monthly, agenda
|
||||||
maxEvents: 10 # optional - defaults to 10
|
maxEvents: 10 # optional - defaults to 10
|
||||||
|
showTime: true # optional - show time for event happening today - defaults to false
|
||||||
integrations: # optional
|
integrations: # optional
|
||||||
- type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr
|
- type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr, ical
|
||||||
service_group: Media # group name where widget exists
|
service_group: Media # group name where widget exists
|
||||||
service_name: Sonarr # service name for that widget
|
service_name: Sonarr # service name for that widget
|
||||||
color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)
|
color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)
|
||||||
params: # optional - additional params for the service
|
params: # optional - additional params for the service
|
||||||
unmonitored: true # optional - defaults to false, used with *arr stack
|
unmonitored: true # optional - defaults to false, used with *arr stack
|
||||||
|
- type: ical # Show calendar events from another service
|
||||||
|
url: https://domain.url/with/link/to.ics # URL with calendar events
|
||||||
|
name: My Events # required - name for these calendar events
|
||||||
|
color: zinc # optional - defaults to pre-defined color for the service (zinc for ical)
|
||||||
|
params: # optional - additional params for the service
|
||||||
|
showName: true # optional - show name before event title in event line - defaults to false
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agenda
|
## Agenda
|
||||||
|
@ -33,6 +40,7 @@ widget:
|
||||||
type: calendar
|
type: calendar
|
||||||
view: agenda
|
view: agenda
|
||||||
maxEvents: 10 # optional - defaults to 10
|
maxEvents: 10 # optional - defaults to 10
|
||||||
|
showTime: true # optional - show time for event happening today - defaults to false
|
||||||
previousDays: 3 # optional - shows events since three days ago - defaults to 0
|
previousDays: 3 # optional - shows events since three days ago - defaults to 0
|
||||||
integrations: # same as in Monthly view example
|
integrations: # same as in Monthly view example
|
||||||
```
|
```
|
||||||
|
@ -42,3 +50,7 @@ widget:
|
||||||
Currently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).
|
Currently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).
|
||||||
|
|
||||||
Supported colors can be found on [color palette](../../configs/settings.md#color-palette).
|
Supported colors can be found on [color palette](../../configs/settings.md#color-palette).
|
||||||
|
|
||||||
|
### iCal
|
||||||
|
|
||||||
|
This custom integration allows you to show events from any calendar that supports iCal format, for example, Google Calendar (go to `Settings`, select specific calendar, go to `Integrate calendar`, copy URL from `Public Address in iCal format`).
|
||||||
|
|
28
package-lock.json
generated
28
package-lock.json
generated
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.2",
|
"@headlessui/react": "^1.7.2",
|
||||||
"@kubernetes/client-node": "^0.17.1",
|
"@kubernetes/client-node": "^0.17.1",
|
||||||
|
"cal-parser": "^1.0.2",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"compare-versions": "^5.0.1",
|
"compare-versions": "^5.0.1",
|
||||||
"dockerode": "^3.3.4",
|
"dockerode": "^3.3.4",
|
||||||
|
@ -1250,6 +1251,15 @@
|
||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cal-parser": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cal-parser/-/cal-parser-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-wlQwcF0fl4eLclyGdncF9rcNNq0ipRYZGagG6h3LVgRXvCWE1fdMUaCLXwfC9YWoz9jKKbjQAq7TpO2Y3yrvmA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ical-date-parser": "^4.0.0",
|
||||||
|
"rrule": "^2.6.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||||
|
@ -3382,6 +3392,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.2.0.tgz",
|
||||||
"integrity": "sha512-pUx3AcgXCbur0jpFA7U67Z2RJflAcIi698Y8VL+phdOqUchahxriV3Cs+M6UkPNQSS/zPEzWLfdJ8EgjB7HVxg=="
|
"integrity": "sha512-pUx3AcgXCbur0jpFA7U67Z2RJflAcIi698Y8VL+phdOqUchahxriV3Cs+M6UkPNQSS/zPEzWLfdJ8EgjB7HVxg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ical-date-parser": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ical-date-parser/-/ical-date-parser-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-XRCK/FU1akC2ZaJOdKIeZI6BLLgzWUuE0pegSrrkEva89GOan5mNkLVqCU4EMhCJ9nkG5TLWdMXrVX1fNAkFzw=="
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
@ -5485,6 +5500,19 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rrule": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrule/node_modules/tslib": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.2",
|
"@headlessui/react": "^1.7.2",
|
||||||
"@kubernetes/client-node": "^0.17.1",
|
"@kubernetes/client-node": "^0.17.1",
|
||||||
|
"cal-parser": "^1.0.2",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"compare-versions": "^5.0.1",
|
"compare-versions": "^5.0.1",
|
||||||
"dockerode": "^3.3.4",
|
"dockerode": "^3.3.4",
|
||||||
|
|
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
@ -11,6 +11,9 @@ dependencies:
|
||||||
'@kubernetes/client-node':
|
'@kubernetes/client-node':
|
||||||
specifier: ^0.17.1
|
specifier: ^0.17.1
|
||||||
version: 0.17.1
|
version: 0.17.1
|
||||||
|
cal-parser:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
classnames:
|
classnames:
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2
|
version: 2.3.2
|
||||||
|
@ -864,6 +867,13 @@ packages:
|
||||||
responselike: 3.0.0
|
responselike: 3.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cal-parser@1.0.2:
|
||||||
|
resolution: {integrity: sha512-wlQwcF0fl4eLclyGdncF9rcNNq0ipRYZGagG6h3LVgRXvCWE1fdMUaCLXwfC9YWoz9jKKbjQAq7TpO2Y3yrvmA==}
|
||||||
|
dependencies:
|
||||||
|
ical-date-parser: 4.0.0
|
||||||
|
rrule: 2.7.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/call-bind@1.0.2:
|
/call-bind@1.0.2:
|
||||||
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
|
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2232,6 +2242,10 @@ packages:
|
||||||
'@babel/runtime': 7.21.0
|
'@babel/runtime': 7.21.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/ical-date-parser@4.0.0:
|
||||||
|
resolution: {integrity: sha512-XRCK/FU1akC2ZaJOdKIeZI6BLLgzWUuE0pegSrrkEva89GOan5mNkLVqCU4EMhCJ9nkG5TLWdMXrVX1fNAkFzw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/iconv-lite@0.4.24:
|
/iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
@ -3538,6 +3552,12 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
|
|
||||||
|
/rrule@2.7.2:
|
||||||
|
resolution: {integrity: sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==}
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.5.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/run-parallel@1.2.0:
|
/run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
@ -765,6 +765,7 @@
|
||||||
"inCinemas": "In cinemas",
|
"inCinemas": "In cinemas",
|
||||||
"physicalRelease": "Physical release",
|
"physicalRelease": "Physical release",
|
||||||
"digitalRelease": "Digital release",
|
"digitalRelease": "Digital release",
|
||||||
"noEventsToday": "No events for today!"
|
"noEventsToday": "No events for today!",
|
||||||
|
"noEventsFound": "No events found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ 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 } from "utils/contexts/calendar";
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
function MyApp({ Component, pageProps }) {
|
||||||
return (
|
return (
|
||||||
|
@ -32,9 +31,7 @@ function MyApp({ Component, pageProps }) {
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<TabProvider>
|
<TabProvider>
|
||||||
<EventProvider>
|
<Component {...pageProps} />
|
||||||
<Component {...pageProps} />
|
|
||||||
</EventProvider>
|
|
||||||
</TabProvider>
|
</TabProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
@ -351,6 +351,7 @@ export function cleanServiceGroups(groups) {
|
||||||
firstDayInWeek,
|
firstDayInWeek,
|
||||||
integrations,
|
integrations,
|
||||||
maxEvents,
|
maxEvents,
|
||||||
|
showTime,
|
||||||
previousDays,
|
previousDays,
|
||||||
view,
|
view,
|
||||||
|
|
||||||
|
@ -519,6 +520,7 @@ export function cleanServiceGroups(groups) {
|
||||||
if (view) cleanedService.widget.view = view;
|
if (view) cleanedService.widget.view = view;
|
||||||
if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
|
if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
|
||||||
if (previousDays) cleanedService.widget.previousDays = previousDays;
|
if (previousDays) cleanedService.widget.previousDays = previousDays;
|
||||||
|
if (showTime) cleanedService.widget.showTime = showTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>;
|
|
||||||
}
|
|
|
@ -1,45 +1,11 @@
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "next-i18next";
|
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 }) {
|
export default function Agenda({ service, colorVariants, events, showDate }) {
|
||||||
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 }) {
|
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { events } = useContext(EventContext);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!showDate) {
|
if (!showDate) {
|
||||||
|
@ -59,10 +25,8 @@ export default function Agenda({ service, colorVariants, showDate }) {
|
||||||
if (!eventsArray.length) {
|
if (!eventsArray.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="p-2 ">
|
<div className="pl-2 pr-2">
|
||||||
<div
|
<div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
|
||||||
className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}
|
|
||||||
>
|
|
||||||
<Event
|
<Event
|
||||||
key="no-event"
|
key="no-event"
|
||||||
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));
|
const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf("day").ts === d));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2">
|
<div className="pl-1 pr-1 pb-1">
|
||||||
<div className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}>
|
<div className={classNames("flex flex-col", !eventsArray.length && !events.length && "animate-pulse")}>
|
||||||
{eventsByDay.map((eventsDay, i) => (
|
{eventsByDay.map((eventsDay, i) => (
|
||||||
<div key={days[i]}>
|
<div key={days[i]}>
|
||||||
{eventsDay.map((event, j) => (
|
{eventsDay.map((event, j) => (
|
||||||
<Event
|
<Event
|
||||||
key={`event${event.title}-${event.date}`}
|
key={`event-agenda-${event.title}-${event.date}-${event.additional}`}
|
||||||
event={event}
|
event={event}
|
||||||
colorVariants={colorVariants}
|
colorVariants={colorVariants}
|
||||||
showDate={j === 0}
|
showDate={j === 0}
|
||||||
|
showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,6 +40,7 @@ export default function Component({ service }) {
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [showDate, setShowDate] = useState(null);
|
const [showDate, setShowDate] = useState(null);
|
||||||
|
const [events, setEvents] = useState({});
|
||||||
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
|
@ -69,9 +70,9 @@ export default function Component({ service }) {
|
||||||
?.filter((integration) => integration?.type)
|
?.filter((integration) => integration?.type)
|
||||||
.map((integration) => ({
|
.map((integration) => ({
|
||||||
service: dynamic(() => import(`./integrations/${integration.type}`)),
|
service: dynamic(() => import(`./integrations/${integration.type}`)),
|
||||||
widget: integration,
|
widget: { ...widget, ...integration },
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
[widget.integrations],
|
[widget],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -80,13 +81,14 @@ export default function Component({ service }) {
|
||||||
<div className="sticky top-0">
|
<div className="sticky top-0">
|
||||||
{integrations.map((integration) => {
|
{integrations.map((integration) => {
|
||||||
const Integration = integration.service;
|
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 (
|
return (
|
||||||
<Integration
|
<Integration
|
||||||
key={key}
|
key={key}
|
||||||
config={integration.widget}
|
config={integration.widget}
|
||||||
params={params}
|
params={params}
|
||||||
|
setEvents={setEvents}
|
||||||
hideErrors={settings.hideErrors}
|
hideErrors={settings.hideErrors}
|
||||||
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
|
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
|
||||||
/>
|
/>
|
||||||
|
@ -95,8 +97,10 @@ export default function Component({ service }) {
|
||||||
</div>
|
</div>
|
||||||
{(!widget?.view || widget?.view === "monthly") && (
|
{(!widget?.view || widget?.view === "monthly") && (
|
||||||
<Monthly
|
<Monthly
|
||||||
|
key={`monthly-${showDate?.toFormat("yyyy-MM-dd")}`}
|
||||||
service={service}
|
service={service}
|
||||||
colorVariants={colorVariants}
|
colorVariants={colorVariants}
|
||||||
|
events={events}
|
||||||
showDate={showDate}
|
showDate={showDate}
|
||||||
setShowDate={setShowDate}
|
setShowDate={setShowDate}
|
||||||
className="flex"
|
className="flex"
|
||||||
|
@ -104,8 +108,10 @@ export default function Component({ service }) {
|
||||||
)}
|
)}
|
||||||
{widget?.view === "agenda" && (
|
{widget?.view === "agenda" && (
|
||||||
<Agenda
|
<Agenda
|
||||||
|
key={`agenda-${showDate?.toFormat("yyyy-MM-dd")}`}
|
||||||
service={service}
|
service={service}
|
||||||
colorVariants={colorVariants}
|
colorVariants={colorVariants}
|
||||||
|
events={events}
|
||||||
showDate={showDate}
|
showDate={showDate}
|
||||||
setShowDate={setShowDate}
|
setShowDate={setShowDate}
|
||||||
className="flex"
|
className="flex"
|
||||||
|
|
41
src/widgets/calendar/event.jsx
Normal file
41
src/widgets/calendar/event.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
58
src/widgets/calendar/integrations/ical.jsx
Normal file
58
src/widgets/calendar/integrations/ical.jsx
Normal 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}` }} />;
|
||||||
|
}
|
|
@ -1,12 +1,10 @@
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { useContext, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||||
import { EventContext } from "../../../utils/contexts/calendar";
|
|
||||||
import Error from "../../../components/services/widget/error";
|
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 { setEvents } = useContext(EventContext);
|
|
||||||
const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
|
const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
|
||||||
...params,
|
...params,
|
||||||
includeArtist: "false",
|
includeArtist: "false",
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { useEffect, useContext } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
|
||||||
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||||
import { EventContext } from "../../../utils/contexts/calendar";
|
|
||||||
import Error from "../../../components/services/widget/error";
|
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 { t } = useTranslation();
|
||||||
const { setEvents } = useContext(EventContext);
|
|
||||||
const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
|
const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
|
||||||
...params,
|
...params,
|
||||||
...(config?.params ?? {}),
|
...(config?.params ?? {}),
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { useEffect, useContext } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||||
import { EventContext } from "../../../utils/contexts/calendar";
|
|
||||||
import Error from "../../../components/services/widget/error";
|
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 { setEvents } = useContext(EventContext);
|
|
||||||
const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
|
const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
|
||||||
...params,
|
...params,
|
||||||
includeAuthor: "true",
|
includeAuthor: "true",
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { useEffect, useContext } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||||
import { EventContext } from "../../../utils/contexts/calendar";
|
|
||||||
import Error from "../../../components/services/widget/error";
|
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 { setEvents } = useContext(EventContext);
|
|
||||||
const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
|
const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
|
||||||
...params,
|
...params,
|
||||||
includeSeries: "true",
|
includeSeries: "true",
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { useContext, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { DateTime, Info } from "luxon";
|
import { DateTime, Info } from "luxon";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "next-i18next";
|
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 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";
|
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
|
// selected same day style
|
||||||
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 "
|
? "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
|
// today style
|
||||||
style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
|
style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
|
||||||
} else {
|
} else {
|
||||||
|
@ -61,7 +60,7 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((event) => (
|
.map((event) => (
|
||||||
<span
|
<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")}
|
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 = {
|
const dayInWeekId = {
|
||||||
monday: 1,
|
monday: 1,
|
||||||
tuesday: 2,
|
tuesday: 2,
|
||||||
|
@ -99,10 +79,9 @@ const dayInWeekId = {
|
||||||
sunday: 7,
|
sunday: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Monthly({ service, colorVariants, showDate, setShowDate }) {
|
export default function Monthly({ service, colorVariants, events, showDate, setShowDate }) {
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { events } = useContext(EventContext);
|
|
||||||
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
||||||
|
|
||||||
const dayNames = Info.weekdays("short", { locale: i18n.language });
|
const dayNames = Info.weekdays("short", { locale: i18n.language });
|
||||||
|
@ -161,7 +140,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 w-full">
|
<div className="pl-1 pr-1 pb-1 w-full">
|
||||||
<div className="flex justify-between flex-wrap">
|
<div className="flex justify-between flex-wrap">
|
||||||
{dayNames.map((name) => (
|
{dayNames.map((name) => (
|
||||||
<span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>
|
<span key={name} className={classNames(cellStyle)} style={{ width: "14%" }}>
|
||||||
|
@ -172,7 +151,7 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex justify-between flex-wrap",
|
"flex justify-between flex-wrap pb-1",
|
||||||
!eventsArray.length && widget?.integrations?.length && "animate-pulse",
|
!eventsArray.length && widget?.integrations?.length && "animate-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -191,12 +170,18 @@ export default function Monthly({ service, colorVariants, showDate, setShowDate
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col pt-1 pb-1">
|
<div className="flex flex-col">
|
||||||
{eventsArray
|
{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)
|
.slice(0, widget?.maxEvents ?? 10)
|
||||||
.map((event) => (
|
.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
33
src/widgets/calendar/proxy.js
Normal file
33
src/widgets/calendar/proxy.js
Normal 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" });
|
||||||
|
}
|
8
src/widgets/calendar/widget.js
Normal file
8
src/widgets/calendar/widget.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import calendarProxyHandler from "./proxy";
|
||||||
|
|
||||||
|
const widget = {
|
||||||
|
api: "{url}",
|
||||||
|
proxyHandler: calendarProxyHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default widget;
|
|
@ -6,6 +6,7 @@ import autobrr from "./autobrr/widget";
|
||||||
import azuredevops from "./azuredevops/widget";
|
import azuredevops from "./azuredevops/widget";
|
||||||
import bazarr from "./bazarr/widget";
|
import bazarr from "./bazarr/widget";
|
||||||
import caddy from "./caddy/widget";
|
import caddy from "./caddy/widget";
|
||||||
|
import calendar from "./calendar/widget";
|
||||||
import calibreweb from "./calibreweb/widget";
|
import calibreweb from "./calibreweb/widget";
|
||||||
import changedetectionio from "./changedetectionio/widget";
|
import changedetectionio from "./changedetectionio/widget";
|
||||||
import channelsdvrserver from "./channelsdvrserver/widget";
|
import channelsdvrserver from "./channelsdvrserver/widget";
|
||||||
|
@ -131,6 +132,7 @@ const widgets = {
|
||||||
homeassistant,
|
homeassistant,
|
||||||
homebridge,
|
homebridge,
|
||||||
healthchecks,
|
healthchecks,
|
||||||
|
ical: calendar,
|
||||||
immich,
|
immich,
|
||||||
jackett,
|
jackett,
|
||||||
jdownloader,
|
jdownloader,
|
||||||
|
|
Loading…
Add table
Reference in a new issue