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:
@@ -62,6 +62,12 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||
label: 'Contacts',
|
||||
}
|
||||
: null,
|
||||
enabledApps.includes('calendar')
|
||||
? {
|
||||
url: '/calendar',
|
||||
label: 'Calendar',
|
||||
}
|
||||
: null,
|
||||
];
|
||||
|
||||
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
||||
@@ -87,6 +93,10 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||
pageLabel = 'Contacts';
|
||||
}
|
||||
|
||||
if (route.startsWith('/calendar')) {
|
||||
pageLabel = 'Calendar';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isCalDavEnabled?: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
@@ -59,7 +58,6 @@ export default function MainFiles(
|
||||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
isCalDavEnabled,
|
||||
fileShareId,
|
||||
}: MainFilesProps,
|
||||
) {
|
||||
@@ -888,15 +886,6 @@ export default function MainFiles(
|
||||
)
|
||||
: 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
|
||||
? (
|
||||
<CreateDirectoryModal
|
||||
|
||||
Reference in New Issue
Block a user