Calendar code re-organization, event CRUD (except for Update).
This commit is contained in:
156
components/calendar/AddEventModal.tsx
Normal file
156
components/calendar/AddEventModal.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
|
||||||
|
export type NewCalendarEvent = Pick<
|
||||||
|
CalendarEvent,
|
||||||
|
'id' | 'calendar_id' | 'title' | 'start_date' | 'end_date' | 'is_all_day'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface AddEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialStartDate?: Date;
|
||||||
|
initiallyAllDay?: boolean;
|
||||||
|
calendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||||
|
onClickSave: (newEvent: NewCalendarEvent) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddEventModal(
|
||||||
|
{ isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps,
|
||||||
|
) {
|
||||||
|
const newEvent = useSignal<NewCalendarEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
newEvent.value = null;
|
||||||
|
} else {
|
||||||
|
const startDate = new Date(initialStartDate || new Date());
|
||||||
|
|
||||||
|
startDate.setMinutes(0);
|
||||||
|
startDate.setSeconds(0);
|
||||||
|
startDate.setMilliseconds(0);
|
||||||
|
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setHours(startDate.getHours() + 1);
|
||||||
|
|
||||||
|
if (initiallyAllDay) {
|
||||||
|
startDate.setHours(9);
|
||||||
|
endDate.setHours(18);
|
||||||
|
}
|
||||||
|
|
||||||
|
newEvent.value = {
|
||||||
|
id: 'new',
|
||||||
|
title: '',
|
||||||
|
calendar_id: calendars[0]!.id,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
is_all_day: initiallyAllDay || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [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 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
name='event_calendar'
|
||||||
|
id='event_calendar'
|
||||||
|
value={newEvent.value?.calendar_id || ''}
|
||||||
|
onChange={(event) => newEvent.value = { ...newEvent.value!, calendar_id: event.currentTarget.value }}
|
||||||
|
>
|
||||||
|
{calendars.map((calendar) => <option value={calendar.id}>{calendar.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 block ${
|
||||||
|
calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color
|
||||||
|
} rounded-full`}
|
||||||
|
title={calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color}
|
||||||
|
>
|
||||||
|
</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?.start_date
|
||||||
|
? new Date(newEvent.value.start_date).toISOString().substring(0, 16)
|
||||||
|
: ''}
|
||||||
|
onInput={(event) =>
|
||||||
|
newEvent.value = { ...newEvent.value!, start_date: new Date(event.currentTarget.value) }}
|
||||||
|
/>
|
||||||
|
</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?.end_date ? new Date(newEvent.value.end_date).toISOString().substring(0, 16) : ''}
|
||||||
|
onInput={(event) =>
|
||||||
|
newEvent.value = { ...newEvent.value!, end_date: new Date(event.currentTarget.value) }}
|
||||||
|
/>
|
||||||
|
</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?.is_all_day}
|
||||||
|
onChange={(event) => newEvent.value = { ...newEvent.value!, is_all_day: event.currentTarget.checked }}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
<footer class='flex justify-between'>
|
||||||
|
<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
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
components/calendar/CalendarViewDay.tsx
Normal file
225
components/calendar/CalendarViewDay.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface CalendarViewDayProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewDay(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewDayProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
const dayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (!calendarEvent.is_all_day) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayDate = new Date(startDate);
|
||||||
|
const endDayDate = new Date(startDate);
|
||||||
|
endDayDate.setHours(23);
|
||||||
|
endDayDate.setMinutes(59);
|
||||||
|
endDayDate.setSeconds(59);
|
||||||
|
endDayDate.setMilliseconds(999);
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.start_date);
|
||||||
|
const eventEndDate = new Date(calendarEvent.end_date);
|
||||||
|
|
||||||
|
// 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.setHours(hourNumber);
|
||||||
|
|
||||||
|
const shortIsoDate = date.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const isCurrentHour = shortIsoDate === today && new Date().getHours() === 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 ${
|
||||||
|
visibleCalendars.find((calendar) => calendar.id === calendarEvent.calendar_id)
|
||||||
|
?.color || 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
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.setHours(hour.date.getHours());
|
||||||
|
const endHourDate = new Date(shortIsoDate);
|
||||||
|
endHourDate.setHours(hour.date.getHours());
|
||||||
|
endHourDate.setMinutes(59);
|
||||||
|
endHourDate.setSeconds(59);
|
||||||
|
endHourDate.setMilliseconds(999);
|
||||||
|
|
||||||
|
const isLastHour = hourIndex === 23;
|
||||||
|
|
||||||
|
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (calendarEvent.is_all_day) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.start_date);
|
||||||
|
const eventEndDate = new Date(calendarEvent.end_date);
|
||||||
|
eventEndDate.setSeconds(eventEndDate.getSeconds() - 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 ${
|
||||||
|
visibleCalendars.find((calendar) => calendar.id === hourEvent.calendar_id)
|
||||||
|
?.color || 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickOpenEvent(hourEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{hourEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
components/calendar/CalendarViewMonth.tsx
Normal file
162
components/calendar/CalendarViewMonth.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
import { getWeeksForMonth } from '/lib/utils.ts';
|
||||||
|
|
||||||
|
interface CalendarViewWeekProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewWeek(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
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.setHours(23);
|
||||||
|
endDayDate.setMinutes(59);
|
||||||
|
endDayDate.setSeconds(59);
|
||||||
|
endDayDate.setMilliseconds(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.start_date);
|
||||||
|
const eventEndDate = new Date(calendarEvent.end_date);
|
||||||
|
|
||||||
|
// 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.getDate()}
|
||||||
|
</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 ${
|
||||||
|
visibleCalendars.find((calendar) => calendar.id === dayEvent.calendar_id)
|
||||||
|
?.color || 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickOpenEvent(dayEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(dayEvent.start_date).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(dayEvent.start_date))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
components/calendar/CalendarViewWeek.tsx
Normal file
218
components/calendar/CalendarViewWeek.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
import { getDaysForWeek } from '/lib/utils.ts';
|
||||||
|
|
||||||
|
interface CalendarViewWeekProps {
|
||||||
|
startDate: Date;
|
||||||
|
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||||
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarViewWeek(
|
||||||
|
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||||
|
) {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
const weekDayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'short' });
|
||||||
|
|
||||||
|
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.is_all_day) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayDate = new Date(day.date);
|
||||||
|
const endDayDate = new Date(day.date);
|
||||||
|
endDayDate.setHours(23);
|
||||||
|
endDayDate.setMinutes(59);
|
||||||
|
endDayDate.setSeconds(59);
|
||||||
|
endDayDate.setMilliseconds(999);
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.start_date);
|
||||||
|
const eventEndDate = new Date(calendarEvent.end_date);
|
||||||
|
|
||||||
|
// 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 ${
|
||||||
|
visibleCalendars.find((calendar) => calendar.id === calendarEvent.calendar_id)
|
||||||
|
?.color || 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
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.setHours(hour.date.getHours());
|
||||||
|
const endHourDate = new Date(shortIsoDate);
|
||||||
|
endHourDate.setHours(hour.date.getHours());
|
||||||
|
endHourDate.setMinutes(59);
|
||||||
|
endHourDate.setSeconds(59);
|
||||||
|
endHourDate.setMilliseconds(999);
|
||||||
|
|
||||||
|
const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0;
|
||||||
|
const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6;
|
||||||
|
|
||||||
|
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||||
|
if (calendarEvent.is_all_day) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStartDate = new Date(calendarEvent.start_date);
|
||||||
|
const eventEndDate = new Date(calendarEvent.end_date);
|
||||||
|
eventEndDate.setSeconds(eventEndDate.getSeconds() - 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'} min-h-8 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 ${
|
||||||
|
visibleCalendars.find((calendar) => calendar.id === hourEvent.calendar_id)
|
||||||
|
?.color || 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickOpenEvent(hourEvent)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||||
|
class='mr-2 flex-none text-slate-100 block'
|
||||||
|
>
|
||||||
|
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||||
|
</time>
|
||||||
|
<p class='flex-auto truncate font-medium text-white'>
|
||||||
|
{hourEvent.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
components/calendar/MainCalendar.tsx
Normal file
653
components/calendar/MainCalendar.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
import { baseUrl, capitalizeWord } from '/lib/utils.ts';
|
||||||
|
// import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/calendar/get.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-events.tsx';
|
||||||
|
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||||
|
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||||
|
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||||
|
import AddEventModal, { NewCalendarEvent } from './AddEventModal.tsx';
|
||||||
|
import ViewEventModal from './ViewEventModal.tsx';
|
||||||
|
|
||||||
|
interface MainCalendarProps {
|
||||||
|
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||||
|
initialCalendarEvents: CalendarEvent[];
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) {
|
||||||
|
const isAdding = useSignal<boolean>(false);
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const isExporting = useSignal<boolean>(false);
|
||||||
|
const isImporting = useSignal<boolean>(false);
|
||||||
|
const isSearching = useSignal<boolean>(false);
|
||||||
|
const calendars = useSignal<Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[]>(initialCalendars);
|
||||||
|
const isViewOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
|
||||||
|
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
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 dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
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: NewCalendarEvent) {
|
||||||
|
if (isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: AddRequestBody = {
|
||||||
|
calendarIds: calendars.value.map((calendar) => calendar.id),
|
||||||
|
calendarView: view,
|
||||||
|
calendarStartDate: startDate,
|
||||||
|
calendarId: newEvent.calendar_id,
|
||||||
|
title: newEvent.title,
|
||||||
|
startDate: new Date(newEvent.start_date).toISOString(),
|
||||||
|
endDate: new Date(newEvent.end_date).toISOString(),
|
||||||
|
isAllDay: newEvent.is_all_day,
|
||||||
|
};
|
||||||
|
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.id === calendarEvent.calendar_id)!;
|
||||||
|
|
||||||
|
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: calendars.value.map((calendar) => calendar.id),
|
||||||
|
calendarView: view,
|
||||||
|
calendarStartDate: startDate,
|
||||||
|
calendarEventId,
|
||||||
|
calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.id === calendarEventId)!.calendar_id,
|
||||||
|
};
|
||||||
|
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() {
|
||||||
|
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 = (fileRead) => {
|
||||||
|
const importFileContents = fileRead.target?.result;
|
||||||
|
|
||||||
|
if (!importFileContents || isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = true;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const partialContacts = parseVCardFromTextContents(importFileContents!.toString());
|
||||||
|
|
||||||
|
// const requestBody: ImportRequestBody = { partialContacts, page };
|
||||||
|
// 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 contact!');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// contacts.value = [...result.contacts];
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(error);
|
||||||
|
// }
|
||||||
|
|
||||||
|
isImporting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickExportICS() {
|
||||||
|
isImportExportOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isExporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
// const fileName = ['calendars-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics']
|
||||||
|
// .join('');
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const requestBody: GetRequestBody = {};
|
||||||
|
// const response = await fetch(`/api/calendar/get`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// body: JSON.stringify(requestBody),
|
||||||
|
// });
|
||||||
|
// const result = await response.json() as GetResponseBody;
|
||||||
|
|
||||||
|
// if (!result.success) {
|
||||||
|
// throw new Error('Failed to get contact!');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const exportContents = formatContactToVCard([...result.contacts]);
|
||||||
|
|
||||||
|
// // Add content-type
|
||||||
|
// const vCardContent = ['data:text/vcard; 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchEvents(searchTerms: string) {
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout.value = setTimeout(async () => {
|
||||||
|
isSearching.value = true;
|
||||||
|
|
||||||
|
// TODO: Remove this
|
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(true), 1000));
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const requestBody: RequestBody = { search: searchTerms };
|
||||||
|
// 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!');
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(error);
|
||||||
|
// }
|
||||||
|
|
||||||
|
isSearching.value = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleCalendars = calendars.value.filter((calendar) => calendar.is_visible);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
class='input-field w-72 mr-2'
|
||||||
|
type='search'
|
||||||
|
name='search'
|
||||||
|
placeholder='Search events...'
|
||||||
|
onInput={(event) => searchEvents(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||||
|
</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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickImportICS()}
|
||||||
|
>
|
||||||
|
Import ICS
|
||||||
|
</button>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{view === 'week'
|
||||||
|
? (
|
||||||
|
<CalendarViewWeek
|
||||||
|
startDate={new Date(startDate)}
|
||||||
|
visibleCalendars={visibleCalendars}
|
||||||
|
calendarEvents={calendarEvents.value}
|
||||||
|
onClickAddEvent={onClickAddEvent}
|
||||||
|
onClickOpenEvent={onClickOpenEvent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{view === 'month'
|
||||||
|
? (
|
||||||
|
<CalendarViewMonth
|
||||||
|
startDate={new Date(startDate)}
|
||||||
|
visibleCalendars={visibleCalendars}
|
||||||
|
calendarEvents={calendarEvents.value}
|
||||||
|
onClickAddEvent={onClickAddEvent}
|
||||||
|
onClickOpenEvent={onClickOpenEvent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: 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 URLs:</span>{' '}
|
||||||
|
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||||
|
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/calendars/</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/calendar/ViewEventModal.tsx
Normal file
112
components/calendar/ViewEventModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface ViewEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
calendarEvent: CalendarEvent;
|
||||||
|
calendar: Pick<Calendar, 'id' | 'name' | 'color'>;
|
||||||
|
onClickDelete: (eventId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewEventModal(
|
||||||
|
{ isOpen, calendarEvent, calendar, onClickDelete, onClose }: ViewEventModalProps,
|
||||||
|
) {
|
||||||
|
if (!calendarEvent || !calendar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
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 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||||
|
>
|
||||||
|
<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.start_date ? allDayEventDateFormat.format(new Date(calendarEvent.start_date)) : ''}
|
||||||
|
</span>
|
||||||
|
{calendarEvent.is_all_day ? <span>All-day</span> : (
|
||||||
|
<span>
|
||||||
|
{calendarEvent.start_date ? hourFormat.format(new Date(calendarEvent.start_date)) : ''} -{' '}
|
||||||
|
{calendarEvent.end_date ? hourFormat.format(new Date(calendarEvent.end_date)) : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500 flex justify-between items-center'>
|
||||||
|
<span>
|
||||||
|
{calendar.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class={`w-5 h-5 ml-2 block ${calendar.color} rounded-full`}
|
||||||
|
title={calendar.color}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p>TODO: recurrence</p>
|
||||||
|
</section>
|
||||||
|
{calendarEvent.extra.description
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p>{calendarEvent.extra.description}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{calendarEvent.extra.url
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a href={calendarEvent.extra.url} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{calendarEvent.extra.url}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{calendarEvent.extra.location
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/maps?q=${encodeURIComponent(calendarEvent.extra.location)}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{calendarEvent.extra.location}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<section class='py-5 mb-2 border-b border-slate-500'>
|
||||||
|
<p>TODO: reminders</p>
|
||||||
|
</section>
|
||||||
|
<footer class='flex justify-between'>
|
||||||
|
<button
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md'
|
||||||
|
onClick={() => onClickDelete(calendarEvent.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/calendar/events/${calendarEvent.id}`}
|
||||||
|
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
|||||||
import * as $_404 from './routes/_404.tsx';
|
import * as $_404 from './routes/_404.tsx';
|
||||||
import * as $_app from './routes/_app.tsx';
|
import * as $_app from './routes/_app.tsx';
|
||||||
import * as $_middleware from './routes/_middleware.tsx';
|
import * as $_middleware from './routes/_middleware.tsx';
|
||||||
|
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_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_delete from './routes/api/calendar/delete.tsx';
|
||||||
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
|
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
|
||||||
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||||
@@ -44,8 +46,8 @@ import * as $remote_php_davRoute_ from './routes/remote.php/[davRoute].tsx';
|
|||||||
import * as $settings from './routes/settings.tsx';
|
import * as $settings from './routes/settings.tsx';
|
||||||
import * as $signup from './routes/signup.tsx';
|
import * as $signup from './routes/signup.tsx';
|
||||||
import * as $Settings from './islands/Settings.tsx';
|
import * as $Settings from './islands/Settings.tsx';
|
||||||
|
import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx';
|
||||||
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
|
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
|
||||||
import * as $calendar_MainCalendar from './islands/calendar/MainCalendar.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';
|
||||||
@@ -61,7 +63,9 @@ const manifest = {
|
|||||||
'./routes/_404.tsx': $_404,
|
'./routes/_404.tsx': $_404,
|
||||||
'./routes/_app.tsx': $_app,
|
'./routes/_app.tsx': $_app,
|
||||||
'./routes/_middleware.tsx': $_middleware,
|
'./routes/_middleware.tsx': $_middleware,
|
||||||
|
'./routes/api/calendar/add-event.tsx': $api_calendar_add_event,
|
||||||
'./routes/api/calendar/add.tsx': $api_calendar_add,
|
'./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/delete.tsx': $api_calendar_delete,
|
||||||
'./routes/api/calendar/update.tsx': $api_calendar_update,
|
'./routes/api/calendar/update.tsx': $api_calendar_update,
|
||||||
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||||
@@ -100,8 +104,8 @@ const manifest = {
|
|||||||
},
|
},
|
||||||
islands: {
|
islands: {
|
||||||
'./islands/Settings.tsx': $Settings,
|
'./islands/Settings.tsx': $Settings,
|
||||||
|
'./islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper,
|
||||||
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
|
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
|
||||||
'./islands/calendar/MainCalendar.tsx': $calendar_MainCalendar,
|
|
||||||
'./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,
|
||||||
|
|||||||
23
islands/calendar/CalendarWrapper.tsx
Normal file
23
islands/calendar/CalendarWrapper.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
import MainCalendar from '/components/calendar/MainCalendar.tsx';
|
||||||
|
|
||||||
|
interface CalendarWrapperProps {
|
||||||
|
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||||
|
initialCalendarEvents: CalendarEvent[];
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }: CalendarWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainCalendar
|
||||||
|
initialCalendars={initialCalendars}
|
||||||
|
initialCalendarEvents={initialCalendarEvents}
|
||||||
|
view={view}
|
||||||
|
startDate={startDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
import { CALENDAR_COLOR_OPTIONS, getRandomItem } from '/lib/utils.ts';
|
import { CALENDAR_COLOR_OPTIONS, getRandomItem } from '/lib/utils.ts';
|
||||||
// import { getUserById } from './user.ts';
|
import { getUserById } from './user.ts';
|
||||||
|
|
||||||
const db = new Database();
|
const db = new Database();
|
||||||
|
|
||||||
@@ -16,7 +16,12 @@ export async function getCalendars(userId: string): Promise<Calendar[]> {
|
|||||||
return calendars;
|
return calendars;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCalendarEvents(userId: string, calendarIds: string[]): Promise<CalendarEvent[]> {
|
export async function getCalendarEvents(
|
||||||
|
userId: string,
|
||||||
|
calendarIds: string[],
|
||||||
|
dateRange?: { start: Date; end: Date },
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
if (!dateRange) {
|
||||||
const calendarEvents = await db.query<CalendarEvent>(
|
const calendarEvents = await db.query<CalendarEvent>(
|
||||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`,
|
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`,
|
||||||
[
|
[
|
||||||
@@ -26,6 +31,19 @@ export async function getCalendarEvents(userId: string, calendarIds: string[]):
|
|||||||
);
|
);
|
||||||
|
|
||||||
return calendarEvents;
|
return calendarEvents;
|
||||||
|
} else {
|
||||||
|
const calendarEvents = await db.query<CalendarEvent>(
|
||||||
|
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) AND (("start_date" >= $3 OR "end_date" <= $4) OR ("start_date" < $3 AND "end_date" > $4)) ORDER BY "start_date" ASC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
calendarIds,
|
||||||
|
dateRange.start,
|
||||||
|
dateRange.end,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return calendarEvents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCalendarEvent(id: string, calendarId: string, userId: string): Promise<CalendarEvent> {
|
export async function getCalendarEvent(id: string, calendarId: string, userId: string): Promise<CalendarEvent> {
|
||||||
@@ -127,4 +145,97 @@ export async function deleteCalendar(id: string, userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: When creating, updating, or deleting events, also update the calendar's revision
|
async function updateCalendarRevision(calendar: Calendar) {
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_calendars" SET
|
||||||
|
"revision" = $3,
|
||||||
|
"updated_at" = now()
|
||||||
|
WHERE "id" = $1 AND "revision" = $2`,
|
||||||
|
[
|
||||||
|
calendar.id,
|
||||||
|
calendar.revision,
|
||||||
|
revision,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCalendarEvent(
|
||||||
|
userId: string,
|
||||||
|
calendarId: string,
|
||||||
|
title: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
isAllDay = false,
|
||||||
|
) {
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await getCalendar(calendarId, userId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
throw new Error('Calendar not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra: CalendarEvent['extra'] = {
|
||||||
|
organizer_email: user.email,
|
||||||
|
transparency: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
const status: CalendarEvent['status'] = 'scheduled';
|
||||||
|
|
||||||
|
const newCalendar = (await db.query<Calendar>(
|
||||||
|
sql`INSERT INTO "bewcloud_calendar_events" (
|
||||||
|
"user_id",
|
||||||
|
"calendar_id",
|
||||||
|
"revision",
|
||||||
|
"title",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"is_all_day",
|
||||||
|
"status",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
calendarId,
|
||||||
|
revision,
|
||||||
|
title,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
isAllDay,
|
||||||
|
status,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
await updateCalendarRevision(calendar);
|
||||||
|
|
||||||
|
return newCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCalendarEvent(id: string, calendarId: string, userId: string) {
|
||||||
|
const calendar = await getCalendar(calendarId, userId);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
throw new Error('Calendar not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
calendarId,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateCalendarRevision(calendar);
|
||||||
|
}
|
||||||
|
|||||||
77
routes/api/calendar/add-event.tsx
Normal file
77
routes/api/calendar/add-event.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { createCalendarEvent, getCalendar, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendarEvent = await createCalendarEvent(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.calendarId,
|
||||||
|
requestBody.title,
|
||||||
|
new Date(requestBody.startDate),
|
||||||
|
new Date(requestBody.endDate),
|
||||||
|
requestBody.isAllDay,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newCalendarEvent) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) };
|
||||||
|
|
||||||
|
if (requestBody.calendarView === 'day') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||||
|
} else if (requestBody.calendarView === 'week') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendarEvents = await getCalendarEvents(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
74
routes/api/calendar/delete-event.tsx
Normal file
74
routes/api/calendar/delete-event.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { deleteCalendarEvent, getCalendar, getCalendarEvent, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvent = await getCalendarEvent(
|
||||||
|
requestBody.calendarEventId,
|
||||||
|
requestBody.calendarId,
|
||||||
|
context.state.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendarEvent) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteCalendarEvent(requestBody.calendarEventId, requestBody.calendarId, context.state.user.id);
|
||||||
|
|
||||||
|
const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) };
|
||||||
|
|
||||||
|
if (requestBody.calendarView === 'day') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||||
|
} else if (requestBody.calendarView === 'week') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalendarEvents = await getCalendarEvents(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts';
|
|||||||
|
|
||||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts';
|
import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts';
|
||||||
import MainCalendar from '/islands/calendar/MainCalendar.tsx';
|
import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx';
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||||
@@ -24,6 +24,20 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
|
|
||||||
const userCalendars = await getCalendars(context.state.user.id);
|
const userCalendars = await getCalendars(context.state.user.id);
|
||||||
const visibleCalendarIds = userCalendars.filter((calendar) => calendar.is_visible).map((calendar) => calendar.id);
|
const visibleCalendarIds = userCalendars.filter((calendar) => calendar.is_visible).map((calendar) => calendar.id);
|
||||||
|
|
||||||
|
const dateRange = { start: new Date(startDate), end: new Date(startDate) };
|
||||||
|
|
||||||
|
if (view === 'day') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||||
|
} else if (view === 'week') {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||||
|
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||||
|
}
|
||||||
|
|
||||||
const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds);
|
const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds);
|
||||||
|
|
||||||
return await context.render({ userCalendars, userCalendarEvents, view, startDate });
|
return await context.render({ userCalendars, userCalendarEvents, view, startDate });
|
||||||
@@ -33,7 +47,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
export default function CalendarPage({ data }: PageProps<Data, FreshContextState>) {
|
export default function CalendarPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<MainCalendar
|
<CalendarWrapper
|
||||||
initialCalendars={data.userCalendars}
|
initialCalendars={data.userCalendars}
|
||||||
initialCalendarEvents={data.userCalendarEvents}
|
initialCalendarEvents={data.userCalendarEvents}
|
||||||
view={data.view}
|
view={data.view}
|
||||||
|
|||||||
Reference in New Issue
Block a user