Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description. You can also import and export ICS (VCALENDAR + VEVENT) files. It allows editing the ICS directly, for power users. Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar". You can also change the display timezone for the calendar from the settings. Finally, there's some minor documentation fixes and some other minor tweaks. Closes #56 Closes #89
This commit is contained in:
@@ -98,7 +98,7 @@ Just push to the `main` branch.
|
|||||||
|
|
||||||
## How does Contacts/CardDav and Calendar/CalDav work?
|
## How does Contacts/CardDav and Calendar/CalDav work?
|
||||||
|
|
||||||
CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and for CalDav is not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav).
|
CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and the "Calendar" client for CalDav is available since [v2.5.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.5.0). [Check this tag/release for custom-made server code where it was all mostly working, except for many edge cases, if you're interested](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav).
|
||||||
|
|
||||||
In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
|
In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ In order to share a calendar, you can either have a shared user, or you can syml
|
|||||||
|
|
||||||
## How does private file sharing work?
|
## How does private file sharing work?
|
||||||
|
|
||||||
Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks).
|
Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working, if you're interested](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks).
|
||||||
|
|
||||||
You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now.
|
You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const config: PartialDeep<Config> = {
|
|||||||
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
||||||
// },
|
// },
|
||||||
// core: {
|
// core: {
|
||||||
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts], // dashboard and files cannot be disabled
|
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled
|
||||||
// },
|
// },
|
||||||
// visuals: {
|
// visuals: {
|
||||||
// title: 'My own cloud',
|
// title: 'My own cloud',
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||||||
label: 'Contacts',
|
label: 'Contacts',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
enabledApps.includes('calendar')
|
||||||
|
? {
|
||||||
|
url: '/calendar',
|
||||||
|
label: 'Calendar',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
||||||
@@ -87,6 +93,10 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||||||
pageLabel = 'Contacts';
|
pageLabel = 'Contacts';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.startsWith('/calendar')) {
|
||||||
|
pageLabel = 'Calendar';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
|||||||
163
components/calendar/AddEventModal.tsx
Normal file
163
components/calendar/AddEventModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface AddEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialStartDate?: Date;
|
||||||
|
initiallyAllDay?: boolean;
|
||||||
|
calendars: Calendar[];
|
||||||
|
onClickSave: (newEvent: CalendarEvent) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddEventModal(
|
||||||
|
{ isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps,
|
||||||
|
) {
|
||||||
|
const newEvent = useSignal<CalendarEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
newEvent.value = null;
|
||||||
|
} else {
|
||||||
|
const startDate = new Date(initialStartDate || new Date());
|
||||||
|
|
||||||
|
startDate.setUTCMinutes(0);
|
||||||
|
startDate.setUTCSeconds(0);
|
||||||
|
startDate.setUTCMilliseconds(0);
|
||||||
|
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setUTCHours(startDate.getUTCHours() + 1);
|
||||||
|
|
||||||
|
if (initiallyAllDay) {
|
||||||
|
startDate.setUTCHours(9);
|
||||||
|
endDate.setUTCHours(18);
|
||||||
|
}
|
||||||
|
|
||||||
|
newEvent.value = {
|
||||||
|
uid: 'new',
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
calendarId: calendars[0]!.uid!,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
isAllDay: initiallyAllDay || false,
|
||||||
|
organizerEmail: '',
|
||||||
|
transparency: 'opaque',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={`fixed ${
|
||||||
|
newEvent.value ? 'block' : 'hidden'
|
||||||
|
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-96 max-w-lg bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||||
|
>
|
||||||
|
<h1 class='text-2xl font-semibold my-5'>New Event</h1>
|
||||||
|
<section class='py-5 my-2 border-y border-slate-500'>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_title'>Title</label>
|
||||||
|
<input
|
||||||
|
class='input-field'
|
||||||
|
type='text'
|
||||||
|
name='event_title'
|
||||||
|
id='event_title'
|
||||||
|
value={newEvent.value?.title || ''}
|
||||||
|
onInput={(event) => newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }}
|
||||||
|
placeholder='Dentist'
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||||
|
<section class='flex items-center justify-between'>
|
||||||
|
<select
|
||||||
|
class='input-field mr-2 !w-5/6'
|
||||||
|
name='event_calendar'
|
||||||
|
id='event_calendar'
|
||||||
|
value={newEvent.value?.calendarId || ''}
|
||||||
|
onChange={(event) => newEvent.value = { ...newEvent.value!, calendarId: event.currentTarget.value }}
|
||||||
|
>
|
||||||
|
{calendars.map((calendar) => (
|
||||||
|
<option key={calendar.uid} value={calendar.uid}>{calendar.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 block rounded-full`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: calendars.find((calendar) => calendar.uid === newEvent.value?.calendarId)
|
||||||
|
?.calendarColor,
|
||||||
|
}}
|
||||||
|
title={calendars.find((calendar) => calendar.uid === newEvent.value?.calendarId)?.calendarColor}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_start_date'>Start date</label>
|
||||||
|
<input
|
||||||
|
class='input-field'
|
||||||
|
type='datetime-local'
|
||||||
|
name='event_start_date'
|
||||||
|
id='event_start_date'
|
||||||
|
value={newEvent.value?.startDate ? new Date(newEvent.value.startDate).toISOString().substring(0, 16) : ''}
|
||||||
|
onInput={(event) =>
|
||||||
|
newEvent.value = { ...newEvent.value!, startDate: new Date(event.currentTarget.value) }}
|
||||||
|
/>
|
||||||
|
<aside class='text-sm text-slate-400 p-2 '>
|
||||||
|
Dates are set in the default calendar timezone, controlled by Radicale.
|
||||||
|
</aside>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_end_date'>End date</label>
|
||||||
|
<input
|
||||||
|
class='input-field'
|
||||||
|
type='datetime-local'
|
||||||
|
name='event_end_date'
|
||||||
|
id='event_end_date'
|
||||||
|
value={newEvent.value?.endDate ? new Date(newEvent.value.endDate).toISOString().substring(0, 16) : ''}
|
||||||
|
onInput={(event) => newEvent.value = { ...newEvent.value!, endDate: new Date(event.currentTarget.value) }}
|
||||||
|
/>
|
||||||
|
<aside class='text-sm text-slate-400 p-2 '>
|
||||||
|
Dates are set in the default calendar timezone, controlled by Radicale.
|
||||||
|
</aside>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_is_all_day'>All-day?</label>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
name='event_is_all_day'
|
||||||
|
id='event_is_all_day'
|
||||||
|
value='true'
|
||||||
|
checked={newEvent.value?.isAllDay}
|
||||||
|
onChange={(event) => newEvent.value = { ...newEvent.value!, isAllDay: event.currentTarget.checked }}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
<footer class='flex justify-between'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClickSave(newEvent.value!)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
components/calendar/CalendarViewDay.tsx
Normal file
229
components/calendar/CalendarViewDay.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { getCalendarEventStyle } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface CalendarViewDayProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Calendar[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
timezoneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewDay(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewDayProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed are stored without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
const dayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (!calendarEvent.isAllDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayDate = new Date(startDate);
|
||||||
|
const endDayDate = new Date(startDate);
|
||||||
|
endDayDate.setUTCHours(23);
|
||||||
|
endDayDate.setUTCMinutes(59);
|
||||||
|
endDayDate.setUTCSeconds(59);
|
||||||
|
endDayDate.setUTCMilliseconds(999);
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.startDate);
|
||||||
|
const eventEndDate = new Date(calendarEvent.endDate);
|
||||||
|
|
||||||
|
// Event starts and ends on this day
|
||||||
|
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends after this day
|
||||||
|
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts on and ends after this day
|
||||||
|
if (
|
||||||
|
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends on this day
|
||||||
|
if (
|
||||||
|
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours: { date: Date; isCurrentHour: boolean }[] = Array.from({ length: 24 }).map((_, index) => {
|
||||||
|
const hourNumber = index;
|
||||||
|
|
||||||
|
const date = new Date(startDate);
|
||||||
|
date.setUTCHours(hourNumber);
|
||||||
|
|
||||||
|
const shortIsoDate = date.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const isCurrentHour = shortIsoDate === today && new Date().getUTCHours() === hourNumber;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
isCurrentHour,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||||
|
<section class='border-b border-slate-500 bg-slate-700 text-center text-base font-semibold text-white flex-none rounded-t-md'>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2 rounded-t-md'>
|
||||||
|
<span>{dayFormat.format(startDate)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class='flex bg-slate-500 text-sm text-white flex-auto rounded-b-md'>
|
||||||
|
<section class='w-full rounded-b-md'>
|
||||||
|
{allDayEvents.length > 0
|
||||||
|
? (
|
||||||
|
<section
|
||||||
|
class={`relative bg-slate-700 min-h-16 px-3 py-2 text-slate-100 border-b border-b-slate-600`}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||||
|
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||||
|
class='cursor-pointer'
|
||||||
|
title='Add a new all-day event'
|
||||||
|
>
|
||||||
|
All-day
|
||||||
|
</time>
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{allDayEvents.map((calendarEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||||
|
style={getCalendarEventStyle(calendarEvent, visibleCalendars)}
|
||||||
|
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||||
|
>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{calendarEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{hours.map((hour, hourIndex) => {
|
||||||
|
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const startHourDate = new Date(shortIsoDate);
|
||||||
|
startHourDate.setUTCHours(hour.date.getUTCHours());
|
||||||
|
const endHourDate = new Date(shortIsoDate);
|
||||||
|
endHourDate.setUTCHours(hour.date.getUTCHours());
|
||||||
|
endHourDate.setUTCMinutes(59);
|
||||||
|
endHourDate.setUTCSeconds(59);
|
||||||
|
endHourDate.setUTCMilliseconds(999);
|
||||||
|
|
||||||
|
const isLastHour = hourIndex === 23;
|
||||||
|
|
||||||
|
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (calendarEvent.isAllDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.startDate);
|
||||||
|
const eventEndDate = new Date(calendarEvent.endDate);
|
||||||
|
eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||||
|
|
||||||
|
// Event starts and ends on this hour
|
||||||
|
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends after this hour
|
||||||
|
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts on and ends after this hour
|
||||||
|
if (
|
||||||
|
eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends on this hour
|
||||||
|
if (
|
||||||
|
eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} ${
|
||||||
|
hourIndex <= 6 ? 'min-h-8' : 'min-h-16'
|
||||||
|
} px-3 py-2 ${hour.isCurrentHour ? '' : 'text-slate-100'} ${
|
||||||
|
isLastHour ? 'rounded-b-md' : ''
|
||||||
|
} border-b border-b-slate-600`}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={startHourDate.toISOString()}
|
||||||
|
onClick={() => onClickAddEvent(startHourDate)}
|
||||||
|
class='cursor-pointer'
|
||||||
|
title='Add a new event'
|
||||||
|
>
|
||||||
|
{hourFormat.format(startHourDate)}
|
||||||
|
</time>
|
||||||
|
{hourEvents.length > 0
|
||||||
|
? (
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{hourEvents.map((hourEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||||
|
style={getCalendarEventStyle(hourEvent, visibleCalendars)}
|
||||||
|
onClick={() => onClickOpenEvent(hourEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(hourEvent.startDate).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(hourEvent.startDate))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{hourEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/calendar/CalendarViewMonth.tsx
Normal file
166
components/calendar/CalendarViewMonth.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { getCalendarEventStyle, getWeeksForMonth } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface CalendarViewWeekProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Calendar[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
timezoneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewWeek(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
|
||||||
|
const weeks = getWeeksForMonth(new Date(startDate));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||||
|
<section class='grid grid-cols-7 gap-px border-b border-slate-500 bg-slate-700 text-center text-xs font-semibold text-white flex-none rounded-t-md'>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'>
|
||||||
|
<span>Mon</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2'>
|
||||||
|
<span>Tue</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2'>
|
||||||
|
<span>Wed</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2'>
|
||||||
|
<span>Thu</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2'>
|
||||||
|
<span>Fri</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2'>
|
||||||
|
<span>Sat</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
|
||||||
|
<span>Sun</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class='flex bg-slate-500 text-xs text-white flex-auto rounded-b-md'>
|
||||||
|
<section class='w-full grid grid-cols-7 grid-rows-5 gap-px rounded-b-md'>
|
||||||
|
{weeks.map((week, weekIndex) =>
|
||||||
|
week.map((day, dayIndex) => {
|
||||||
|
const shortIsoDate = day.date.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const startDayDate = new Date(shortIsoDate);
|
||||||
|
const endDayDate = new Date(shortIsoDate);
|
||||||
|
endDayDate.setUTCHours(23);
|
||||||
|
endDayDate.setUTCMinutes(59);
|
||||||
|
endDayDate.setUTCSeconds(59);
|
||||||
|
endDayDate.setUTCMilliseconds(999);
|
||||||
|
|
||||||
|
const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0;
|
||||||
|
const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1;
|
||||||
|
|
||||||
|
const isToday = today === shortIsoDate;
|
||||||
|
|
||||||
|
const dayEvents = calendarEvents.filter((calendarEvent) => {
|
||||||
|
const eventStartDate = new Date(calendarEvent.startDate);
|
||||||
|
const eventEndDate = new Date(calendarEvent.endDate);
|
||||||
|
|
||||||
|
// Event starts and ends on this day
|
||||||
|
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends after this day
|
||||||
|
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts on and ends after this day
|
||||||
|
if (
|
||||||
|
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends on this day
|
||||||
|
if (
|
||||||
|
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${
|
||||||
|
day.isSameMonth ? '' : 'text-slate-100'
|
||||||
|
} ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={shortIsoDate}
|
||||||
|
class={`cursor-pointer ${
|
||||||
|
isToday ? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickAddEvent(new Date(`${shortIsoDate}T09:00`))}
|
||||||
|
title='Add a new event'
|
||||||
|
>
|
||||||
|
{day.date.getUTCDate()}
|
||||||
|
</time>
|
||||||
|
{dayEvents.length > 0
|
||||||
|
? (
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{[...dayEvents].slice(0, 2).map((dayEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`flex px-2 py-1 rounded-md hover:no-underline hover:opacity-60`}
|
||||||
|
style={getCalendarEventStyle(dayEvent, visibleCalendars)}
|
||||||
|
onClick={() => onClickOpenEvent(dayEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(dayEvent.startDate).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(dayEvent.startDate))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{dayEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 2
|
||||||
|
? (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href={`/calendar/view=day&startDate=${shortIsoDate}`}
|
||||||
|
class='flex bg-gray-700 px-2 py-1 rounded-md hover:no-underline hover:opacity-60'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
components/calendar/CalendarViewWeek.tsx
Normal file
225
components/calendar/CalendarViewWeek.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { getCalendarEventStyle, getDaysForWeek } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface CalendarViewWeekProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Calendar[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
timezoneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewWeek(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
const weekDayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
|
||||||
|
const days = getDaysForWeek(new Date(startDate));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||||
|
<section class='w-full grid gap-px grid-flow-col rounded-md text-white text-xs bg-slate-600 calendar-week-view-days'>
|
||||||
|
{days.map((day, dayIndex) => {
|
||||||
|
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (!calendarEvent.isAllDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayDate = new Date(day.date);
|
||||||
|
const endDayDate = new Date(day.date);
|
||||||
|
endDayDate.setUTCHours(23);
|
||||||
|
endDayDate.setUTCMinutes(59);
|
||||||
|
endDayDate.setUTCSeconds(59);
|
||||||
|
endDayDate.setUTCMilliseconds(999);
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.startDate);
|
||||||
|
const eventEndDate = new Date(calendarEvent.endDate);
|
||||||
|
|
||||||
|
// Event starts and ends on this day
|
||||||
|
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends after this day
|
||||||
|
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts on and ends after this day
|
||||||
|
if (
|
||||||
|
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends on this day
|
||||||
|
if (
|
||||||
|
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFirstDay = dayIndex === 0;
|
||||||
|
const isLastDay = dayIndex === 6;
|
||||||
|
const isToday = new Date(day.date).toISOString().substring(0, 10) === today;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
class={`flex justify-center ${isToday ? 'bg-[#51A4FB]' : 'bg-gray-900'} py-2 ${
|
||||||
|
isFirstDay ? 'rounded-tl-md' : ''
|
||||||
|
} ${isLastDay ? 'rounded-tr-md' : ''} text-center text-xs font-semibold text-white`}
|
||||||
|
>
|
||||||
|
<span>{weekDayFormat.format(day.date)}</span>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class={`relative bg-slate-700 min-h-8 px-3 py-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||||
|
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||||
|
class='cursor-pointer'
|
||||||
|
title='Add a new all-day event'
|
||||||
|
>
|
||||||
|
All-day
|
||||||
|
</time>
|
||||||
|
{allDayEvents.length > 0
|
||||||
|
? (
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{allDayEvents.map((calendarEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||||
|
style={getCalendarEventStyle(calendarEvent, visibleCalendars)}
|
||||||
|
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||||
|
>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{calendarEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
{day.hours.map((hour, hourIndex) => {
|
||||||
|
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const startHourDate = new Date(shortIsoDate);
|
||||||
|
startHourDate.setUTCHours(hour.date.getUTCHours());
|
||||||
|
const endHourDate = new Date(shortIsoDate);
|
||||||
|
endHourDate.setUTCHours(hour.date.getUTCHours());
|
||||||
|
endHourDate.setUTCMinutes(59);
|
||||||
|
endHourDate.setUTCSeconds(59);
|
||||||
|
endHourDate.setUTCMilliseconds(999);
|
||||||
|
|
||||||
|
const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0;
|
||||||
|
const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6;
|
||||||
|
|
||||||
|
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (calendarEvent.isAllDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.startDate);
|
||||||
|
const eventEndDate = new Date(calendarEvent.endDate);
|
||||||
|
eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||||
|
|
||||||
|
// Event starts and ends on this hour
|
||||||
|
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends after this hour
|
||||||
|
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts on and ends after this hour
|
||||||
|
if (
|
||||||
|
eventStartDate >= startHourDate && eventStartDate <= endHourDate &&
|
||||||
|
eventEndDate >= endHourDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts before and ends on this hour
|
||||||
|
if (
|
||||||
|
eventStartDate <= startHourDate && eventEndDate >= startHourDate &&
|
||||||
|
eventEndDate <= endHourDate
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} px-3 py-2 ${
|
||||||
|
hour.isCurrentHour ? '' : 'text-slate-100'
|
||||||
|
} ${isLastHourOfFirstDay ? 'rounded-bl-md' : ''} ${isLastHourOfLastDay ? 'rounded-br-md' : ''}`}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={startHourDate.toISOString()}
|
||||||
|
onClick={() => onClickAddEvent(startHourDate)}
|
||||||
|
class='cursor-pointer'
|
||||||
|
title='Add a new event'
|
||||||
|
>
|
||||||
|
{hourFormat.format(startHourDate)}
|
||||||
|
</time>
|
||||||
|
{hourEvents.length > 0
|
||||||
|
? (
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{hourEvents.map((hourEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||||
|
style={getCalendarEventStyle(hourEvent, visibleCalendars)}
|
||||||
|
onClick={() => onClickOpenEvent(hourEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(hourEvent.startDate).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(hourEvent.startDate))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{hourEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
components/calendar/ImportEventsModal.tsx
Normal file
86
components/calendar/ImportEventsModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { Calendar } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface ImportEventsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
calendars: Calendar[];
|
||||||
|
onClickImport: (calendarId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportEventsModal(
|
||||||
|
{ isOpen, calendars, onClickImport, onClose }: ImportEventsModalProps,
|
||||||
|
) {
|
||||||
|
const newCalendarId = useSignal<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
newCalendarId.value = null;
|
||||||
|
} else {
|
||||||
|
newCalendarId.value = calendars[0]!.uid!;
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={`fixed ${
|
||||||
|
newCalendarId.value ? 'block' : 'hidden'
|
||||||
|
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||||
|
>
|
||||||
|
<h1 class='text-2xl font-semibold my-5'>Import Events</h1>
|
||||||
|
<section class='py-5 my-2 border-y border-slate-500'>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||||
|
<section class='flex items-center justify-between'>
|
||||||
|
<select
|
||||||
|
class='input-field mr-2 !w-5/6'
|
||||||
|
name='event_calendar'
|
||||||
|
id='event_calendar'
|
||||||
|
value={newCalendarId.value || ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
newCalendarId.value = event.currentTarget.value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{calendars.map((calendar) => (
|
||||||
|
<option key={calendar.uid} value={calendar.uid}>{calendar.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 block rounded-full`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: calendars.find((calendar) => calendar.uid === newCalendarId.value)?.calendarColor,
|
||||||
|
}}
|
||||||
|
title={calendars.find((calendar) => calendar.uid === newCalendarId.value)?.calendarColor}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
<footer class='flex justify-between'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClickImport(newCalendarId.value!)}
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
655
components/calendar/MainCalendar.tsx
Normal file
655
components/calendar/MainCalendar.tsx
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { capitalizeWord } from '/lib/utils/misc.ts';
|
||||||
|
import { generateVCalendar } from '/lib/utils/calendar.ts';
|
||||||
|
import {
|
||||||
|
RequestBody as ExportRequestBody,
|
||||||
|
ResponseBody as ExportResponseBody,
|
||||||
|
} from '/routes/api/calendar/export-events.tsx';
|
||||||
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx';
|
||||||
|
import {
|
||||||
|
RequestBody as DeleteRequestBody,
|
||||||
|
ResponseBody as DeleteResponseBody,
|
||||||
|
} from '/routes/api/calendar/delete-event.tsx';
|
||||||
|
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx';
|
||||||
|
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||||
|
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||||
|
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||||
|
import AddEventModal from './AddEventModal.tsx';
|
||||||
|
import ViewEventModal from './ViewEventModal.tsx';
|
||||||
|
import SearchEvents from './SearchEvents.tsx';
|
||||||
|
import ImportEventsModal from './ImportEventsModal.tsx';
|
||||||
|
|
||||||
|
interface MainCalendarProps {
|
||||||
|
initialCalendars: Calendar[];
|
||||||
|
initialCalendarEvents: CalendarEvent[];
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timezoneId: string;
|
||||||
|
timezoneUtcOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainCalendar(
|
||||||
|
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
|
||||||
|
MainCalendarProps,
|
||||||
|
) {
|
||||||
|
const isAdding = useSignal<boolean>(false);
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const isExporting = useSignal<boolean>(false);
|
||||||
|
const isImporting = useSignal<boolean>(false);
|
||||||
|
const calendars = useSignal<Calendar[]>(initialCalendars);
|
||||||
|
const isViewOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
|
||||||
|
const openEventModal = useSignal<
|
||||||
|
{ isOpen: boolean; calendar?: typeof initialCalendars[number]; calendarEvent?: CalendarEvent }
|
||||||
|
>({ isOpen: false });
|
||||||
|
const newEventModal = useSignal<{ isOpen: boolean; initialStartDate?: Date; initiallyAllDay?: boolean }>({
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
const openImportModal = useSignal<
|
||||||
|
{ isOpen: boolean }
|
||||||
|
>({ isOpen: false });
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const visibleCalendars = calendars.value.filter((calendar) => calendar.isVisible);
|
||||||
|
|
||||||
|
function onClickAddEvent(startDate = new Date(), isAllDay = false) {
|
||||||
|
if (newEventModal.value.isOpen) {
|
||||||
|
newEventModal.value = {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendars.value.length === 0) {
|
||||||
|
alert('You need to create a calendar first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newEventModal.value = {
|
||||||
|
isOpen: true,
|
||||||
|
initialStartDate: startDate,
|
||||||
|
initiallyAllDay: isAllDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickSaveNewEvent(newEvent: CalendarEvent) {
|
||||||
|
if (isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: AddRequestBody = {
|
||||||
|
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||||
|
calendarView: view,
|
||||||
|
calendarStartDate: startDate,
|
||||||
|
calendarId: newEvent.calendarId,
|
||||||
|
title: newEvent.title,
|
||||||
|
startDate: new Date(newEvent.startDate).toISOString(),
|
||||||
|
endDate: new Date(newEvent.endDate).toISOString(),
|
||||||
|
isAllDay: newEvent.isAllDay,
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/calendar/add-event`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as AddResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to add event!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarEvents.value = [...result.newCalendarEvents];
|
||||||
|
|
||||||
|
newEventModal.value = {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseNewEvent() {
|
||||||
|
newEventModal.value = {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImportExportOptionsDropdown() {
|
||||||
|
isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleViewOptionsDropdown() {
|
||||||
|
isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOpenEvent(calendarEvent: CalendarEvent) {
|
||||||
|
if (openEventModal.value.isOpen) {
|
||||||
|
openEventModal.value = {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = calendars.value.find((calendar) => calendar.uid === calendarEvent.calendarId)!;
|
||||||
|
|
||||||
|
openEventModal.value = {
|
||||||
|
isOpen: true,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickDeleteEvent(calendarEventId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this event?')) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = {
|
||||||
|
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||||
|
calendarView: view,
|
||||||
|
calendarStartDate: startDate,
|
||||||
|
calendarEventId,
|
||||||
|
calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.uid === calendarEventId)!.calendarId,
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/calendar/delete-event`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete event!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarEvents.value = [...result.newCalendarEvents];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
|
||||||
|
openEventModal.value = { isOpen: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseOpenEvent() {
|
||||||
|
openEventModal.value = {
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') {
|
||||||
|
const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString()
|
||||||
|
.substring(0, 10);
|
||||||
|
|
||||||
|
if (changeTo === 'today') {
|
||||||
|
if (today === startDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/calendar?view=${view}&startDate=${today}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeTo === 'previous') {
|
||||||
|
let newStartDate = previousMonth;
|
||||||
|
|
||||||
|
if (view === 'day') {
|
||||||
|
newStartDate = previousDay;
|
||||||
|
} else if (view === 'week') {
|
||||||
|
newStartDate = previousWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStartDate === startDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newStartDate = nextMonth;
|
||||||
|
|
||||||
|
if (view === 'day') {
|
||||||
|
newStartDate = nextDay;
|
||||||
|
} else if (view === 'week') {
|
||||||
|
newStartDate = nextWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStartDate === startDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickChangeView(newView: MainCalendarProps['view']) {
|
||||||
|
if (view === newView) {
|
||||||
|
isViewOptionsDropdownOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/calendar?view=${newView}&startDate=${startDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickImportICS() {
|
||||||
|
openImportModal.value = { isOpen: true };
|
||||||
|
isImportExportOptionsDropdownOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickChooseImportCalendar(calendarId: string) {
|
||||||
|
isImportExportOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.click();
|
||||||
|
|
||||||
|
fileInput.onchange = (event) => {
|
||||||
|
const files = (event.target as HTMLInputElement)?.files!;
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (fileRead) => {
|
||||||
|
const importFileContents = fileRead.target?.result;
|
||||||
|
|
||||||
|
if (!importFileContents || isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = true;
|
||||||
|
|
||||||
|
openImportModal.value = { isOpen: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const icsToImport = importFileContents!.toString();
|
||||||
|
|
||||||
|
const requestBody: ImportRequestBody = {
|
||||||
|
icsToImport,
|
||||||
|
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||||
|
calendarView: view,
|
||||||
|
calendarStartDate: startDate,
|
||||||
|
calendarId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/calendar/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ImportResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to import file!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarEvents.value = [...result.newCalendarEvents];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickExportICS() {
|
||||||
|
isImportExportOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isExporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
const fileName = ['calendar-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics']
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: ExportRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.uid!) };
|
||||||
|
const response = await fetch(`/api/calendar/export-events`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ExportResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to get contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportContents = generateVCalendar([...result.calendarEvents]);
|
||||||
|
|
||||||
|
// Add content-type
|
||||||
|
const vCardContent = ['data:text/calendar; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const data = vCardContent;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', data);
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<section class='relative inline-block text-left mr-2'>
|
||||||
|
<section class='flex flex-row items-center justify-start'>
|
||||||
|
<a href='/calendars' class='mr-4 whitespace-nowrap'>Manage calendars</a>
|
||||||
|
<SearchEvents calendars={visibleCalendars} onClickOpenEvent={onClickOpenEvent} />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='flex items-center justify-end'>
|
||||||
|
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||||
|
<time datetime={startDate}>{dateFormat.format(new Date(startDate))}</time>
|
||||||
|
</h3>
|
||||||
|
<section class='ml-2 relative flex items-center rounded-md bg-slate-700 shadow-sm md:items-stretch'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='flex h-9 w-12 items-center justify-center rounded-l-md text-white hover:bg-slate-600 focus:relative'
|
||||||
|
onClick={() => onClickChangeStartDate('previous')}
|
||||||
|
>
|
||||||
|
<span class='sr-only'>Previous {view}</span>
|
||||||
|
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-3.5 text-sm font-semibold text-white hover:bg-slate-600 focus:relative'
|
||||||
|
onClick={() => onClickChangeStartDate('today')}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='flex h-9 w-12 items-center justify-center rounded-r-md text-white hover:bg-slate-600 pl-1 focus:relative'
|
||||||
|
onClick={() => onClickChangeStartDate('next')}
|
||||||
|
>
|
||||||
|
<span class='sr-only'>Next {view}</span>
|
||||||
|
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section class='relative inline-block text-left ml-2'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||||
|
id='view-button'
|
||||||
|
aria-expanded='true'
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={() => toggleViewOptionsDropdown()}
|
||||||
|
>
|
||||||
|
{capitalizeWord(view)}
|
||||||
|
<svg class='-mr-1 h-5 w-5 text-white' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
|
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='view-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<div class='py-1'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||||
|
view === 'day' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickChangeView('day')}
|
||||||
|
>
|
||||||
|
Day
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||||
|
view === 'week' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickChangeView('week')}
|
||||||
|
>
|
||||||
|
Week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||||
|
view === 'month' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickChangeView('month')}
|
||||||
|
>
|
||||||
|
Month
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class='relative inline-block text-left ml-2'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||||
|
id='import-export-button'
|
||||||
|
aria-expanded='true'
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={() => toggleImportExportOptionsDropdown()}
|
||||||
|
>
|
||||||
|
ICS
|
||||||
|
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
|
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='import-export-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<div class='py-1'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickImportICS()}
|
||||||
|
>
|
||||||
|
Import ICS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickExportICS()}
|
||||||
|
>
|
||||||
|
Export ICS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Add new event'
|
||||||
|
onClick={() => onClickAddEvent()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/add.svg'
|
||||||
|
alt='Add new event'
|
||||||
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{view === 'day'
|
||||||
|
? (
|
||||||
|
<CalendarViewDay
|
||||||
|
startDate={new Date(startDate)}
|
||||||
|
visibleCalendars={visibleCalendars}
|
||||||
|
calendarEvents={calendarEvents.value}
|
||||||
|
onClickAddEvent={onClickAddEvent}
|
||||||
|
onClickOpenEvent={onClickOpenEvent}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{view === 'week'
|
||||||
|
? (
|
||||||
|
<CalendarViewWeek
|
||||||
|
startDate={new Date(startDate)}
|
||||||
|
visibleCalendars={visibleCalendars}
|
||||||
|
calendarEvents={calendarEvents.value}
|
||||||
|
onClickAddEvent={onClickAddEvent}
|
||||||
|
onClickOpenEvent={onClickOpenEvent}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{view === 'month'
|
||||||
|
? (
|
||||||
|
<CalendarViewMonth
|
||||||
|
startDate={new Date(startDate)}
|
||||||
|
visibleCalendars={visibleCalendars}
|
||||||
|
calendarEvents={calendarEvents.value}
|
||||||
|
onClickAddEvent={onClickAddEvent}
|
||||||
|
onClickOpenEvent={onClickOpenEvent}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isExporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isImporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='flex flex-row items-center justify-start my-12'>
|
||||||
|
<span class='font-semibold'>CalDav URL:</span>{' '}
|
||||||
|
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/caldav</code>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AddEventModal
|
||||||
|
isOpen={newEventModal.value.isOpen}
|
||||||
|
initialStartDate={newEventModal.value.initialStartDate}
|
||||||
|
initiallyAllDay={newEventModal.value.initiallyAllDay}
|
||||||
|
calendars={calendars.value}
|
||||||
|
onClickSave={onClickSaveNewEvent}
|
||||||
|
onClose={onCloseNewEvent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ViewEventModal
|
||||||
|
isOpen={openEventModal.value.isOpen}
|
||||||
|
calendar={openEventModal.value.calendar!}
|
||||||
|
calendarEvent={openEventModal.value.calendarEvent!}
|
||||||
|
onClickDelete={onClickDeleteEvent}
|
||||||
|
onClose={onCloseOpenEvent}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportEventsModal
|
||||||
|
isOpen={openImportModal.value.isOpen}
|
||||||
|
calendars={calendars.value}
|
||||||
|
onClickImport={onClickChooseImportCalendar}
|
||||||
|
onClose={() => {
|
||||||
|
openImportModal.value = { isOpen: false };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
components/calendar/SearchEvents.tsx
Normal file
152
components/calendar/SearchEvents.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx';
|
||||||
|
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||||
|
interface SearchEventsProps {
|
||||||
|
calendars: Calendar[];
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) {
|
||||||
|
const isSearching = useSignal<boolean>(false);
|
||||||
|
const areResultsVisible = useSignal<boolean>(false);
|
||||||
|
const calendarEvents = useSignal<CalendarEvent[]>([]);
|
||||||
|
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'UTC', // Calendar dates are parsed without timezone info, so we need to force to UTC so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarIds = calendars.map((calendar) => calendar.uid!);
|
||||||
|
|
||||||
|
function searchEvents(searchTerm: string) {
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm.trim().length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
areResultsVisible.value = false;
|
||||||
|
|
||||||
|
searchTimeout.value = setTimeout(async () => {
|
||||||
|
isSearching.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: RequestBody = { calendarIds, searchTerm };
|
||||||
|
const response = await fetch(`/api/calendar/search-events`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to search events!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarEvents.value = result.matchingCalendarEvents;
|
||||||
|
|
||||||
|
if (calendarEvents.value.length > 0) {
|
||||||
|
areResultsVisible.value = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching.value = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
if (calendarEvents.value.length > 0) {
|
||||||
|
areResultsVisible.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
if (closeTimeout.value) {
|
||||||
|
clearTimeout(closeTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTimeout.value = setTimeout(() => {
|
||||||
|
areResultsVisible.value = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeTimeout.value) {
|
||||||
|
clearTimeout(closeTimeout.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
class='input-field w-72 mr-2'
|
||||||
|
type='search'
|
||||||
|
name='search'
|
||||||
|
placeholder='Search events...'
|
||||||
|
onInput={(event) => searchEvents(event.currentTarget.value)}
|
||||||
|
onFocus={() => onFocus()}
|
||||||
|
onBlur={() => onBlur()}
|
||||||
|
/>
|
||||||
|
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||||
|
{areResultsVisible.value
|
||||||
|
? (
|
||||||
|
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||||
|
<section
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='view-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<section class='py-1'>
|
||||||
|
<ol class='mt-2'>
|
||||||
|
{calendarEvents.value.map((calendarEvent) => (
|
||||||
|
<li class='mb-1'>
|
||||||
|
<a
|
||||||
|
href='javascript:void(0);'
|
||||||
|
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: calendars.find((calendar) => calendar.uid === calendarEvent.calendarId)
|
||||||
|
?.calendarColor || getColorAsHex('bg-gray-700'),
|
||||||
|
}}
|
||||||
|
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(calendarEvent.startDate).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{dateFormat.format(new Date(calendarEvent.startDate))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{calendarEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/calendar/ViewEventModal.tsx
Normal file
159
components/calendar/ViewEventModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { convertRRuleToWords } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface ViewEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
calendarEvent: CalendarEvent;
|
||||||
|
calendar: Calendar;
|
||||||
|
onClickDelete: (eventId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
timezoneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewEventModal(
|
||||||
|
{ isOpen, calendarEvent, calendar, onClickDelete, onClose, timezoneId }: ViewEventModalProps,
|
||||||
|
) {
|
||||||
|
if (!calendarEvent || !calendar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={`fixed ${
|
||||||
|
isOpen ? 'block' : 'hidden'
|
||||||
|
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-96 max-w-lg bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||||
|
>
|
||||||
|
<h1 class='text-2xl font-semibold my-5'>{calendarEvent.title}</h1>
|
||||||
|
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between items-center'>
|
||||||
|
<span>
|
||||||
|
{calendarEvent.startDate ? allDayEventDateFormat.format(new Date(calendarEvent.startDate)) : ''}
|
||||||
|
</span>
|
||||||
|
{calendarEvent.isAllDay ? <span>All-day</span> : (
|
||||||
|
<span>
|
||||||
|
{calendarEvent.startDate ? hourFormat.format(new Date(calendarEvent.startDate)) : ''} -{' '}
|
||||||
|
{calendarEvent.endDate ? hourFormat.format(new Date(calendarEvent.endDate)) : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500 flex justify-between items-center'>
|
||||||
|
<span>
|
||||||
|
{calendar.displayName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 ml-2 block rounded-full`}
|
||||||
|
title={calendar.calendarColor}
|
||||||
|
style={{ backgroundColor: calendar.calendarColor }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{calendarEvent.description
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<article class='overflow-auto max-w-full max-h-80 font-mono text-sm whitespace-pre-wrap'>
|
||||||
|
{calendarEvent.description}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{calendarEvent.eventUrl
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a href={calendarEvent.eventUrl} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{calendarEvent.eventUrl}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{calendarEvent.location
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/maps?q=${encodeURIComponent(calendarEvent.location)}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{calendarEvent.location}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{Array.isArray(calendarEvent.attendees) && calendarEvent.attendees.length > 0
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
{calendarEvent.attendees.map((attendee) => (
|
||||||
|
<p class='my-1'>
|
||||||
|
<a href={`mailto:${attendee.email}`} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{attendee.name || attendee.email}
|
||||||
|
</a>{' '}
|
||||||
|
- {attendee.status}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{calendarEvent.isRecurring && calendarEvent.recurringRrule
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p class='text-xs'>
|
||||||
|
Repeats {convertRRuleToWords(calendarEvent.recurringRrule, { capitalizeSentence: false })}.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{Array.isArray(calendarEvent.reminders) && calendarEvent.reminders.length > 0
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
{calendarEvent.reminders.map((reminder) => (
|
||||||
|
<p class='my-1 text-xs'>
|
||||||
|
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.startDate))} via{' '}
|
||||||
|
{reminder.type}.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<footer class='flex justify-between mt-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClickDelete(calendarEvent.uid!)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/calendar/${calendarEvent.uid}?calendarId=${calendar.uid}`}
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,6 @@ interface MainFilesProps {
|
|||||||
initialPath: string;
|
initialPath: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
isFileSharingAllowed: boolean;
|
isFileSharingAllowed: boolean;
|
||||||
isCalDavEnabled?: boolean;
|
|
||||||
fileShareId?: string;
|
fileShareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ export default function MainFiles(
|
|||||||
initialPath,
|
initialPath,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
isFileSharingAllowed,
|
isFileSharingAllowed,
|
||||||
isCalDavEnabled,
|
|
||||||
fileShareId,
|
fileShareId,
|
||||||
}: MainFilesProps,
|
}: MainFilesProps,
|
||||||
) {
|
) {
|
||||||
@@ -888,15 +886,6 @@ export default function MainFiles(
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{!fileShareId && isCalDavEnabled
|
|
||||||
? (
|
|
||||||
<section class='flex flex-row items-center justify-start my-12'>
|
|
||||||
<span class='font-semibold'>CalDav URL:</span>{' '}
|
|
||||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/caldav</code>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
|
|
||||||
{!fileShareId
|
{!fileShareId
|
||||||
? (
|
? (
|
||||||
<CreateDirectoryModal
|
<CreateDirectoryModal
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
radicale:
|
radicale:
|
||||||
image: tomsquest/docker-radicale:3.5.4.0
|
image: tomsquest/docker-radicale:3.5.4.0
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:5232:5232
|
- 5232:5232
|
||||||
init: true
|
init: true
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
website:
|
website:
|
||||||
image: ghcr.io/bewcloud/bewcloud:v2.4.7
|
image: ghcr.io/bewcloud/bewcloud:v2.5.0
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8000:8000
|
- 127.0.0.1:8000:8000
|
||||||
|
|||||||
28
fresh.gen.ts
28
fresh.gen.ts
@@ -15,6 +15,14 @@ import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/m
|
|||||||
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||||
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
|
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
|
||||||
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
|
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
|
||||||
|
import * as $api_calendar_add_event from './routes/api/calendar/add-event.tsx';
|
||||||
|
import * as $api_calendar_add from './routes/api/calendar/add.tsx';
|
||||||
|
import * as $api_calendar_delete_event from './routes/api/calendar/delete-event.tsx';
|
||||||
|
import * as $api_calendar_delete from './routes/api/calendar/delete.tsx';
|
||||||
|
import * as $api_calendar_export_events from './routes/api/calendar/export-events.tsx';
|
||||||
|
import * as $api_calendar_import from './routes/api/calendar/import.tsx';
|
||||||
|
import * as $api_calendar_search_events from './routes/api/calendar/search-events.tsx';
|
||||||
|
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
|
||||||
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
|
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
|
||||||
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||||
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
|
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
|
||||||
@@ -55,6 +63,9 @@ import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
|||||||
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||||
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||||
import * as $caldav from './routes/caldav.tsx';
|
import * as $caldav from './routes/caldav.tsx';
|
||||||
|
import * as $calendar from './routes/calendar.tsx';
|
||||||
|
import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx';
|
||||||
|
import * as $calendars from './routes/calendars.tsx';
|
||||||
import * as $carddav from './routes/carddav.tsx';
|
import * as $carddav from './routes/carddav.tsx';
|
||||||
import * as $contacts from './routes/contacts.tsx';
|
import * as $contacts from './routes/contacts.tsx';
|
||||||
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||||
@@ -82,6 +93,9 @@ import * as $signup from './routes/signup.tsx';
|
|||||||
import * as $Settings from './islands/Settings.tsx';
|
import * as $Settings from './islands/Settings.tsx';
|
||||||
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
|
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
|
||||||
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
|
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||||
|
import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx';
|
||||||
|
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
|
||||||
|
import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx';
|
||||||
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||||
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||||
@@ -110,6 +124,14 @@ const manifest = {
|
|||||||
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
|
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
|
||||||
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
|
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
|
||||||
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
|
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
|
||||||
|
'./routes/api/calendar/add-event.tsx': $api_calendar_add_event,
|
||||||
|
'./routes/api/calendar/add.tsx': $api_calendar_add,
|
||||||
|
'./routes/api/calendar/delete-event.tsx': $api_calendar_delete_event,
|
||||||
|
'./routes/api/calendar/delete.tsx': $api_calendar_delete,
|
||||||
|
'./routes/api/calendar/export-events.tsx': $api_calendar_export_events,
|
||||||
|
'./routes/api/calendar/import.tsx': $api_calendar_import,
|
||||||
|
'./routes/api/calendar/search-events.tsx': $api_calendar_search_events,
|
||||||
|
'./routes/api/calendar/update.tsx': $api_calendar_update,
|
||||||
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
|
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
|
||||||
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||||
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
|
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
|
||||||
@@ -150,6 +172,9 @@ const manifest = {
|
|||||||
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||||
'./routes/api/notes/save.tsx': $api_notes_save,
|
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||||
'./routes/caldav.tsx': $caldav,
|
'./routes/caldav.tsx': $caldav,
|
||||||
|
'./routes/calendar.tsx': $calendar,
|
||||||
|
'./routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_,
|
||||||
|
'./routes/calendars.tsx': $calendars,
|
||||||
'./routes/carddav.tsx': $carddav,
|
'./routes/carddav.tsx': $carddav,
|
||||||
'./routes/contacts.tsx': $contacts,
|
'./routes/contacts.tsx': $contacts,
|
||||||
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||||
@@ -179,6 +204,9 @@ const manifest = {
|
|||||||
'./islands/Settings.tsx': $Settings,
|
'./islands/Settings.tsx': $Settings,
|
||||||
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
|
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
|
||||||
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
|
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
|
||||||
|
'./islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper,
|
||||||
|
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
|
||||||
|
'./islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent,
|
||||||
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||||
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
|||||||
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
|
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
|
||||||
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
|
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
|
||||||
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
|
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
|
||||||
|
import { getTimeZones } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
@@ -15,8 +16,10 @@ interface SettingsProps {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
currency?: SupportedCurrencySymbol;
|
currency?: SupportedCurrencySymbol;
|
||||||
|
timezoneId?: string;
|
||||||
isExpensesAppEnabled: boolean;
|
isExpensesAppEnabled: boolean;
|
||||||
isMultiFactorAuthEnabled: boolean;
|
isMultiFactorAuthEnabled: boolean;
|
||||||
|
isCalendarAppEnabled: boolean;
|
||||||
helpEmail: string;
|
helpEmail: string;
|
||||||
user: {
|
user: {
|
||||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||||
@@ -29,7 +32,8 @@ export type Action =
|
|||||||
| 'change-password'
|
| 'change-password'
|
||||||
| 'change-dav-password'
|
| 'change-dav-password'
|
||||||
| 'delete-account'
|
| 'delete-account'
|
||||||
| 'change-currency';
|
| 'change-currency'
|
||||||
|
| 'change-timezone';
|
||||||
|
|
||||||
export const actionWords = new Map<Action, string>([
|
export const actionWords = new Map<Action, string>([
|
||||||
['change-email', 'change email'],
|
['change-email', 'change email'],
|
||||||
@@ -38,9 +42,10 @@ export const actionWords = new Map<Action, string>([
|
|||||||
['change-dav-password', 'change WebDav password'],
|
['change-dav-password', 'change WebDav password'],
|
||||||
['delete-account', 'delete account'],
|
['delete-account', 'delete account'],
|
||||||
['change-currency', 'change currency'],
|
['change-currency', 'change currency'],
|
||||||
|
['change-timezone', 'change timezone'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) {
|
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
|
||||||
const fields: FormField[] = [
|
const fields: FormField[] = [
|
||||||
{
|
{
|
||||||
name: 'action',
|
name: 'action',
|
||||||
@@ -122,6 +127,20 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr
|
|||||||
value: getFormDataField(formData, 'currency') || currency,
|
value: getFormDataField(formData, 'currency') || currency,
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
} else if (action === 'change-timezone') {
|
||||||
|
const timezones = getTimeZones();
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'timezone',
|
||||||
|
label: 'Timezone',
|
||||||
|
type: 'select',
|
||||||
|
options: timezones.map((timezone) => ({
|
||||||
|
value: timezone.id,
|
||||||
|
label: timezone.label,
|
||||||
|
})),
|
||||||
|
value: getFormDataField(formData, 'timezone') || timezoneId,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
@@ -132,8 +151,10 @@ export default function Settings(
|
|||||||
error,
|
error,
|
||||||
notice,
|
notice,
|
||||||
currency,
|
currency,
|
||||||
|
timezoneId,
|
||||||
isExpensesAppEnabled,
|
isExpensesAppEnabled,
|
||||||
isMultiFactorAuthEnabled,
|
isMultiFactorAuthEnabled,
|
||||||
|
isCalendarAppEnabled,
|
||||||
helpEmail,
|
helpEmail,
|
||||||
user,
|
user,
|
||||||
}: SettingsProps,
|
}: SettingsProps,
|
||||||
@@ -201,7 +222,9 @@ export default function Settings(
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method='POST' class='mb-12'>
|
<form method='POST' class='mb-12'>
|
||||||
{formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))}
|
{formFields('change-currency', formData, currency, timezoneId).map((field) =>
|
||||||
|
generateFieldHtml(field, formData)
|
||||||
|
)}
|
||||||
<section class='flex justify-end mt-8 mb-4'>
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
<button class='button-secondary' type='submit'>Change currency</button>
|
<button class='button-secondary' type='submit'>Change currency</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -210,6 +233,26 @@ export default function Settings(
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
|
{isCalendarAppEnabled
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your timezone</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
This is only used in the calendar app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-timezone', formData, currency, timezoneId).map((field) =>
|
||||||
|
generateFieldHtml(field, formData)
|
||||||
|
)}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change timezone</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{isMultiFactorAuthEnabled
|
{isMultiFactorAuthEnabled
|
||||||
? (
|
? (
|
||||||
<MultiFactorAuthSettings
|
<MultiFactorAuthSettings
|
||||||
|
|||||||
30
islands/calendar/CalendarWrapper.tsx
Normal file
30
islands/calendar/CalendarWrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import MainCalendar from '/components/calendar/MainCalendar.tsx';
|
||||||
|
|
||||||
|
interface CalendarWrapperProps {
|
||||||
|
initialCalendars: Calendar[];
|
||||||
|
initialCalendarEvents: CalendarEvent[];
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timezoneId: string;
|
||||||
|
timezoneUtcOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function CalendarWrapper(
|
||||||
|
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
|
||||||
|
CalendarWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainCalendar
|
||||||
|
initialCalendars={initialCalendars}
|
||||||
|
initialCalendarEvents={initialCalendarEvents}
|
||||||
|
view={view}
|
||||||
|
startDate={startDate}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
timezoneUtcOffset={timezoneUtcOffset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
islands/calendar/Calendars.tsx
Normal file
314
islands/calendar/Calendars.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { Calendar } from '/lib/models/calendar.ts';
|
||||||
|
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/lib/utils/calendar.ts';
|
||||||
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx';
|
||||||
|
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx';
|
||||||
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx';
|
||||||
|
|
||||||
|
interface CalendarsProps {
|
||||||
|
initialCalendars: Calendar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
|
const isAdding = useSignal<boolean>(false);
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const isSaving = useSignal<boolean>(false);
|
||||||
|
const calendars = useSignal<Calendar[]>(initialCalendars);
|
||||||
|
const openCalendar = useSignal<Calendar | null>(null);
|
||||||
|
|
||||||
|
async function onClickAddCalendar() {
|
||||||
|
if (isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (prompt(`What's the **name** for the new calendar?`) || '').trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('A name is required for a new calendar!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: AddRequestBody = { name };
|
||||||
|
const response = await fetch(`/api/calendar/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as AddResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to add calendar!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendars.value = [...result.newCalendars];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickDeleteCalendar(calendarId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this calendar and all its events?')) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = { calendarId };
|
||||||
|
const response = await fetch(`/api/calendar/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete calendar!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendars.value = [...result.newCalendars];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickSaveOpenCalendar() {
|
||||||
|
if (isSaving.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openCalendar.value?.uid) {
|
||||||
|
alert('A calendar is required to update one!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openCalendar.value?.displayName) {
|
||||||
|
alert('A name is required to update the calendar!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openCalendar.value?.calendarColor) {
|
||||||
|
alert('A color is required to update the calendar!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: UpdateRequestBody = {
|
||||||
|
id: openCalendar.value.uid!,
|
||||||
|
name: openCalendar.value.displayName!,
|
||||||
|
color: openCalendar.value.calendarColor!,
|
||||||
|
isVisible: openCalendar.value.isVisible!,
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/calendar/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as UpdateResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to update calendar!');
|
||||||
|
}
|
||||||
|
|
||||||
|
calendars.value = [...result.newCalendars];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = false;
|
||||||
|
openCalendar.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Add new calendar'
|
||||||
|
onClick={() => onClickAddCalendar()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/add.svg'
|
||||||
|
alt='Add new calendar'
|
||||||
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-white shadow-sm rounded-md'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium'>Name</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium'>Color</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium'>Visible?</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium w-20'></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||||
|
{calendars.value.map((calendar) => (
|
||||||
|
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-medium'>
|
||||||
|
{calendar.displayName}
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4 text-slate-200'>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 inline-block rounded-full cursor-pointer`}
|
||||||
|
title={calendar.calendarColor}
|
||||||
|
style={{ backgroundColor: calendar.calendarColor }}
|
||||||
|
onClick={() => openCalendar.value = { ...calendar }}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4'>
|
||||||
|
{calendar.isVisible ? 'Yes' : 'No'}
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4'>
|
||||||
|
<span
|
||||||
|
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||||
|
onClick={() => onClickDeleteCalendar(calendar.uid!)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
class='red drop-shadow-md'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt='Delete calendar'
|
||||||
|
title='Delete calendar'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{calendars.value.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||||
|
<div class='text-md'>
|
||||||
|
<div class='font-medium text-slate-400'>No calendars to show</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isSaving.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value && !isSaving.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={`fixed ${
|
||||||
|
openCalendar.value ? 'block' : 'hidden'
|
||||||
|
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={`fixed ${
|
||||||
|
openCalendar.value ? 'block' : 'hidden'
|
||||||
|
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||||
|
>
|
||||||
|
<h1 class='text-2xl font-semibold my-5'>Edit Calendar</h1>
|
||||||
|
<section class='py-5 my-2 border-y border-slate-500'>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='calendar_name'>Name</label>
|
||||||
|
<input
|
||||||
|
class='input-field'
|
||||||
|
type='text'
|
||||||
|
name='calendar_name'
|
||||||
|
id='calendar_name'
|
||||||
|
value={openCalendar.value?.displayName || ''}
|
||||||
|
onInput={(event) =>
|
||||||
|
openCalendar.value = { ...openCalendar.value!, displayName: event.currentTarget.value }}
|
||||||
|
placeholder='Personal'
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
|
||||||
|
<section class='flex items-center justify-between'>
|
||||||
|
<select
|
||||||
|
class='input-field mr-2 !w-5/6'
|
||||||
|
name='calendar_color'
|
||||||
|
id='calendar_color'
|
||||||
|
value={openCalendar.value?.calendarColor || ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
openCalendar.value = { ...openCalendar.value!, calendarColor: event.currentTarget.value }}
|
||||||
|
>
|
||||||
|
{CALENDAR_COLOR_OPTIONS.map((color) => <option key={color} value={getColorAsHex(color)}>{color}
|
||||||
|
</option>)}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 block rounded-full`}
|
||||||
|
style={{ backgroundColor: openCalendar.value?.calendarColor }}
|
||||||
|
title={openCalendar.value?.calendarColor}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='block mb-2'>
|
||||||
|
<label class='text-slate-300 block pb-1' for='calendar_is_visible'>Visible?</label>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
name='calendar_is_visible'
|
||||||
|
id='calendar_is_visible'
|
||||||
|
value='true'
|
||||||
|
checked={openCalendar.value?.isVisible}
|
||||||
|
onChange={(event) =>
|
||||||
|
openCalendar.value = { ...openCalendar.value!, isVisible: event.currentTarget.checked }}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
<footer class='flex justify-between'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClickSaveOpenCalendar()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => openCalendar.value = null}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
islands/calendar/ViewCalendarEvent.tsx
Normal file
263
islands/calendar/ViewCalendarEvent.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||||
|
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||||
|
import {
|
||||||
|
RequestBody as DeleteRequestBody,
|
||||||
|
ResponseBody as DeleteResponseBody,
|
||||||
|
} from '/routes/api/calendar/delete-event.tsx';
|
||||||
|
|
||||||
|
interface ViewCalendarEventProps {
|
||||||
|
initialCalendarEvent: CalendarEvent;
|
||||||
|
calendars: Calendar[];
|
||||||
|
formData: Record<string, any>;
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[], updateType: 'raw' | 'ui') {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'update-type',
|
||||||
|
label: 'Update type',
|
||||||
|
type: 'hidden',
|
||||||
|
value: updateType,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (updateType === 'ui') {
|
||||||
|
fields.push({
|
||||||
|
name: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Dentis',
|
||||||
|
value: calendarEvent.title,
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'calendarId',
|
||||||
|
label: 'Calendar',
|
||||||
|
type: 'select',
|
||||||
|
value: calendarEvent.calendarId,
|
||||||
|
options: calendars.map((calendar) => ({ label: calendar.displayName!, value: calendar.uid! })),
|
||||||
|
required: true,
|
||||||
|
description: 'Cannot be changed after the event has been created.',
|
||||||
|
}, {
|
||||||
|
name: 'startDate',
|
||||||
|
label: 'Start date',
|
||||||
|
type: 'datetime-local',
|
||||||
|
value: new Date(calendarEvent.startDate).toISOString().substring(0, 16),
|
||||||
|
required: true,
|
||||||
|
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
|
||||||
|
}, {
|
||||||
|
name: 'endDate',
|
||||||
|
label: 'End date',
|
||||||
|
type: 'datetime-local',
|
||||||
|
value: new Date(calendarEvent.endDate).toISOString().substring(0, 16),
|
||||||
|
required: true,
|
||||||
|
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
|
||||||
|
}, {
|
||||||
|
name: 'isAllDay',
|
||||||
|
label: 'All-day?',
|
||||||
|
type: 'checkbox',
|
||||||
|
placeholder: 'YYYYMMDD',
|
||||||
|
value: 'true',
|
||||||
|
required: false,
|
||||||
|
checked: calendarEvent.isAllDay,
|
||||||
|
}, {
|
||||||
|
name: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
value: calendarEvent.status,
|
||||||
|
options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({
|
||||||
|
label: capitalizeWord(status),
|
||||||
|
value: status,
|
||||||
|
})),
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'Just a regular check-up.',
|
||||||
|
value: calendarEvent.description,
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'eventUrl',
|
||||||
|
label: 'URL',
|
||||||
|
type: 'url',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
value: calendarEvent.eventUrl,
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'location',
|
||||||
|
label: 'Location',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Birmingham, UK',
|
||||||
|
value: calendarEvent.location,
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'transparency',
|
||||||
|
label: 'Transparency',
|
||||||
|
type: 'select',
|
||||||
|
value: calendarEvent.transparency,
|
||||||
|
options: (['opaque', 'transparent'] as CalendarEvent['transparency'][]).map((
|
||||||
|
transparency,
|
||||||
|
) => ({
|
||||||
|
label: capitalizeWord(transparency),
|
||||||
|
value: transparency,
|
||||||
|
})),
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (updateType === 'raw') {
|
||||||
|
fields.push({
|
||||||
|
name: 'ics',
|
||||||
|
label: 'Raw ICS',
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'Raw ICS...',
|
||||||
|
value: calendarEvent.data,
|
||||||
|
description:
|
||||||
|
'This is the raw ICS for this event. Use this to manually update the event _if_ you know what you are doing.',
|
||||||
|
rows: '10',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewCalendarEvent(
|
||||||
|
{ initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps,
|
||||||
|
) {
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const calendarEvent = useSignal<CalendarEvent>(initialCalendarEvent);
|
||||||
|
|
||||||
|
const formData = convertObjectToFormData(formDataObject);
|
||||||
|
|
||||||
|
async function onClickDeleteEvent() {
|
||||||
|
const message = calendarEvent.peek().isRecurring
|
||||||
|
? 'Are you sure you want to delete _all_ instances of this recurring event?'
|
||||||
|
: 'Are you sure you want to delete this event?';
|
||||||
|
if (confirm(message)) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = {
|
||||||
|
calendarIds: calendars.map((calendar) => calendar.uid!),
|
||||||
|
calendarView: 'day',
|
||||||
|
calendarStartDate: new Date().toISOString().substring(0, 10),
|
||||||
|
calendarEventId: calendarEvent.value.uid!,
|
||||||
|
calendarId: calendarEvent.value.calendarId,
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/calendar/delete-event`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete event!');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = '/calendar';
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Delete event'
|
||||||
|
onClick={() => onClickDeleteEvent()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
alt='Delete event'
|
||||||
|
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>Failed to update!</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>Success!</h3>
|
||||||
|
<p>{notice}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(calendarEvent.peek(), calendars, 'ui').map((field) => generateFieldHtml(field, formData))}
|
||||||
|
|
||||||
|
<section class='flex justify-end items-center mt-8 mb-4'>
|
||||||
|
{calendarEvent.peek().isRecurring
|
||||||
|
? (
|
||||||
|
<p class='text-sm text-slate-400 mr-4'>
|
||||||
|
Note that you'll update all instances of this recurring event.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<button class='button' type='submit'>Update event</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class='my-8 border-slate-700' />
|
||||||
|
|
||||||
|
<details class='mb-12 group'>
|
||||||
|
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
||||||
|
Edit Raw ICS{' '}
|
||||||
|
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
||||||
|
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(calendarEvent.peek(), calendars, 'raw').map((field) => generateFieldHtml(field, formData))}
|
||||||
|
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button' type='submit'>Update ICS</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ interface FilesWrapperProps {
|
|||||||
initialPath: string;
|
initialPath: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
isFileSharingAllowed: boolean;
|
isFileSharingAllowed: boolean;
|
||||||
isCalDavEnabled?: boolean;
|
|
||||||
fileShareId?: string;
|
fileShareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ export default function FilesWrapper(
|
|||||||
initialPath,
|
initialPath,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
isFileSharingAllowed,
|
isFileSharingAllowed,
|
||||||
isCalDavEnabled,
|
|
||||||
fileShareId,
|
fileShareId,
|
||||||
}: FilesWrapperProps,
|
}: FilesWrapperProps,
|
||||||
) {
|
) {
|
||||||
@@ -30,7 +28,6 @@ export default function FilesWrapper(
|
|||||||
initialPath={initialPath}
|
initialPath={initialPath}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
isFileSharingAllowed={isFileSharingAllowed}
|
isFileSharingAllowed={isFileSharingAllowed}
|
||||||
isCalDavEnabled={isCalDavEnabled}
|
|
||||||
fileShareId={fileShareId}
|
fileShareId={fileShareId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class AppConfig {
|
|||||||
allowPublicSharing: false,
|
allowPublicSharing: false,
|
||||||
},
|
},
|
||||||
core: {
|
core: {
|
||||||
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'],
|
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||||
},
|
},
|
||||||
visuals: {
|
visuals: {
|
||||||
title: '',
|
title: '',
|
||||||
|
|||||||
321
lib/models/calendar.ts
Normal file
321
lib/models/calendar.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { createDAVClient } from 'tsdav';
|
||||||
|
|
||||||
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
import { getColorAsHex, parseVCalendar } from '/lib/utils/calendar.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
|
import { UserModel } from '/lib/models/user.ts';
|
||||||
|
|
||||||
|
interface DAVObject extends Record<string, any> {
|
||||||
|
data?: string;
|
||||||
|
displayName?: string;
|
||||||
|
ctag?: string;
|
||||||
|
url: string;
|
||||||
|
uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Calendar extends DAVObject {
|
||||||
|
calendarColor?: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent extends DAVObject {
|
||||||
|
calendarId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
title: string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
organizerEmail: string;
|
||||||
|
attendees?: CalendarEventAttendee[];
|
||||||
|
reminders?: CalendarEventReminder[];
|
||||||
|
transparency: 'opaque' | 'transparent';
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
eventUrl?: string;
|
||||||
|
sequence?: number;
|
||||||
|
isRecurring?: boolean;
|
||||||
|
recurringRrule?: string;
|
||||||
|
recurrenceId?: string;
|
||||||
|
recurrenceMasterUid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventAttendee {
|
||||||
|
email: string;
|
||||||
|
status: 'accepted' | 'rejected' | 'invited';
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventReminder {
|
||||||
|
uid?: string;
|
||||||
|
startDate: string;
|
||||||
|
type: 'email' | 'sound' | 'display';
|
||||||
|
acknowledgedAt?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||||
|
|
||||||
|
async function getClient(userId: string) {
|
||||||
|
const client = await createDAVClient({
|
||||||
|
serverUrl: calendarConfig.calDavUrl,
|
||||||
|
credentials: {},
|
||||||
|
authMethod: 'Custom',
|
||||||
|
// deno-lint-ignore require-await
|
||||||
|
authFunction: async () => {
|
||||||
|
return {
|
||||||
|
'X-Remote-User': userId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fetchOptions: {
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
defaultAccountType: 'caldav',
|
||||||
|
rootUrl: `${calendarConfig.calDavUrl}/`,
|
||||||
|
principalUrl: `${calendarConfig.calDavUrl}/${userId}/`,
|
||||||
|
homeUrl: `${calendarConfig.calDavUrl}/${userId}/`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarModel {
|
||||||
|
static async list(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Calendar[]> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/`;
|
||||||
|
|
||||||
|
const davCalendars: DAVObject[] = await client.fetchCalendars({
|
||||||
|
calendar: {
|
||||||
|
url: calendarUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await UserModel.getById(userId);
|
||||||
|
|
||||||
|
const calendars: Calendar[] = davCalendars.map((davCalendar) => {
|
||||||
|
const uid = davCalendar.url.split('/').filter(Boolean).pop()!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...davCalendar,
|
||||||
|
displayName: decodeURIComponent(davCalendar.displayName || '(empty)'),
|
||||||
|
calendarColor: decodeURIComponent(davCalendar.calendarColor || getColorAsHex('bg-gray-700')),
|
||||||
|
isVisible: !user.extra.hidden_calendar_ids?.includes(uid),
|
||||||
|
uid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(
|
||||||
|
userId: string,
|
||||||
|
calendarId: string,
|
||||||
|
): Promise<Calendar | undefined> {
|
||||||
|
const calendars = await this.list(userId);
|
||||||
|
|
||||||
|
return calendars.find((calendar) => calendar.uid === calendarId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
userId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const calendarId = crypto.randomUUID();
|
||||||
|
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
||||||
|
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
await client.makeCalendar({
|
||||||
|
url: calendarUrl,
|
||||||
|
props: {
|
||||||
|
displayName: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(
|
||||||
|
userId: string,
|
||||||
|
calendarUrl: string,
|
||||||
|
displayName: string,
|
||||||
|
color?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because tsdav doesn't have PROPPATCH
|
||||||
|
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:proppatch xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>${encodeURIComponent(displayName)}</d:displayname>
|
||||||
|
${color ? `<a:calendar-color>${encodeURIComponent(color)}</a:calendar-color>` : ''}
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</d:proppatch>`;
|
||||||
|
|
||||||
|
await fetch(calendarUrl, {
|
||||||
|
method: 'PROPPATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'X-Remote-User': userId,
|
||||||
|
},
|
||||||
|
body: xmlBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(
|
||||||
|
userId: string,
|
||||||
|
calendarUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
await client.deleteObject({
|
||||||
|
url: calendarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarEventModel {
|
||||||
|
private static async fetchByCalendarId(
|
||||||
|
userId: string,
|
||||||
|
calendarId: string,
|
||||||
|
dateRange?: { start: Date; end: Date },
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
const fetchOptions: { calendar: { url: string }; timeRange?: { start: string; end: string }; expand?: boolean } = {
|
||||||
|
calendar: {
|
||||||
|
url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
fetchOptions.timeRange = {
|
||||||
|
start: dateRange.start.toISOString(),
|
||||||
|
end: dateRange.end.toISOString(),
|
||||||
|
};
|
||||||
|
fetchOptions.expand = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions);
|
||||||
|
|
||||||
|
const calendarEvents: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
for (const davCalendarEvent of davCalendarEvents) {
|
||||||
|
let uid = davCalendarEvent.url.split('/').filter(Boolean).pop()!;
|
||||||
|
|
||||||
|
const parsedEvents = parseVCalendar(davCalendarEvent.data || '');
|
||||||
|
|
||||||
|
for (const parsedEvent of parsedEvents) {
|
||||||
|
if (parsedEvent.uid) {
|
||||||
|
uid = parsedEvent.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarEvents.push({
|
||||||
|
...davCalendarEvent,
|
||||||
|
...parsedEvent,
|
||||||
|
uid,
|
||||||
|
calendarId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendarEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list(
|
||||||
|
userId: string,
|
||||||
|
calendarIds: string[],
|
||||||
|
dateRange?: { start: Date; end: Date },
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
const allCalendarEvents: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
await concurrentPromises(
|
||||||
|
calendarIds.map((calendarId) => async () => {
|
||||||
|
const calendarEvents = await this.fetchByCalendarId(userId, calendarId, dateRange);
|
||||||
|
|
||||||
|
allCalendarEvents.push(...calendarEvents);
|
||||||
|
|
||||||
|
return calendarEvents;
|
||||||
|
}),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allCalendarEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(
|
||||||
|
userId: string,
|
||||||
|
calendarId: string,
|
||||||
|
eventId: string,
|
||||||
|
): Promise<CalendarEvent | undefined> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects({
|
||||||
|
calendar: {
|
||||||
|
url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`,
|
||||||
|
},
|
||||||
|
objectUrls: [`${calendarConfig.calDavUrl}/${userId}/${calendarId}/${eventId}.ics`],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (davCalendarEvents.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const davCalendarEvent = davCalendarEvents[0];
|
||||||
|
|
||||||
|
const calendarEvent: CalendarEvent = {
|
||||||
|
...davCalendarEvent,
|
||||||
|
...parseVCalendar(davCalendarEvent.data || '')[0],
|
||||||
|
uid: eventId,
|
||||||
|
calendarId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return calendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
userId: string,
|
||||||
|
calendarId: string,
|
||||||
|
eventId: string,
|
||||||
|
vCalendar: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
||||||
|
|
||||||
|
await client.createCalendarObject({
|
||||||
|
calendar: {
|
||||||
|
url: calendarUrl,
|
||||||
|
},
|
||||||
|
iCalString: vCalendar,
|
||||||
|
filename: `${eventId}.ics`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(
|
||||||
|
userId: string,
|
||||||
|
eventUrl: string,
|
||||||
|
ics: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
await client.updateCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: eventUrl,
|
||||||
|
data: ics,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(
|
||||||
|
userId: string,
|
||||||
|
eventUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getClient(userId);
|
||||||
|
|
||||||
|
await client.deleteCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: eventUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ export interface User {
|
|||||||
dav_hashed_password?: string;
|
dav_hashed_password?: string;
|
||||||
expenses_currency?: SupportedCurrencySymbol;
|
expenses_currency?: SupportedCurrencySymbol;
|
||||||
multi_factor_auth_methods?: MultiFactorAuthMethod[];
|
multi_factor_auth_methods?: MultiFactorAuthMethod[];
|
||||||
|
hidden_calendar_ids?: string[];
|
||||||
|
timezone?: {
|
||||||
|
id: string;
|
||||||
|
utcOffset: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
@@ -147,7 +152,7 @@ export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
|
|||||||
|
|
||||||
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
||||||
|
|
||||||
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts';
|
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts' | 'calendar';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
957
lib/utils/calendar.ts
Normal file
957
lib/utils/calendar.ts
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
import { Calendar, CalendarEvent, CalendarEventAttendee, CalendarEventReminder } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
export const CALENDAR_COLOR_OPTIONS = [
|
||||||
|
'bg-red-700',
|
||||||
|
'bg-red-950',
|
||||||
|
'bg-orange-700',
|
||||||
|
'bg-orange-950',
|
||||||
|
'bg-amber-700',
|
||||||
|
'bg-yellow-800',
|
||||||
|
'bg-lime-700',
|
||||||
|
'bg-lime-950',
|
||||||
|
'bg-green-700',
|
||||||
|
'bg-emerald-800',
|
||||||
|
'bg-teal-700',
|
||||||
|
'bg-cyan-700',
|
||||||
|
'bg-sky-800',
|
||||||
|
'bg-blue-900',
|
||||||
|
'bg-indigo-700',
|
||||||
|
'bg-violet-700',
|
||||||
|
'bg-purple-800',
|
||||||
|
'bg-fuchsia-700',
|
||||||
|
'bg-pink-800',
|
||||||
|
'bg-rose-700',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CALENDAR_COLOR_OPTIONS_HEX = [
|
||||||
|
'#B51E1F',
|
||||||
|
'#450A0A',
|
||||||
|
'#BF4310',
|
||||||
|
'#431407',
|
||||||
|
'#B0550F',
|
||||||
|
'#834F13',
|
||||||
|
'#4D7D16',
|
||||||
|
'#1A2E05',
|
||||||
|
'#148041',
|
||||||
|
'#066048',
|
||||||
|
'#107873',
|
||||||
|
'#0E7490',
|
||||||
|
'#075985',
|
||||||
|
'#1E3A89',
|
||||||
|
'#423BCA',
|
||||||
|
'#6A2BD9',
|
||||||
|
'#6923A9',
|
||||||
|
'#9D21B1',
|
||||||
|
'#9C174D',
|
||||||
|
'#BC133D',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getColorAsHex(calendarColor: string) {
|
||||||
|
const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor);
|
||||||
|
|
||||||
|
return CALENDAR_COLOR_OPTIONS_HEX[colorIndex] || '#384354';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdFromVEvent(vEvent: string): string {
|
||||||
|
const lines = vEvent.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// Loop through every line and find the UID line
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('UID:')) {
|
||||||
|
const uid = line.replace('UID:', '');
|
||||||
|
return uid.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTextIntoVEvents(text: string): string[] {
|
||||||
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
const vEvents: string[] = [];
|
||||||
|
const currentVEvent: string[] = [];
|
||||||
|
let hasFoundBeginVEvent = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('BEGIN:VEVENT')) {
|
||||||
|
hasFoundBeginVEvent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFoundBeginVEvent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVEvent.push(line);
|
||||||
|
|
||||||
|
if (line.startsWith('END:VEVENT')) {
|
||||||
|
vEvents.push(currentVEvent.join('\n'));
|
||||||
|
currentVEvent.length = 0;
|
||||||
|
hasFoundBeginVEvent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateRangeForCalendarView(
|
||||||
|
calendarStartDate: string,
|
||||||
|
calendarView: 'day' | 'week' | 'month',
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const dateRange = { start: new Date(calendarStartDate), end: new Date(calendarStartDate) };
|
||||||
|
|
||||||
|
if (calendarView === 'day') {
|
||||||
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 1);
|
||||||
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 1);
|
||||||
|
} else if (calendarView === 'week') {
|
||||||
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 7);
|
||||||
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 7);
|
||||||
|
} else {
|
||||||
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 7);
|
||||||
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
|
||||||
|
if (status === 'accepted' || status === 'rejected') {
|
||||||
|
return status.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `NEEDS-ACTION`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttendeeStatusFromVCalendar(
|
||||||
|
status: 'NEEDS-ACTION' | 'ACCEPTED' | 'REJECTED',
|
||||||
|
): CalendarEventAttendee['status'] {
|
||||||
|
if (status === 'ACCEPTED' || status === 'REJECTED') {
|
||||||
|
return status.toLowerCase() as CalendarEventAttendee['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'invited';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVCalendarDate(date: Date | string) {
|
||||||
|
return new Date(date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafelyEscapedTextForVCalendar(text: string) {
|
||||||
|
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafelyUnescapedTextFromVCalendar(text: string) {
|
||||||
|
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVCalendar(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
createdDate: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const vCalendarText = events.map((event) => generateVEvent(event, createdDate)).join('\n');
|
||||||
|
|
||||||
|
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
||||||
|
line.trim()
|
||||||
|
).filter(
|
||||||
|
Boolean,
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVEvent(calendarEvent: CalendarEvent, createdDate: Date = new Date()): string {
|
||||||
|
const vEventText = `BEGIN:VEVENT
|
||||||
|
DTSTAMP:${getVCalendarDate(createdDate)}
|
||||||
|
${
|
||||||
|
calendarEvent.isAllDay
|
||||||
|
? `DTSTART;VALUE=DATE:${getVCalendarDate(calendarEvent.startDate).substring(0, 8)}`
|
||||||
|
: `DTSTART:${getVCalendarDate(calendarEvent.startDate)}`
|
||||||
|
}
|
||||||
|
${
|
||||||
|
calendarEvent.isAllDay
|
||||||
|
? `DTEND;VALUE=DATE:${getVCalendarDate(calendarEvent.endDate).substring(0, 8)}`
|
||||||
|
: `DTEND:${getVCalendarDate(calendarEvent.endDate)}`
|
||||||
|
}
|
||||||
|
ORGANIZER;CN=:mailto:${calendarEvent.organizerEmail}
|
||||||
|
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
|
||||||
|
TRANSP:${calendarEvent.transparency.toUpperCase()}
|
||||||
|
UID:${calendarEvent.uid}
|
||||||
|
${calendarEvent.isRecurring && calendarEvent.recurringRrule ? `RRULE:${calendarEvent.recurringRrule}` : ''}
|
||||||
|
${calendarEvent.sequence && calendarEvent.sequence > 0 ? `SEQUENCE:${calendarEvent.sequence}` : 'SEQUENCE:0'}
|
||||||
|
CREATED:${getVCalendarDate(createdDate)}
|
||||||
|
LAST-MODIFIED:${getVCalendarDate(createdDate)}
|
||||||
|
${
|
||||||
|
calendarEvent.description
|
||||||
|
? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(calendarEvent.description.replaceAll('\r', ''))}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${calendarEvent.location ? `LOCATION:${getSafelyEscapedTextForVCalendar(calendarEvent.location)}` : ''}
|
||||||
|
${calendarEvent.eventUrl ? `URL:${getSafelyEscapedTextForVCalendar(calendarEvent.eventUrl)}` : ''}
|
||||||
|
${
|
||||||
|
calendarEvent.attendees?.map((attendee) =>
|
||||||
|
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
|
||||||
|
getSafelyEscapedTextForVCalendar(attendee.name || '')
|
||||||
|
}:mailto:${attendee.email}`
|
||||||
|
).join('\n') || ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
calendarEvent.reminders?.map((reminder) =>
|
||||||
|
`BEGIN:VALARM
|
||||||
|
ACTION:${reminder.type.toUpperCase()}
|
||||||
|
${
|
||||||
|
reminder.description
|
||||||
|
? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description.replaceAll('\r', ''))}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.startDate)}
|
||||||
|
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
||||||
|
${reminder.acknowledgedAt ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledgedAt)}` : ''}
|
||||||
|
END:VALARM`
|
||||||
|
).join('\n') || ''
|
||||||
|
}
|
||||||
|
END:VEVENT`;
|
||||||
|
|
||||||
|
return vEventText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIcs(
|
||||||
|
ics: string,
|
||||||
|
event: CalendarEvent,
|
||||||
|
): string {
|
||||||
|
const lines = ics.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
let replacedTitle = false;
|
||||||
|
let replacedStartDate = false;
|
||||||
|
let replacedEndDate = false;
|
||||||
|
let replacedStatus = false;
|
||||||
|
let replacedDescription = false;
|
||||||
|
let replacedEventUrl = false;
|
||||||
|
let replacedLocation = false;
|
||||||
|
let replacedTransparency = false;
|
||||||
|
let replacedLastModified = false;
|
||||||
|
let hasFoundFirstEventLine = false;
|
||||||
|
const lastModifiedDate = new Date();
|
||||||
|
|
||||||
|
const updatedIcsLines = lines.map((line) => {
|
||||||
|
// Skip everything until finding the first event
|
||||||
|
if (!hasFoundFirstEventLine) {
|
||||||
|
hasFoundFirstEventLine = line.startsWith('BEGIN:VEVENT');
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('SUMMARY:') && event.title && !replacedTitle) {
|
||||||
|
replacedTitle = true;
|
||||||
|
return `SUMMARY:${getSafelyEscapedTextForVCalendar(event.title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) && event.startDate && !replacedStartDate) {
|
||||||
|
replacedStartDate = true;
|
||||||
|
if (event.isAllDay) {
|
||||||
|
return `DTSTART;VALUE=DATE:${getVCalendarDate(event.startDate).substring(0, 8)}`;
|
||||||
|
}
|
||||||
|
return `DTSTART:${getVCalendarDate(event.startDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((line.startsWith('DTEND:') || line.startsWith('DTEND;')) && event.endDate && !replacedEndDate) {
|
||||||
|
replacedEndDate = true;
|
||||||
|
if (event.isAllDay) {
|
||||||
|
return `DTEND;VALUE=DATE:${getVCalendarDate(event.endDate).substring(0, 8)}`;
|
||||||
|
}
|
||||||
|
return `DTEND:${getVCalendarDate(event.endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('STATUS:') && event.status && !replacedStatus) {
|
||||||
|
replacedStatus = true;
|
||||||
|
return `STATUS:${getSafelyEscapedTextForVCalendar(event.status)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('DESCRIPTION:') && event.description && !replacedDescription) {
|
||||||
|
replacedDescription = true;
|
||||||
|
return `DESCRIPTION:${getSafelyEscapedTextForVCalendar(event.description.replaceAll('\r', ''))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('URL:') && event.eventUrl && !replacedEventUrl) {
|
||||||
|
replacedEventUrl = true;
|
||||||
|
return `URL:${getSafelyEscapedTextForVCalendar(event.eventUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('LOCATION:') && event.location && !replacedLocation) {
|
||||||
|
replacedLocation = true;
|
||||||
|
return `LOCATION:${getSafelyEscapedTextForVCalendar(event.location)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('TRANSP:') && event.transparency && !replacedTransparency) {
|
||||||
|
replacedTransparency = true;
|
||||||
|
return `TRANSP:${getSafelyEscapedTextForVCalendar(event.transparency.toUpperCase())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('LAST-MODIFIED:') && !replacedLastModified) {
|
||||||
|
replacedLastModified = true;
|
||||||
|
return `LAST-MODIFIED:${getVCalendarDate(lastModifiedDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find last line with END:VEVENT, extract it and what's after it
|
||||||
|
const endLineIndex = updatedIcsLines.findIndex((line) => line.startsWith('END:VEVENT'));
|
||||||
|
const endLines = updatedIcsLines.splice(endLineIndex, updatedIcsLines.length - endLineIndex);
|
||||||
|
|
||||||
|
if (!replacedDescription && event.description) {
|
||||||
|
updatedIcsLines.push(`DESCRIPTION:${getSafelyEscapedTextForVCalendar(event.description)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacedEventUrl && event.eventUrl) {
|
||||||
|
updatedIcsLines.push(`URL:${getSafelyEscapedTextForVCalendar(event.eventUrl)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacedLocation && event.location) {
|
||||||
|
updatedIcsLines.push(`LOCATION:${getSafelyEscapedTextForVCalendar(event.location)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the final lines back
|
||||||
|
updatedIcsLines.push(...endLines);
|
||||||
|
|
||||||
|
const updatedIcs = updatedIcsLines.map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return updatedIcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIcsDate(date: string): Date {
|
||||||
|
const [dateInfo, hourInfo] = date.split('T');
|
||||||
|
|
||||||
|
const year = dateInfo.substring(0, 4);
|
||||||
|
const month = dateInfo.substring(4, 6);
|
||||||
|
const day = dateInfo.substring(6, 8);
|
||||||
|
|
||||||
|
const hours = hourInfo.substring(0, 2);
|
||||||
|
const minutes = hourInfo.substring(2, 4);
|
||||||
|
const seconds = hourInfo.substring(4, 6);
|
||||||
|
|
||||||
|
return new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VCalendarVersion = '1.0' | '2.0';
|
||||||
|
|
||||||
|
export function parseVCalendar(text: string): CalendarEvent[] {
|
||||||
|
// Lines that start with a space should be moved to the line above them, as it's the same field/value to parse
|
||||||
|
const lines = text.split('\n').reduce((previousLines, currentLine) => {
|
||||||
|
if (currentLine.startsWith(' ')) {
|
||||||
|
previousLines[previousLines.length - 1] = `${previousLines[previousLines.length - 1]} ${
|
||||||
|
currentLine.substring(1).replaceAll('\r', '')
|
||||||
|
}`;
|
||||||
|
} else {
|
||||||
|
previousLines.push(currentLine.replaceAll('\r', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousLines;
|
||||||
|
}, [] as string[]).map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
||||||
|
|
||||||
|
let partialCalendarEvent: Partial<CalendarEvent> = {};
|
||||||
|
let partialCalendarReminder: Partial<CalendarEventReminder> = {};
|
||||||
|
let vCalendarVersion: VCalendarVersion = '2.0';
|
||||||
|
const partialRecurringMasterEvent: Pick<CalendarEvent, 'uid'> = {};
|
||||||
|
|
||||||
|
// Loop through every line
|
||||||
|
for (const line of lines) {
|
||||||
|
// Start new vCard version
|
||||||
|
if (line.startsWith('BEGIN:VCALENDAR')) {
|
||||||
|
vCalendarVersion = '2.0';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new event
|
||||||
|
if (line.startsWith('BEGIN:VEVENT')) {
|
||||||
|
partialCalendarEvent = {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish event
|
||||||
|
if (line.startsWith('END:VEVENT')) {
|
||||||
|
partialCalendarEvents.push(partialCalendarEvent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new reminder
|
||||||
|
if (line.startsWith('BEGIN:VALARM')) {
|
||||||
|
partialCalendarReminder = {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish reminder
|
||||||
|
if (line.startsWith('END:VALARM')) {
|
||||||
|
partialCalendarEvent.reminders = [
|
||||||
|
...(partialCalendarEvent?.reminders || []),
|
||||||
|
partialCalendarReminder as CalendarEventReminder,
|
||||||
|
];
|
||||||
|
|
||||||
|
partialCalendarReminder = {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select proper vCalendar version
|
||||||
|
if (line.startsWith('VERSION:')) {
|
||||||
|
if (line.startsWith('VERSION:1.0')) {
|
||||||
|
vCalendarVersion = '1.0';
|
||||||
|
} else if (line.startsWith('VERSION:2.0')) {
|
||||||
|
vCalendarVersion = '2.0';
|
||||||
|
} else {
|
||||||
|
// Default to 2.0, log warning
|
||||||
|
vCalendarVersion = '2.0';
|
||||||
|
console.warn(`Invalid vCalendar version found: "${line}". Defaulting to 2.0 parser.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vCalendarVersion !== '1.0' && vCalendarVersion !== '2.0') {
|
||||||
|
console.warn(`Invalid vCalendar version found: "${vCalendarVersion}". Defaulting to 2.0 parser.`);
|
||||||
|
vCalendarVersion = '2.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('UID:')) {
|
||||||
|
const uid = line.replace('UID:', '').trim();
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||||
|
partialCalendarReminder.uid = uid;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.uid = uid;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('RECURRENCE-ID:')) {
|
||||||
|
const recurrenceId = line.replace('RECURRENCE-ID:', '').trim();
|
||||||
|
|
||||||
|
if (!recurrenceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't found the master event yet, use the current event as the master (the UID from the ICS will be the master's, and the same for all instances)
|
||||||
|
if (Object.keys(partialRecurringMasterEvent).length === 0) {
|
||||||
|
partialRecurringMasterEvent.uid = partialCalendarEvent.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.recurrenceMasterUid = partialCalendarEvent.uid || partialRecurringMasterEvent.uid;
|
||||||
|
partialCalendarEvent.uid = `${partialCalendarEvent.recurrenceMasterUid}:${recurrenceId}`;
|
||||||
|
partialCalendarEvent.isRecurring = true;
|
||||||
|
partialCalendarEvent.recurrenceId = recurrenceId;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('DESCRIPTION:')) {
|
||||||
|
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||||
|
partialCalendarReminder.description = description;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.description = description;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('SUMMARY:')) {
|
||||||
|
const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim());
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.title = title;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('URL:')) {
|
||||||
|
const eventUrl = getSafelyUnescapedTextFromVCalendar((line.split('URL:')[1] || '').trim());
|
||||||
|
|
||||||
|
if (!eventUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.eventUrl = eventUrl;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('LOCATION:')) {
|
||||||
|
const location = getSafelyUnescapedTextFromVCalendar((line.split('LOCATION:')[1] || '').trim());
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.location = location;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) {
|
||||||
|
const startDateInfo = line.split(':')[1] || '';
|
||||||
|
const startDate = parseIcsDate(startDateInfo);
|
||||||
|
|
||||||
|
partialCalendarEvent.startDate = startDate;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) {
|
||||||
|
const endDateInfo = line.split(':')[1] || '';
|
||||||
|
const endDate = parseIcsDate(endDateInfo);
|
||||||
|
|
||||||
|
partialCalendarEvent.endDate = endDate;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('ORGANIZER;')) {
|
||||||
|
const organizerInfo = line.split(':');
|
||||||
|
const organizerEmail = organizerInfo.slice(-1)[0] || '';
|
||||||
|
|
||||||
|
if (!organizerEmail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.organizerEmail = organizerEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('TRANSP:')) {
|
||||||
|
const transparency = (line.split('TRANSP:')[1] || 'opaque')
|
||||||
|
.toLowerCase() as CalendarEvent['transparency'];
|
||||||
|
|
||||||
|
partialCalendarEvent.transparency = transparency;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('ATTENDEE;')) {
|
||||||
|
const attendeeInfo = line.split(':');
|
||||||
|
const attendeeEmail = attendeeInfo.slice(-1)[0] || '';
|
||||||
|
const attendeeStatusInfo = line.split('PARTSTAT=')[1] || '';
|
||||||
|
const attendeeStatus = getAttendeeStatusFromVCalendar(
|
||||||
|
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
||||||
|
);
|
||||||
|
const attendeeNameInfo = line.split('CN=')[1] || '';
|
||||||
|
const attendeeName = getSafelyUnescapedTextFromVCalendar((attendeeNameInfo.split(';')[0] || '').trim());
|
||||||
|
|
||||||
|
if (!attendeeEmail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendee: CalendarEventAttendee = {
|
||||||
|
email: attendeeEmail,
|
||||||
|
status: attendeeStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attendeeName) {
|
||||||
|
attendee.name = attendeeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.attendees = [...(partialCalendarEvent?.attendees || []), attendee];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('ACTION:')) {
|
||||||
|
const reminderType =
|
||||||
|
(line.replace('ACTION:', '').trim().toLowerCase() || 'display') as CalendarEventReminder['type'];
|
||||||
|
|
||||||
|
partialCalendarReminder.type = reminderType;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('TRIGGER:') || line.startsWith('TRIGGER;')) {
|
||||||
|
const triggerInfo = line.split(':')[1] || '';
|
||||||
|
let triggerDate = new Date(partialCalendarEvent.startDate || new Date());
|
||||||
|
|
||||||
|
if (line.includes('DATE-TIME')) {
|
||||||
|
triggerDate = parseIcsDate(triggerInfo);
|
||||||
|
} else {
|
||||||
|
const triggerHoursMatch = triggerInfo.match(/(\d+(?:H))/);
|
||||||
|
const triggerMinutesMatch = triggerInfo.match(/(\d+(?:M))/);
|
||||||
|
const triggerSecondsMatch = triggerInfo.match(/(\d+(?:S))/);
|
||||||
|
|
||||||
|
const isNegative = triggerInfo.startsWith('-');
|
||||||
|
|
||||||
|
if (triggerHoursMatch && triggerHoursMatch.length > 0) {
|
||||||
|
const triggerHours = parseInt(triggerHoursMatch[0], 10);
|
||||||
|
|
||||||
|
if (isNegative) {
|
||||||
|
triggerDate.setUTCHours(triggerDate.getUTCHours() - triggerHours);
|
||||||
|
} else {
|
||||||
|
triggerDate.setUTCHours(triggerHours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerMinutesMatch && triggerMinutesMatch.length > 0) {
|
||||||
|
const triggerMinutes = parseInt(triggerMinutesMatch[0], 10);
|
||||||
|
|
||||||
|
if (isNegative) {
|
||||||
|
triggerDate.setUTCMinutes(triggerDate.getUTCMinutes() - triggerMinutes);
|
||||||
|
} else {
|
||||||
|
triggerDate.setUTCMinutes(triggerMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerSecondsMatch && triggerSecondsMatch.length > 0) {
|
||||||
|
const triggerSeconds = parseInt(triggerSecondsMatch[0], 10);
|
||||||
|
|
||||||
|
if (isNegative) {
|
||||||
|
triggerDate.setUTCSeconds(triggerDate.getUTCSeconds() - triggerSeconds);
|
||||||
|
} else {
|
||||||
|
triggerDate.setUTCSeconds(triggerSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarReminder.startDate = triggerDate.toISOString();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('RRULE:')) {
|
||||||
|
const rRule = line.replace('RRULE:', '').trim();
|
||||||
|
|
||||||
|
if (!rRule) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.isRecurring = true;
|
||||||
|
partialCalendarEvent.recurringRrule = rRule;
|
||||||
|
partialCalendarEvent.sequence = partialCalendarEvent.sequence || 0;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('SEQUENCE:')) {
|
||||||
|
const sequence = line.replace('SEQUENCE:', '').trim();
|
||||||
|
|
||||||
|
if (!sequence || sequence === '0') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.sequence = parseInt(sequence, 10);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return partialCalendarEvents as CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Considers weeks starting Monday, not Sunday
|
||||||
|
export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] {
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = date.getUTCMonth();
|
||||||
|
|
||||||
|
const firstOfMonth = new Date(year, month, 1);
|
||||||
|
const lastOfMonth = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
const daysToShow = firstOfMonth.getUTCDay() + (firstOfMonth.getUTCDay() === 0 ? 6 : -1) + lastOfMonth.getUTCDate();
|
||||||
|
|
||||||
|
const weekCount = Math.ceil(daysToShow / 7);
|
||||||
|
|
||||||
|
const weeks: { date: Date; isSameMonth: boolean }[][] = [];
|
||||||
|
|
||||||
|
const startingDate = new Date(firstOfMonth);
|
||||||
|
startingDate.setUTCDate(
|
||||||
|
startingDate.getUTCDate() - Math.abs(firstOfMonth.getUTCDay() === 0 ? 6 : (firstOfMonth.getUTCDay() - 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let weekIndex = 0; weeks.length < weekCount; ++weekIndex) {
|
||||||
|
for (let dayIndex = 0; dayIndex < 7; ++dayIndex) {
|
||||||
|
if (!Array.isArray(weeks[weekIndex])) {
|
||||||
|
weeks[weekIndex] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDayDate = new Date(startingDate);
|
||||||
|
weekDayDate.setUTCDate(weekDayDate.getUTCDate() + (dayIndex + weekIndex * 7));
|
||||||
|
|
||||||
|
const isSameMonth = weekDayDate.getUTCMonth() === month;
|
||||||
|
|
||||||
|
weeks[weekIndex].push({ date: weekDayDate, isSameMonth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Considers week starting Monday, not Sunday
|
||||||
|
export function getDaysForWeek(
|
||||||
|
date: Date,
|
||||||
|
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
|
||||||
|
const shortIsoDate = new Date().toISOString().substring(0, 10);
|
||||||
|
const currentHour = new Date().getUTCHours();
|
||||||
|
|
||||||
|
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
||||||
|
|
||||||
|
const startingDate = new Date(date);
|
||||||
|
startingDate.setUTCDate(
|
||||||
|
startingDate.getUTCDate() - Math.abs(startingDate.getUTCDay() === 0 ? 6 : (startingDate.getUTCDay() - 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let dayIndex = 0; days.length < 7; ++dayIndex) {
|
||||||
|
const dayDate = new Date(startingDate);
|
||||||
|
dayDate.setUTCDate(dayDate.getUTCDate() + dayIndex);
|
||||||
|
|
||||||
|
const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate;
|
||||||
|
|
||||||
|
days[dayIndex] = {
|
||||||
|
date: dayDate,
|
||||||
|
isSameDay,
|
||||||
|
hours: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let hourIndex = 0; hourIndex < 24; ++hourIndex) {
|
||||||
|
const dayHourDate = new Date(dayDate);
|
||||||
|
dayHourDate.setUTCHours(hourIndex);
|
||||||
|
|
||||||
|
const isCurrentHour = isSameDay && hourIndex === currentHour;
|
||||||
|
|
||||||
|
days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarEventStyle(
|
||||||
|
calendarEvent: CalendarEvent,
|
||||||
|
calendars: Calendar[],
|
||||||
|
): { backgroundColor?: string; border?: string } {
|
||||||
|
const matchingCalendar = calendars.find((calendar) => calendar.uid === calendarEvent.calendarId);
|
||||||
|
const hexColor = matchingCalendar?.calendarColor || getColorAsHex('bg-gray-700');
|
||||||
|
|
||||||
|
return calendarEvent.transparency === 'opaque'
|
||||||
|
? {
|
||||||
|
backgroundColor: hexColor,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
border: `1px solid ${hexColor}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RRuleFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||||
|
type RRuleWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
|
||||||
|
type RRuleType = 'FREQ' | 'BYDAY' | 'BYMONTHDAY' | 'BYHOUR' | 'BYMINUTE' | 'COUNT' | 'INTERVAL' | 'UNTIL';
|
||||||
|
|
||||||
|
const rRuleToFrequencyOrWeekDay = new Map<RRuleFrequency | RRuleWeekDay, string>([
|
||||||
|
['DAILY', 'day'],
|
||||||
|
['WEEKLY', 'week'],
|
||||||
|
['MONTHLY', 'month'],
|
||||||
|
['MO', 'Monday'],
|
||||||
|
['TU', 'Tuesday'],
|
||||||
|
['WE', 'Wednesday'],
|
||||||
|
['TH', 'Thursday'],
|
||||||
|
['FR', 'Friday'],
|
||||||
|
['SA', 'Saturday'],
|
||||||
|
['SU', 'Sunday'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function convertRRuleDaysToWords(day: string | RRuleFrequency | RRuleWeekDay): string {
|
||||||
|
if (day.includes(',')) {
|
||||||
|
const days = day.split(',') as (typeof day)[];
|
||||||
|
return days.map((individualDay) => rRuleToFrequencyOrWeekDay.get(individualDay as RRuleFrequency | RRuleWeekDay))
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rRuleToFrequencyOrWeekDay.get(day as RRuleFrequency | RRuleWeekDay)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrdinalSuffix(number: number) {
|
||||||
|
const text = ['th', 'st', 'nd', 'rd'] as const;
|
||||||
|
const value = number % 100;
|
||||||
|
return `${number}${(text[(value - 20) % 10] || text[value] || text[0])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertRRuleToWords(
|
||||||
|
rRule: string,
|
||||||
|
{ capitalizeSentence = true }: { capitalizeSentence?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const rulePart = rRule.replace('RRULE:', '');
|
||||||
|
|
||||||
|
const rulePieces = rulePart.split(';');
|
||||||
|
|
||||||
|
const parsedRRule: Partial<Record<RRuleType, string>> = {};
|
||||||
|
|
||||||
|
rulePieces.forEach(function (rulePiece) {
|
||||||
|
const keyAndValue = rulePiece.split('=') as [RRuleType, string];
|
||||||
|
const [key, value] = keyAndValue;
|
||||||
|
|
||||||
|
parsedRRule[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const frequency = parsedRRule.FREQ;
|
||||||
|
const byDay = parsedRRule.BYDAY;
|
||||||
|
const byMonthDay = parsedRRule.BYMONTHDAY;
|
||||||
|
const byHour = parsedRRule.BYHOUR;
|
||||||
|
const byMinute = parsedRRule.BYMINUTE;
|
||||||
|
const count = parsedRRule.COUNT;
|
||||||
|
const interval = parsedRRule.INTERVAL;
|
||||||
|
const until = parsedRRule.UNTIL;
|
||||||
|
|
||||||
|
const words: string[] = [];
|
||||||
|
|
||||||
|
if (frequency === 'DAILY') {
|
||||||
|
if (byHour) {
|
||||||
|
if (byMinute) {
|
||||||
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day at ${byHour}:${byMinute}`);
|
||||||
|
} else {
|
||||||
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day at ${byHour}:00`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count) {
|
||||||
|
if (count === '1') {
|
||||||
|
words.push(`for 1 time`);
|
||||||
|
} else {
|
||||||
|
words.push(`for ${count} times`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until) {
|
||||||
|
const untilDate = parseIcsDate(until);
|
||||||
|
|
||||||
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frequency === 'WEEKLY') {
|
||||||
|
if (byDay) {
|
||||||
|
if (interval && parseInt(interval, 10) > 1) {
|
||||||
|
words.push(
|
||||||
|
`${capitalizeSentence ? 'Every' : 'every'} ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on ${
|
||||||
|
convertRRuleDaysToWords(byDay)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
words.push(
|
||||||
|
`${capitalizeSentence ? 'Every' : 'every'} ${rRuleToFrequencyOrWeekDay.get(frequency)} on ${
|
||||||
|
convertRRuleDaysToWords(byDay)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byMonthDay) {
|
||||||
|
words.push(`the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count) {
|
||||||
|
if (count === '1') {
|
||||||
|
words.push(`for 1 time`);
|
||||||
|
} else {
|
||||||
|
words.push(`for ${count} times`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until) {
|
||||||
|
const untilDate = parseIcsDate(until);
|
||||||
|
|
||||||
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// monthly
|
||||||
|
if (frequency === 'MONTHLY' && byMonthDay) {
|
||||||
|
if (interval && parseInt(interval, 10) > 1) {
|
||||||
|
words.push(
|
||||||
|
`${capitalizeSentence ? 'Every' : 'every'} ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on the ${
|
||||||
|
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
words.push(
|
||||||
|
`${capitalizeSentence ? 'Every' : 'every'} ${rRuleToFrequencyOrWeekDay.get(frequency)} on the ${
|
||||||
|
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count) {
|
||||||
|
if (count === '1') {
|
||||||
|
words.push(` for 1 time`);
|
||||||
|
} else {
|
||||||
|
words.push(` for ${count} times`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until) {
|
||||||
|
const untilDate = parseIcsDate(until);
|
||||||
|
|
||||||
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedTimeZone = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
utcOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function offsetStringToNumeric(offsetString: string): number {
|
||||||
|
const sign = offsetString.startsWith('GMT-') ? -1 : 1;
|
||||||
|
const [hours, minutes] = offsetString.slice(4).split(':').map(Number);
|
||||||
|
return sign * (hours * 60 + minutes) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimeZones(): ParsedTimeZone[] {
|
||||||
|
const supportedTimeZones = Intl.supportedValuesOf('timeZone');
|
||||||
|
|
||||||
|
const timezones: ParsedTimeZone[] = [{
|
||||||
|
id: 'UTC',
|
||||||
|
label: 'UTC',
|
||||||
|
utcOffset: 0,
|
||||||
|
}];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const tz of supportedTimeZones) {
|
||||||
|
// Some browsers return UTC as a timezone, so we can skip it to avoid duplicates
|
||||||
|
if (timezones.find((timezone) => timezone.id === tz)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedOffset = offsetFormat.format(now);
|
||||||
|
const [, offsetString] = formattedOffset.split(', ');
|
||||||
|
|
||||||
|
const offsetNumeric = offsetStringToNumeric(offsetString);
|
||||||
|
|
||||||
|
timezones.push({
|
||||||
|
id: tz,
|
||||||
|
label: `${tz} (${offsetString})`,
|
||||||
|
utcOffset: offsetNumeric,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return timezones;
|
||||||
|
}
|
||||||
1915
lib/utils/calendar_test.ts
Normal file
1915
lib/utils/calendar_test.ts
Normal file
File diff suppressed because it is too large
Load Diff
74
routes/api/calendar/add-event.tsx
Normal file
74
routes/api/calendar/add-event.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import { generateVCalendar, getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarIds: string[];
|
||||||
|
calendarView: 'day' | 'week' | 'month';
|
||||||
|
calendarStartDate: string;
|
||||||
|
calendarId: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendarEvents: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.title || !requestBody.startDate ||
|
||||||
|
!requestBody.endDate || !requestBody.calendarView || !requestBody.calendarStartDate
|
||||||
|
) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = context.state.user.id;
|
||||||
|
|
||||||
|
const eventId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const newEvent: CalendarEvent = {
|
||||||
|
calendarId: requestBody.calendarId,
|
||||||
|
title: requestBody.title,
|
||||||
|
startDate: new Date(requestBody.startDate),
|
||||||
|
endDate: new Date(requestBody.endDate),
|
||||||
|
isAllDay: Boolean(requestBody.isAllDay),
|
||||||
|
organizerEmail: context.state.user.email,
|
||||||
|
transparency: 'opaque',
|
||||||
|
url: `${calendar.url}/${eventId}.ics`,
|
||||||
|
uid: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vCalendar = generateVCalendar([newEvent]);
|
||||||
|
|
||||||
|
await CalendarEventModel.create(userId, requestBody.calendarId, eventId, vCalendar);
|
||||||
|
|
||||||
|
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||||
|
|
||||||
|
const newCalendarEvents = await CalendarEventModel.list(userId, requestBody.calendarIds, dateRange);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
35
routes/api/calendar/add.tsx
Normal file
35
routes/api/calendar/add.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendars: Calendar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.name) {
|
||||||
|
await CalendarModel.create(context.state.user.id, requestBody.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
64
routes/api/calendar/delete-event.tsx
Normal file
64
routes/api/calendar/delete-event.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarIds: string[];
|
||||||
|
calendarView: 'day' | 'week' | 'month';
|
||||||
|
calendarStartDate: string;
|
||||||
|
calendarId: string;
|
||||||
|
calendarEventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendarEvents: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.calendarEventId ||
|
||||||
|
!requestBody.calendarEventId ||
|
||||||
|
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||||
|
) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvent = await CalendarEventModel.get(
|
||||||
|
context.state.user.id,
|
||||||
|
calendar.uid!,
|
||||||
|
requestBody.calendarEventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendarId) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await CalendarEventModel.delete(context.state.user.id, calendarEvent.url);
|
||||||
|
|
||||||
|
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||||
|
|
||||||
|
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
41
routes/api/calendar/delete.tsx
Normal file
41
routes/api/calendar/delete.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendars: Calendar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.calendarId) {
|
||||||
|
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await CalendarModel.delete(context.state.user.id, requestBody.calendarId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
38
routes/api/calendar/export-events.tsx
Normal file
38
routes/api/calendar/export-events.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (!requestBody.calendarIds) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvents = await CalendarEventModel.list(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.calendarIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, calendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
67
routes/api/calendar/import.tsx
Normal file
67
routes/api/calendar/import.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
|
import { getDateRangeForCalendarView, getIdFromVEvent, splitTextIntoVEvents } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarIds: string[];
|
||||||
|
calendarView: 'day' | 'week' | 'month';
|
||||||
|
calendarStartDate: string;
|
||||||
|
icsToImport: string;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendarEvents: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.icsToImport ||
|
||||||
|
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||||
|
) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vEvents = splitTextIntoVEvents(requestBody.icsToImport);
|
||||||
|
|
||||||
|
if (vEvents.length === 0) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await concurrentPromises(
|
||||||
|
vEvents.map((vEvent) => async () => {
|
||||||
|
const eventId = getIdFromVEvent(vEvent);
|
||||||
|
|
||||||
|
await CalendarEventModel.create(context.state.user!.id, calendar.uid!, eventId, vEvent);
|
||||||
|
}),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||||
|
|
||||||
|
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
53
routes/api/calendar/search-events.tsx
Normal file
53
routes/api/calendar/search-events.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
calendarIds: string[];
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
matchingCalendarEvents: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.calendarIds || !requestBody.searchTerm
|
||||||
|
) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCalendarEvents = await CalendarEventModel.list(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.calendarIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lowerSearchTerm = requestBody.searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
const matchingCalendarEvents = allCalendarEvents.filter((calendarEvent) =>
|
||||||
|
calendarEvent.title.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
|
calendarEvent.description?.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
|
calendarEvent.location?.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
|
calendarEvent.eventUrl?.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
|
calendarEvent.organizerEmail?.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
|
calendarEvent.attendees?.some((attendee) => attendee.email.toLowerCase().includes(lowerSearchTerm)) ||
|
||||||
|
calendarEvent.reminders?.some((reminder) => reminder.description?.toLowerCase().includes(lowerSearchTerm))
|
||||||
|
).sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, matchingCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
65
routes/api/calendar/update.tsx
Normal file
65
routes/api/calendar/update.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import { UserModel } from '/lib/models/user.ts';
|
||||||
|
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newCalendars: Calendar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.id) {
|
||||||
|
const calendar = await CalendarModel.get(context.state.user.id, requestBody.id);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.displayName = requestBody.name;
|
||||||
|
calendar.calendarColor = requestBody.color?.startsWith('#')
|
||||||
|
? requestBody.color
|
||||||
|
: getColorAsHex(requestBody.color || 'bg-gray-700');
|
||||||
|
|
||||||
|
await CalendarModel.update(context.state.user.id, calendar.url, calendar.displayName, calendar.calendarColor);
|
||||||
|
|
||||||
|
if (requestBody.isVisible !== calendar.isVisible) {
|
||||||
|
const user = await UserModel.getById(context.state.user.id);
|
||||||
|
|
||||||
|
if (requestBody.isVisible) {
|
||||||
|
user.extra.hidden_calendar_ids = user.extra.hidden_calendar_ids?.filter((id) => id !== calendar.uid!);
|
||||||
|
} else if (Array.isArray(user.extra.hidden_calendar_ids)) {
|
||||||
|
user.extra.hidden_calendar_ids.push(calendar.uid!);
|
||||||
|
} else {
|
||||||
|
user.extra.hidden_calendar_ids = [calendar.uid!];
|
||||||
|
}
|
||||||
|
|
||||||
|
await UserModel.update(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
82
routes/calendar.tsx
Normal file
82
routes/calendar.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx';
|
||||||
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userCalendars: Calendar[];
|
||||||
|
userCalendarEvents: CalendarEvent[];
|
||||||
|
baseUrl: string;
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
timezoneId: string;
|
||||||
|
timezoneUtcOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (await AppConfig.getConfig()).auth.baseUrl;
|
||||||
|
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||||
|
|
||||||
|
if (!calendarConfig.enableCalDavServer) {
|
||||||
|
throw new Error('CalDAV server is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = context.state.user.id;
|
||||||
|
const timezoneId = context.state.user.extra.timezone?.id || 'UTC';
|
||||||
|
const timezoneUtcOffset = context.state.user.extra.timezone?.utcOffset || 0;
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
const view = (searchParams.get('view') as Data['view']) || 'week';
|
||||||
|
const startDate = searchParams.get('startDate') || new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
let userCalendars = await CalendarModel.list(userId);
|
||||||
|
|
||||||
|
// Create default calendar if none exists
|
||||||
|
if (userCalendars.length === 0) {
|
||||||
|
await CalendarModel.create(userId, 'Calendar');
|
||||||
|
|
||||||
|
userCalendars = await CalendarModel.list(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCalendarIds = userCalendars.filter((calendar) => calendar.isVisible).map((calendar) => calendar.uid!);
|
||||||
|
|
||||||
|
const dateRange = getDateRangeForCalendarView(startDate, view);
|
||||||
|
|
||||||
|
const userCalendarEvents = await CalendarEventModel.list(userId, visibleCalendarIds, dateRange);
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
userCalendars,
|
||||||
|
userCalendarEvents,
|
||||||
|
baseUrl,
|
||||||
|
view,
|
||||||
|
startDate,
|
||||||
|
timezoneId,
|
||||||
|
timezoneUtcOffset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<CalendarWrapper
|
||||||
|
initialCalendars={data?.userCalendars || []}
|
||||||
|
initialCalendarEvents={data?.userCalendarEvents || []}
|
||||||
|
baseUrl={data.baseUrl}
|
||||||
|
view={data.view}
|
||||||
|
startDate={data.startDate}
|
||||||
|
timezoneId={data.timezoneId}
|
||||||
|
timezoneUtcOffset={data.timezoneUtcOffset}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
routes/calendar/[calendarEventId].tsx
Normal file
160
routes/calendar/[calendarEventId].tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import { convertFormDataToObject } from '/lib/utils/misc.ts';
|
||||||
|
import { updateIcs } from '/lib/utils/calendar.ts';
|
||||||
|
import { getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx';
|
||||||
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
calendarEvent: CalendarEvent;
|
||||||
|
calendars: Calendar[];
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
formData: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||||
|
|
||||||
|
if (!calendarConfig.enableCalDavServer) {
|
||||||
|
throw new Error('CalDAV server is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
let { calendarEventId } = context.params;
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
const calendarId = searchParams.get('calendarId') || undefined;
|
||||||
|
|
||||||
|
if (!calendarId) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// When editing a recurring event, we only allow the master
|
||||||
|
if (calendarEventId.includes(':')) {
|
||||||
|
calendarEventId = calendarEventId.split(':')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId);
|
||||||
|
|
||||||
|
if (!calendarEvent) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
return await context.render({ calendarEvent, calendars, formData: {} });
|
||||||
|
},
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||||
|
|
||||||
|
if (!calendarConfig.enableCalDavServer) {
|
||||||
|
throw new Error('CalDAV server is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { calendarEventId } = context.params;
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
const calendarId = searchParams.get('calendarId') || undefined;
|
||||||
|
|
||||||
|
if (!calendarId) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId);
|
||||||
|
|
||||||
|
if (!calendarEvent) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const updateType = getFormDataField(formData, 'update-type') as 'raw' | 'ui';
|
||||||
|
|
||||||
|
calendarEvent.title = getFormDataField(formData, 'title');
|
||||||
|
calendarEvent.startDate = new Date(`${getFormDataField(formData, 'startDate')}:00.000Z`);
|
||||||
|
calendarEvent.endDate = new Date(`${getFormDataField(formData, 'endDate')}:00.000Z`);
|
||||||
|
calendarEvent.isAllDay = getFormDataField(formData, 'isAllDay') === 'true';
|
||||||
|
calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status'];
|
||||||
|
|
||||||
|
calendarEvent.description = getFormDataField(formData, 'description') || undefined;
|
||||||
|
calendarEvent.eventUrl = getFormDataField(formData, 'eventUrl') || undefined;
|
||||||
|
calendarEvent.location = getFormDataField(formData, 'location') || undefined;
|
||||||
|
calendarEvent.transparency = getFormDataField(formData, 'transparency') as CalendarEvent['transparency'] ||
|
||||||
|
'opaque';
|
||||||
|
const rawIcs = getFormDataField(formData, 'ics');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!calendarEvent.title) {
|
||||||
|
throw new Error(`Title is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
formFields(calendarEvent, calendars, updateType).forEach((field) => {
|
||||||
|
if (field.required) {
|
||||||
|
const value = formData.get(field.name);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${field.label} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let updatedIcs = '';
|
||||||
|
|
||||||
|
if (updateType === 'raw') {
|
||||||
|
updatedIcs = rawIcs;
|
||||||
|
} else if (updateType === 'ui') {
|
||||||
|
if (!calendarEvent.title || !calendarEvent.startDate || !calendarEvent.endDate) {
|
||||||
|
throw new Error(`Title, start date, and end date are required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedIcs = updateIcs(calendarEvent.data || '', calendarEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CalendarEventModel.update(context.state.user.id, calendarEvent.url!, updatedIcs);
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
calendarEvent,
|
||||||
|
calendars,
|
||||||
|
notice: 'Event updated successfully!',
|
||||||
|
formData: convertFormDataToObject(formData),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
calendarEvent,
|
||||||
|
calendars,
|
||||||
|
error: (error as Error).toString(),
|
||||||
|
formData: convertFormDataToObject(formData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarEventPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ViewCalendarEvent
|
||||||
|
initialCalendarEvent={data.calendarEvent}
|
||||||
|
calendars={data.calendars}
|
||||||
|
formData={data.formData}
|
||||||
|
error={data.error}
|
||||||
|
notice={data.notice}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
routes/calendars.tsx
Normal file
36
routes/calendars.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||||
|
import Calendars from '/islands/calendar/Calendars.tsx';
|
||||||
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userCalendars: Calendar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||||
|
|
||||||
|
if (!calendarConfig.enableCalDavServer) {
|
||||||
|
throw new Error('CalDAV server is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCalendars = await CalendarModel.list(context.state.user.id);
|
||||||
|
|
||||||
|
return await context.render({ userCalendars });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Calendars initialCalendars={data.userCalendars || []} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ interface Data {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
isFileSharingAllowed: boolean;
|
isFileSharingAllowed: boolean;
|
||||||
isCalDavEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handler: Handlers<Data, FreshContextState> = {
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
@@ -41,9 +40,6 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
const userFiles = await FileModel.list(context.state.user.id, currentPath);
|
const userFiles = await FileModel.list(context.state.user.id, currentPath);
|
||||||
|
|
||||||
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
|
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
|
||||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
|
||||||
|
|
||||||
const isCalDavEnabled = calendarConfig.enableCalDavServer;
|
|
||||||
|
|
||||||
return await context.render({
|
return await context.render({
|
||||||
userDirectories,
|
userDirectories,
|
||||||
@@ -51,7 +47,6 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
currentPath,
|
currentPath,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
isFileSharingAllowed: isPublicFileSharingAllowed,
|
isFileSharingAllowed: isPublicFileSharingAllowed,
|
||||||
isCalDavEnabled,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -65,7 +60,6 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
|||||||
initialPath={data.currentPath}
|
initialPath={data.currentPath}
|
||||||
baseUrl={data.baseUrl}
|
baseUrl={data.baseUrl}
|
||||||
isFileSharingAllowed={data.isFileSharingAllowed}
|
isFileSharingAllowed={data.isFileSharingAllowed}
|
||||||
isCalDavEnabled={data.isCalDavEnabled}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getFormDataField } from '/lib/form-utils.tsx';
|
|||||||
import { EmailModel } from '/lib/models/email.ts';
|
import { EmailModel } from '/lib/models/email.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
||||||
|
import { getTimeZones } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
error?: {
|
error?: {
|
||||||
@@ -20,8 +21,10 @@ interface Data {
|
|||||||
};
|
};
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
currency?: SupportedCurrencySymbol;
|
currency?: SupportedCurrencySymbol;
|
||||||
|
timezoneId?: string;
|
||||||
isExpensesAppEnabled: boolean;
|
isExpensesAppEnabled: boolean;
|
||||||
isMultiFactorAuthEnabled: boolean;
|
isMultiFactorAuthEnabled: boolean;
|
||||||
|
isCalendarAppEnabled: boolean;
|
||||||
helpEmail: string;
|
helpEmail: string;
|
||||||
user: {
|
user: {
|
||||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||||
@@ -37,13 +40,16 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
||||||
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
||||||
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
||||||
|
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
|
||||||
|
|
||||||
return await context.render({
|
return await context.render({
|
||||||
formData: {},
|
formData: {},
|
||||||
currency: context.state.user.extra.expenses_currency,
|
currency: context.state.user.extra.expenses_currency,
|
||||||
|
timezoneId: context.state.user.extra.timezone?.id || 'UTC',
|
||||||
isExpensesAppEnabled,
|
isExpensesAppEnabled,
|
||||||
helpEmail,
|
helpEmail,
|
||||||
isMultiFactorAuthEnabled,
|
isMultiFactorAuthEnabled,
|
||||||
|
isCalendarAppEnabled,
|
||||||
user: context.state.user,
|
user: context.state.user,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -55,6 +61,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
||||||
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
||||||
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
||||||
|
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
|
||||||
|
|
||||||
let action: Action = 'change-email';
|
let action: Action = 'change-email';
|
||||||
let errorTitle = '';
|
let errorTitle = '';
|
||||||
@@ -183,6 +190,24 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
|
|
||||||
successTitle = 'Currency changed!';
|
successTitle = 'Currency changed!';
|
||||||
successMessage = 'Currency changed successfully.';
|
successMessage = 'Currency changed successfully.';
|
||||||
|
} else if (action === 'change-timezone') {
|
||||||
|
const timezones = getTimeZones();
|
||||||
|
const newTimezoneId = getFormDataField(formData, 'timezone');
|
||||||
|
const matchingTimezone = timezones.find((timezone) => timezone.id === newTimezoneId);
|
||||||
|
|
||||||
|
if (!matchingTimezone) {
|
||||||
|
throw new Error(`Invalid timezone.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.extra.timezone = {
|
||||||
|
id: newTimezoneId,
|
||||||
|
utcOffset: matchingTimezone.utcOffset,
|
||||||
|
};
|
||||||
|
|
||||||
|
await UserModel.update(user);
|
||||||
|
|
||||||
|
successTitle = 'Timezone changed!';
|
||||||
|
successMessage = 'Timezone changed successfully.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const notice = successTitle
|
const notice = successTitle
|
||||||
@@ -196,9 +221,11 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
notice,
|
notice,
|
||||||
formData: convertFormDataToObject(formData),
|
formData: convertFormDataToObject(formData),
|
||||||
currency: user.extra.expenses_currency,
|
currency: user.extra.expenses_currency,
|
||||||
|
timezoneId: user.extra.timezone?.id || 'UTC',
|
||||||
isExpensesAppEnabled,
|
isExpensesAppEnabled,
|
||||||
helpEmail,
|
helpEmail,
|
||||||
isMultiFactorAuthEnabled,
|
isMultiFactorAuthEnabled,
|
||||||
|
isCalendarAppEnabled,
|
||||||
user: user,
|
user: user,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,9 +237,11 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
error: { title: errorTitle, message: errorMessage },
|
error: { title: errorTitle, message: errorMessage },
|
||||||
formData: convertFormDataToObject(formData),
|
formData: convertFormDataToObject(formData),
|
||||||
currency: user.extra.expenses_currency,
|
currency: user.extra.expenses_currency,
|
||||||
|
timezoneId: user.extra.timezone?.id || 'UTC',
|
||||||
isExpensesAppEnabled,
|
isExpensesAppEnabled,
|
||||||
helpEmail,
|
helpEmail,
|
||||||
isMultiFactorAuthEnabled,
|
isMultiFactorAuthEnabled,
|
||||||
|
isCalendarAppEnabled,
|
||||||
user: user,
|
user: user,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -227,8 +256,10 @@ export default function SettingsPage({ data }: PageProps<Data, FreshContextState
|
|||||||
error={data?.error}
|
error={data?.error}
|
||||||
notice={data?.notice}
|
notice={data?.notice}
|
||||||
currency={data?.currency}
|
currency={data?.currency}
|
||||||
|
timezoneId={data?.timezoneId}
|
||||||
isExpensesAppEnabled={data?.isExpensesAppEnabled}
|
isExpensesAppEnabled={data?.isExpensesAppEnabled}
|
||||||
isMultiFactorAuthEnabled={data?.isMultiFactorAuthEnabled}
|
isMultiFactorAuthEnabled={data?.isMultiFactorAuthEnabled}
|
||||||
|
isCalendarAppEnabled={data?.isCalendarAppEnabled}
|
||||||
helpEmail={data?.helpEmail}
|
helpEmail={data?.helpEmail}
|
||||||
user={data?.user}
|
user={data?.user}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,3 +90,9 @@ img.blue {
|
|||||||
details summary::-webkit-details-marker {
|
details summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-week-view-days {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||||
|
grid-template-rows:
|
||||||
|
0.5fr 1fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user