feat: Add ICS Feed support (#13337)
* feat: ics-feed proof of concept pt. 1 * feat: ics-feedcalendar fixes * fix: ics feed i18n and icon issues * fix: remove debug log * chore: revert yarn.lock * fix: remove debug log --------- Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
16 files changed
+587
No files matched your search
@@ -302,6 +302,7 @@
|
||||
"welcome_to_calcom": "Welcome to {{appName}}",
|
||||
"welcome_instructions": "Tell us what to call you and let us know what timezone you’re in. You’ll be able to edit this later.",
|
||||
"connect_caldav": "Connect to CalDav (Beta)",
|
||||
"connect_ics_feed": "Connect to an ICS Feed",
|
||||
"connect": "Connect",
|
||||
"try_for_free": "Try it for free",
|
||||
"create_booking_link_with_calcom": "Create your own booking link with {{appName}}",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"current_time": "Jelenlegi idő",
|
||||
"welcome": "Üdvözöljük",
|
||||
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
||||
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
||||
"connect": "Csatlakozás",
|
||||
"try_for_free": "Próbáld ki ingyen",
|
||||
"who": "Ki",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const AppSetupMap = {
|
||||
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
|
||||
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
|
||||
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
|
||||
"ics-feed": dynamic(() => import("../../ics-feedcalendar/pages/setup")),
|
||||
zapier: dynamic(() => import("../../zapier/pages/setup")),
|
||||
make: dynamic(() => import("../../make/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
|
||||
import gtm_config_json from "./gtm/config.json";
|
||||
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
|
||||
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
|
||||
import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json";
|
||||
import intercom_config_json from "./intercom/config.json";
|
||||
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
|
||||
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
|
||||
@@ -111,6 +112,7 @@ export const appStoreMetadata = {
|
||||
gtm: gtm_config_json,
|
||||
hubspot: hubspot__metadata_ts,
|
||||
huddle01video: huddle01video__metadata_ts,
|
||||
"ics-feedcalendar": ics_feedcalendar_config_json,
|
||||
intercom: intercom_config_json,
|
||||
jitsivideo: jitsivideo__metadata_ts,
|
||||
larkcalendar: larkcalendar__metadata_ts,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const apiHandlers = {
|
||||
gtm: import("./gtm/api"),
|
||||
hubspot: import("./hubspot/api"),
|
||||
huddle01video: import("./huddle01video/api"),
|
||||
"ics-feedcalendar": import("./ics-feedcalendar/api"),
|
||||
intercom: import("./intercom/api"),
|
||||
jitsivideo: import("./jitsivideo/api"),
|
||||
larkcalendar: import("./larkcalendar/api"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import appConfig from "../config.json";
|
||||
import { CalendarService } from "../lib";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
const { urls } = req.body;
|
||||
// Get user
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: appConfig.type,
|
||||
key: symmetricEncrypt(JSON.stringify({ urls }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
appId: appConfig.slug,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const dav = new CalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: user.email },
|
||||
});
|
||||
const listedCals = await dav.listCalendars();
|
||||
|
||||
if (listedCals.length !== urls.length) {
|
||||
throw new Error(`Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}`);
|
||||
}
|
||||
|
||||
await prisma.credential.create({
|
||||
data,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Could not add ICS feeds", e);
|
||||
return res.status(500).json({ message: "Could not add ICS feeds" });
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: getInstalledAppPath({ variant: "calendar", slug: "ics-feed" }) });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return res.status(200).json({ url: "/apps/ics-feed/setup" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ICS Feed",
|
||||
"title": "ICS Feed",
|
||||
"slug": "ics-feed",
|
||||
"type": "ics-feed_calendar",
|
||||
"logo": "icon.svg",
|
||||
"variant": "calendar",
|
||||
"categories": ["calendar"],
|
||||
"publisher": "Cal.com, Inc.",
|
||||
"email": "help@cal.com",
|
||||
"description": "Import events from an ICS Feed into Cal.com.",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,272 @@
|
||||
/* eslint-disable @typescript-eslint/triple-slash-reference */
|
||||
/// <reference path="../../../types/ical.d.ts"/>
|
||||
import ICAL from "ical.js";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import type {
|
||||
Calendar,
|
||||
IntegrationCalendar,
|
||||
EventBusyDate,
|
||||
CalendarEvent,
|
||||
NewCalendarEventType,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
// for Apple's Travel Time feature only (for now)
|
||||
const getTravelDurationInSeconds = (vevent: ICAL.Component) => {
|
||||
const travelDuration: ICAL.Duration = vevent.getFirstPropertyValue("x-apple-travel-duration");
|
||||
if (!travelDuration) return 0;
|
||||
|
||||
// we can't rely on this being a valid duration and it's painful to check, so just try and catch if anything throws
|
||||
try {
|
||||
const travelSeconds = travelDuration.toSeconds();
|
||||
// integer validation as we can never be sure with ical.js
|
||||
if (!Number.isInteger(travelSeconds)) return 0;
|
||||
return travelSeconds;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const applyTravelDuration = (event: ICAL.Event, seconds: number) => {
|
||||
if (seconds <= 0) return event;
|
||||
// move event start date back by the specified travel time
|
||||
event.startDate.second -= seconds;
|
||||
return event;
|
||||
};
|
||||
|
||||
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
|
||||
export default class ICSFeedCalendarService implements Calendar {
|
||||
private urls: string[] = [];
|
||||
protected integrationName = "ics-feed_calendar";
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
const { urls } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
|
||||
this.urls = urls;
|
||||
}
|
||||
|
||||
createEvent(_event: CalendarEvent, _credentialId: number): Promise<NewCalendarEventType> {
|
||||
throw new Error("createEvent called on read-only ICS feed");
|
||||
}
|
||||
|
||||
deleteEvent(_uid: string, _event: CalendarEvent, _externalCalendarId?: string): Promise<unknown> {
|
||||
throw new Error("deleteEvent called on read-only ICS feed");
|
||||
}
|
||||
|
||||
updateEvent(
|
||||
_uid: string,
|
||||
_event: CalendarEvent,
|
||||
_externalCalendarId?: string
|
||||
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
|
||||
throw new Error("updateEvent called on read-only ICS feed");
|
||||
}
|
||||
|
||||
fetchCalendars = async (): Promise<{ url: string; vcalendar: ICAL.Component }[]> => {
|
||||
const reqPromises = await Promise.allSettled(this.urls.map((x) => fetch(x).then((y) => [x, y])));
|
||||
const reqs = reqPromises
|
||||
.filter((x) => x.status === "fulfilled")
|
||||
.map((x) => (x as PromiseFulfilledResult<[string, Response]>).value);
|
||||
const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y])));
|
||||
return res
|
||||
.map((x) => {
|
||||
try {
|
||||
const jcalData = ICAL.parse(x[1]);
|
||||
return {
|
||||
url: x[0],
|
||||
vcalendar: new ICAL.Component(jcalData),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error parsing calendar object: ", e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((x) => x !== null) as { url: string; vcalendar: ICAL.Component }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* getUserTimezoneFromDB() retrieves the timezone of a user from the database.
|
||||
*
|
||||
* @param {number} id - The user's unique identifier.
|
||||
* @returns {Promise<string | undefined>} - A Promise that resolves to the user's timezone or "Europe/London" as a default value if the timezone is not found.
|
||||
*/
|
||||
getUserTimezoneFromDB = async (id: number): Promise<string | undefined> => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
return user?.timeZone;
|
||||
};
|
||||
|
||||
/**
|
||||
* getUserId() extracts the user ID from the first calendar in an array of IntegrationCalendars.
|
||||
*
|
||||
* @param {IntegrationCalendar[]} selectedCalendars - An array of IntegrationCalendars.
|
||||
* @returns {number | null} - The user ID associated with the first calendar in the array, or null if the array is empty or the user ID is not found.
|
||||
*/
|
||||
getUserId = (selectedCalendars: IntegrationCalendar[]): number | null => {
|
||||
if (selectedCalendars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return selectedCalendars[0].userId || null;
|
||||
};
|
||||
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<EventBusyDate[]> {
|
||||
const startISOString = new Date(dateFrom).toISOString();
|
||||
|
||||
const calendars = await this.fetchCalendars();
|
||||
|
||||
const userId = this.getUserId(selectedCalendars);
|
||||
// we use the userId from selectedCalendars to fetch the user's timeZone from the database primarily for all-day events without any timezone information
|
||||
const userTimeZone = userId ? await this.getUserTimezoneFromDB(userId) : "Europe/London";
|
||||
const events: { start: string; end: string }[] = [];
|
||||
|
||||
calendars.forEach(({ vcalendar }) => {
|
||||
const vevents = vcalendar.getAllSubcomponents("vevent");
|
||||
vevents.forEach((vevent) => {
|
||||
// if event status is free or transparent, DON'T return (unlike usual getAvailability)
|
||||
//
|
||||
// commented out because a lot of public ICS feeds that describe stuff like
|
||||
// public holidays have them marked as transparent. if that is explicitly
|
||||
// added to cal.com as an ICS feed, it should probably not be ignored.
|
||||
// if (vevent?.getFirstPropertyValue("transp") === "TRANSPARENT") return;
|
||||
|
||||
const event = new ICAL.Event(vevent);
|
||||
const dtstart: { [key: string]: string } | undefined = vevent?.getFirstPropertyValue("dtstart");
|
||||
const timezone = dtstart ? dtstart["timezone"] : undefined;
|
||||
// We check if the dtstart timezone is in UTC which is actually represented by Z instead, but not recognized as that in ICAL.js as UTC
|
||||
const isUTC = timezone === "Z";
|
||||
const tzid: string | undefined = vevent?.getFirstPropertyValue("tzid") || isUTC ? "UTC" : timezone;
|
||||
// In case of icalendar, when only tzid is available without vtimezone, we need to add vtimezone explicitly to take care of timezone diff
|
||||
if (!vcalendar.getFirstSubcomponent("vtimezone")) {
|
||||
const timezoneToUse = tzid || userTimeZone;
|
||||
if (timezoneToUse) {
|
||||
try {
|
||||
const timezoneComp = new ICAL.Component("vtimezone");
|
||||
timezoneComp.addPropertyWithValue("tzid", timezoneToUse);
|
||||
const standard = new ICAL.Component("standard");
|
||||
|
||||
// get timezone offset
|
||||
const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z");
|
||||
const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z");
|
||||
|
||||
// set timezone offset
|
||||
standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom);
|
||||
standard.addPropertyWithValue("tzoffsetto", tzoffsetto);
|
||||
// provide a standard dtstart
|
||||
standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00");
|
||||
timezoneComp.addSubcomponent(standard);
|
||||
vcalendar.addSubcomponent(timezoneComp);
|
||||
} catch (e) {
|
||||
// Adds try-catch to ensure the code proceeds when Apple Calendar provides non-standard TZIDs
|
||||
console.log("error in adding vtimezone", e);
|
||||
}
|
||||
} else {
|
||||
console.error("No timezone found");
|
||||
}
|
||||
}
|
||||
const vtimezone = vcalendar.getFirstSubcomponent("vtimezone");
|
||||
|
||||
// mutate event to consider travel time
|
||||
applyTravelDuration(event, getTravelDurationInSeconds(vevent));
|
||||
|
||||
if (event.isRecurring()) {
|
||||
let maxIterations = 365;
|
||||
if (["HOURLY", "SECONDLY", "MINUTELY"].includes(event.getRecurrenceTypes())) {
|
||||
console.error(`Won't handle [${event.getRecurrenceTypes()}] recurrence`);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = dayjs(dateFrom);
|
||||
const end = dayjs(dateTo);
|
||||
const startDate = ICAL.Time.fromDateTimeString(startISOString);
|
||||
startDate.hour = event.startDate.hour;
|
||||
startDate.minute = event.startDate.minute;
|
||||
startDate.second = event.startDate.second;
|
||||
const iterator = event.iterator(startDate);
|
||||
let current: ICAL.Time;
|
||||
let currentEvent;
|
||||
let currentStart = null;
|
||||
let currentError;
|
||||
|
||||
while (
|
||||
maxIterations > 0 &&
|
||||
(currentStart === null || currentStart.isAfter(end) === false) &&
|
||||
// this iterator was poorly implemented, normally done is expected to be
|
||||
// returned
|
||||
(current = iterator.next())
|
||||
) {
|
||||
maxIterations -= 1;
|
||||
|
||||
try {
|
||||
// @see https://github.com/mozilla-comm/ical.js/issues/514
|
||||
currentEvent = event.getOccurrenceDetails(current);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== currentError) {
|
||||
currentError = error.message;
|
||||
}
|
||||
}
|
||||
if (!currentEvent) return;
|
||||
// do not mix up caldav and icalendar! For the recurring events here, the timezone
|
||||
// provided is relevant, not as pointed out in https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5
|
||||
// where recurring events are always in utc (in caldav!). Thus, apply the time zone here.
|
||||
if (vtimezone) {
|
||||
const zone = new ICAL.Timezone(vtimezone);
|
||||
currentEvent.startDate = currentEvent.startDate.convertToZone(zone);
|
||||
currentEvent.endDate = currentEvent.endDate.convertToZone(zone);
|
||||
}
|
||||
currentStart = dayjs(currentEvent.startDate.toJSDate());
|
||||
|
||||
if (currentStart.isBetween(start, end) === true) {
|
||||
events.push({
|
||||
start: currentStart.toISOString(),
|
||||
end: dayjs(currentEvent.endDate.toJSDate()).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (maxIterations <= 0) {
|
||||
console.warn("could not find any occurrence for recurring event in 365 iterations");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vtimezone) {
|
||||
const zone = new ICAL.Timezone(vtimezone);
|
||||
event.startDate = event.startDate.convertToZone(zone);
|
||||
event.endDate = event.endDate.convertToZone(zone);
|
||||
}
|
||||
|
||||
return events.push({
|
||||
start: dayjs(event.startDate.toJSDate()).toISOString(),
|
||||
end: dayjs(event.endDate.toJSDate()).toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.resolve(events);
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
const vcals = await this.fetchCalendars();
|
||||
|
||||
return vcals.map(({ url, vcalendar }) => {
|
||||
const name: string = vcalendar.getFirstPropertyValue("x-wr-calname");
|
||||
return {
|
||||
name,
|
||||
readOnly: true,
|
||||
externalId: url,
|
||||
integrationName: this.integrationName,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CalendarService } from "./CalendarService";
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/ics-feed",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/core": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/ui": "*",
|
||||
"react-hook-form": "^7.43.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Import events from an ICS Feed into Cal.com."
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Button, Form, TextField } from "@calcom/ui";
|
||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function ICSFeedSetup() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {},
|
||||
});
|
||||
|
||||
const [urls, setUrls] = useState<string[]>([""]);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorActionUrl, setErrorActionUrl] = useState("");
|
||||
|
||||
return (
|
||||
<div className="bg-emphasis flex h-screen">
|
||||
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
|
||||
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
|
||||
<div>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img src="/api/app-store/ics-feed/icon.svg" alt="ICS Feed" className="h-12 w-12 max-w-2xl" />
|
||||
</div>
|
||||
<div className="flex w-10/12 flex-col">
|
||||
<h1 className="text-default">{t("connect_ics_feed")}</h1>
|
||||
<div className="mt-1 text-sm">{t("credentials_stored_encrypted")}</div>
|
||||
<div className="my-2 mt-3">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (_) => {
|
||||
setErrorMessage("");
|
||||
const res = await fetch("/api/integrations/ics-feed/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ urls }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setErrorMessage(json?.message || t("something_went_wrong"));
|
||||
if (json.actionUrl) {
|
||||
setErrorActionUrl(json.actionUrl);
|
||||
}
|
||||
} else {
|
||||
router.push(json.url);
|
||||
}
|
||||
}}>
|
||||
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
|
||||
{urls.map((url, i) => (
|
||||
<div key={i} className="flex w-full items-center gap-2">
|
||||
<TextField
|
||||
required
|
||||
type="text"
|
||||
label={t("calendar_url")}
|
||||
value={url}
|
||||
containerClassName={`w-full ${i === 0 ? "mr-6" : ""}`}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.value as string;
|
||||
setUrls((urls) => urls.map((x, ii) => (ii === i ? newVal : x)));
|
||||
}}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
/>
|
||||
{i !== 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 h-min text-sm"
|
||||
onClick={() => setUrls((urls) => urls.filter((_, ii) => i !== ii))}>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
className="text-sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUrls((urls) => urls.concat(""));
|
||||
}}>
|
||||
{t("add")} <Plus size={16} />
|
||||
</button>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={errorMessage}
|
||||
actions={
|
||||
errorActionUrl !== "" ? (
|
||||
<Button
|
||||
href={errorActionUrl}
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
className="ml-5 w-32 !p-5">
|
||||
Go to Admin
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<svg
|
||||
width="3004"
|
||||
height="3004"
|
||||
viewBox="0 0 3004 3004"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs16" />
|
||||
<path
|
||||
fill="#DFE4E4"
|
||||
stroke="#fff"
|
||||
stroke-width="100"
|
||||
d="m2809.88 802.052 12.75 14.238v1801.24c0 168.34-136.45 304.8-304.8 304.8H485.801c-168.343 0-304.801-136.46-304.801-304.8V825.541l3.742-9.121 28.646-69.814 12.728-31.02H2732.48l14.91 16.652 62.49 69.814Z"
|
||||
id="path1" />
|
||||
<path
|
||||
fill="#CD2E47"
|
||||
stroke="#fff"
|
||||
stroke-width="100"
|
||||
d="M2822.63 835.4v50H181V585.507c0-168.345 136.457-304.814 304.801-304.814H2517.83c168.35 0 304.8 136.47 304.8 304.814V835.4Z"
|
||||
id="path2" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
stroke="#fff"
|
||||
stroke-width="70"
|
||||
d="M593.032 611.307h-3.084c-53.065 0-96.079-43.018-96.079-96.08V192.08c0-53.062 43.015-96.08 96.079-96.08h3.084c53.064 0 96.078 43.018 96.078 96.08v323.147c0 53.062-43.014 96.08-96.078 96.08ZM1050.09 611.307h-3.08c-53.065 0-96.081-43.018-96.081-96.08V192.08c0-53.063 43.016-96.08 96.081-96.08h3.08c53.07 0 96.08 43.017 96.08 96.08v323.147c0 53.062-43.01 96.08-96.08 96.08ZM1507.15 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM1964.22 611.307h-3.08c-53.07 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.01-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM2421.27 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.064 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08Z"
|
||||
id="path3" />
|
||||
<path
|
||||
fill="#DFE4E4"
|
||||
d="M2772.63 835.4v1782.13c0 140.72-114.07 254.8-254.8 254.8H485.801c-140.728 0-254.801-114.08-254.801-254.8V835.4l28.645-69.813H2710.14l62.49 69.813Z"
|
||||
id="path4" />
|
||||
<path
|
||||
fill="#CD2E47"
|
||||
d="M2772.63 585.507V835.4H231V585.507c0-140.734 114.073-254.814 254.801-254.814H2517.83c140.73 0 254.8 114.08 254.8 254.814Z"
|
||||
id="path5" />
|
||||
<path
|
||||
fill="#9B213B"
|
||||
d="M684.276 632.147H491.114c-21.767 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.647-39.413 39.414-39.413h193.162c21.768 0 39.414 17.64 39.414 39.413v82.013c0 21.774-17.646 39.414-39.414 39.414Z"
|
||||
id="path6" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
d="M593.032 576.307h-3.084c-33.733 0-61.079-27.347-61.079-61.08V192.08c0-33.733 27.346-61.08 61.079-61.08h3.084c33.733 0 61.078 27.347 61.078 61.08v323.147c0 33.733-27.345 61.08-61.078 61.08Z"
|
||||
id="path7" />
|
||||
<path
|
||||
fill="#9B213B"
|
||||
d="M1141.34 632.147H948.174c-21.768 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.646-39.413 39.414-39.413h193.166c21.76 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.65 39.414-39.41 39.414Z"
|
||||
id="path8" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
d="M1050.09 576.307h-3.08c-33.73 0-61.081-27.347-61.081-61.08V192.08c0-33.733 27.351-61.08 61.081-61.08h3.08c33.74 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.34 61.08-61.08 61.08Z"
|
||||
id="path9" />
|
||||
<path
|
||||
fill="#9B213B"
|
||||
d="M1598.4 632.147h-193.17c-21.76 0-39.41-17.64-39.41-39.414V510.72c0-21.773 17.65-39.413 39.41-39.413h193.17c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"
|
||||
id="path10" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
d="M1507.15 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
|
||||
id="path11" />
|
||||
<path
|
||||
fill="#9B213B"
|
||||
d="M2055.46 632.147H1862.3c-21.78 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.64-39.413 39.42-39.413h193.16c21.77 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.64 39.414-39.41 39.414Z"
|
||||
id="path12" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
d="M1964.22 576.307h-3.08c-33.74 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.34-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
|
||||
id="path13" />
|
||||
<path
|
||||
fill="#9B213B"
|
||||
d="M2512.52 632.147h-193.16c-21.77 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.65-39.413 39.42-39.413h193.16c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"
|
||||
id="path14" />
|
||||
<path
|
||||
fill="#1C202E"
|
||||
d="M2421.27 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
|
||||
id="path15" />
|
||||
<path
|
||||
fill="#B9C3C4"
|
||||
d="M842.32 959.6h154.945c21.575 0 39.065 17.48 39.065 39.053v154.957c0 21.56-17.49 39.06-39.065 39.06H842.32c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM1618.35 959.6h154.95c21.57 0 39.06 17.48 39.06 39.053v154.957c0 21.56-17.49 39.06-39.06 39.06h-154.95c-21.57 0-39.07-17.5-39.07-39.06V998.653c0-21.573 17.5-39.053 39.07-39.053ZM2588.39 998.653v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.94c-21.58 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.48-39.053 39.06-39.053h154.94c21.58 0 39.07 17.48 39.07 39.053ZM454.304 959.6h154.945c21.574 0 39.063 17.48 39.063 39.053v154.957c0 21.56-17.489 39.06-39.063 39.06H454.304c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM2006.36 959.6h154.95c21.57 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.5 39.06-39.07 39.06h-154.95c-21.57 0-39.05-17.5-39.05-39.06V998.653c0-21.573 17.48-39.053 39.05-39.053ZM1230.33 959.6h154.95c21.58 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.95c-21.57 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.49-39.053 39.06-39.053ZM1618.35 2092.4h154.95c21.57 0 39.06 17.48 39.06 39.05v154.95c0 21.57-17.49 39.07-39.06 39.07h-154.95c-21.57 0-39.07-17.5-39.07-39.07v-154.95c0-21.57 17.5-39.05 39.07-39.05ZM2588.39 2131.45v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.94c-21.58 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.48-39.05 39.06-39.05h154.94c21.58 0 39.07 17.48 39.07 39.05ZM2006.36 2092.4h154.95c21.57 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.5 39.07-39.07 39.07h-154.95c-21.57 0-39.05-17.5-39.05-39.07v-154.95c0-21.57 17.48-39.05 39.05-39.05ZM1230.33 2092.4h154.95c21.58 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05ZM842.32 2092.4h154.945c21.575 0 39.065 17.48 39.065 39.05v154.95c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2092.4h154.945c21.574 0 39.063 17.48 39.063 39.05v154.95c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2470h154.945c21.574 0 39.063 17.48 39.063 39.05V2664c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM842.32 2470h154.945c21.575 0 39.065 17.48 39.065 39.05V2664c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM1424.35 2509.05V2664c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05h154.95c21.58 0 39.07 17.48 39.07 39.05Z"
|
||||
id="path16" />
|
||||
<path
|
||||
style="font-weight:bold;font-size:666.667px;font-family:'Noto Serif Yezidi';-inkscape-font-specification:'Noto Serif Yezidi Bold';fill:#1c202e;stroke-width:40;paint-order:markers stroke fill"
|
||||
d="M 1157.7616,1891.9453 H 873.90725 v -85.9375 h 79.42712 v -312.8257 h -79.42712 v -85.9375 h 283.85435 v 85.9375 h -79.4272 v 312.8257 h 79.4272 z m 320.9636,9.4401 q -54.0364,0 -99.9349,-15.9505 -45.5729,-15.9505 -78.4506,-47.5261 -32.8776,-31.5755 -51.1068,-78.7761 -17.9036,-47.2005 -17.9036,-109.0495 0,-57.6172 17.2526,-104.4922 17.2526,-46.875 50.1302,-80.4037 31.5756,-32.2266 78.1251,-49.8047 46.875,-17.5782 102.2136,-17.5782 30.5989,0 55.013,3.5808 24.7396,3.2552 45.573,8.789 21.8099,6.1849 39.388,13.9974 17.9036,7.487 31.25,13.9974 v 117.5131 h -14.3229 q -9.1146,-7.8125 -23.112,-18.5547 -13.6719,-10.7422 -31.25,-21.1588 -17.9037,-10.4167 -38.737,-17.5782 -20.8334,-7.1614 -44.5964,-7.1614 -26.3672,0 -50.1302,8.4635 -23.7631,8.138 -43.9454,27.3438 -19.2057,18.5547 -31.25,49.1536 -11.7187,30.599 -11.7187,74.2188 0,45.573 12.6953,76.1719 13.0208,30.599 32.5521,48.1772 19.8568,17.9036 44.2708,25.7161 24.4141,7.487 48.1771,7.487 22.7865,0 44.9219,-6.8359 22.461,-6.836 41.3412,-18.5547 15.9505,-9.4401 29.6224,-20.1823 13.6719,-10.7422 22.461,-18.5547 h 13.0208 v 115.8854 q -18.2292,8.1381 -34.8307,15.2995 -16.6016,7.1615 -34.8308,12.3698 -23.763,6.836 -44.5964,10.4167 -20.8333,3.5807 -57.2917,3.5807 z m 651.3676,-161.7839 q 0,71.6146 -60.8724,116.5365 -60.5469,44.5964 -164.7137,44.5964 -60.2213,0 -105.1432,-10.4167 -44.5964,-10.7422 -83.6589,-27.0182 v -116.211 h 13.6718 q 38.737,30.9245 86.5886,47.526 48.1771,16.6016 92.448,16.6016 11.3932,0 29.9479,-1.9531 18.5547,-1.9531 30.2735,-6.5104 14.3229,-5.8594 23.4375,-14.6485 9.4401,-8.7891 9.4401,-26.0417 0,-15.9505 -13.6719,-27.3437 -13.3463,-11.7188 -39.388,-17.9037 -27.3438,-6.5104 -57.9428,-12.0443 -30.2734,-5.8593 -56.9661,-14.6484 -61.198,-19.8568 -88.2162,-53.711 -26.6927,-34.1797 -26.6927,-84.6354 0,-67.7084 60.5469,-110.3516 60.8724,-42.9688 156.25,-42.9688 47.8516,0 94.4011,9.4401 46.875,9.1146 81.0548,23.112 v 111.6537 h -13.3464 q -29.2969,-23.4375 -71.9401,-39.0625 -42.3178,-15.9506 -86.5886,-15.9506 -15.625,0 -31.25,2.2787 -15.2995,1.9531 -29.6225,7.8125 -12.6953,4.8828 -21.8099,14.974 -9.1146,9.7656 -9.1146,22.4609 0,19.2057 14.6485,29.6224 14.6484,10.0912 55.3386,18.5547 26.6927,5.5339 51.1068,10.7422 24.7396,5.2083 53.0599,14.3229 55.6641,18.2292 82.0313,49.8047 26.6927,31.2501 26.6927,81.3803 z"
|
||||
id="text16"
|
||||
aria-label="ICS" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -10,6 +10,7 @@ const appStore = {
|
||||
googlevideo: () => import("./googlevideo"),
|
||||
hubspot: () => import("./hubspot"),
|
||||
huddle01video: () => import("./huddle01video"),
|
||||
"ics-feedcalendar": () => import("./ics-feedcalendar"),
|
||||
jitsivideo: () => import("./jitsivideo"),
|
||||
larkcalendar: () => import("./larkcalendar"),
|
||||
office365calendar: () => import("./office365calendar"),
|
||||
|
||||
Reference in new issue
Block a user