Open event details in a modal

This commit is contained in:
Bruno Bernardino
2024-03-17 17:00:05 +00:00
parent 8b131d7855
commit f87a4ab0f1
3 changed files with 226 additions and 116 deletions

View File

@@ -26,9 +26,19 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false); const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents); const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0); const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
const openEvent = useSignal<CalendarEvent | null>(null);
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
const eventDateFormat = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour12: false,
hour: '2-digit',
minute: '2-digit',
});
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' });
const today = new Date().toISOString().substring(0, 10); const today = new Date().toISOString().substring(0, 10);
function onClickAddEvent() { function onClickAddEvent() {
@@ -111,6 +121,8 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
// } // }
isDeleting.value = false; isDeleting.value = false;
openEvent.value = null;
} }
} }
@@ -501,127 +513,147 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
</section> </section>
<section class='mx-auto max-w-7xl my-8'> <section class='mx-auto max-w-7xl my-8'>
<section> {view === 'day'
<section class='shadow-md lg:flex lg:flex-auto lg: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 lg:flex-none rounded-t-md'> <section>
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'> TODO: Build day view
<span>M</span>
<span class='sr-only sm:not-sr-only'>on</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>T</span>
<span class='sr-only sm:not-sr-only'>ue</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>W</span>
<span class='sr-only sm:not-sr-only'>ed</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>T</span>
<span class='sr-only sm:not-sr-only'>hu</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>F</span>
<span class='sr-only sm:not-sr-only'>ri</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>S</span>
<span class='sr-only sm:not-sr-only'>at</span>
</div>
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
<span>S</span>
<span class='sr-only sm:not-sr-only'>un</span>
</div>
</section> </section>
<section class='flex bg-slate-500 text-xs leading-6 text-white lg:flex-auto rounded-b-md'> )
<section class='w-full grid lg:grid-cols-7 lg:grid-rows-5 lg:gap-px rounded-b-md'> : null}
{weeks.map((week, weekIndex) => {view === 'week'
week.map((day, dayIndex) => { ? (
const shortIsoDate = day.date.toISOString().substring(0, 10); <section>
TODO: Build week view
</section>
)
: null}
{view === 'month'
? (
<section>
<section class='shadow-md lg:flex lg:flex-auto lg: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 lg:flex-none rounded-t-md'>
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'>
<span>M</span>
<span class='sr-only sm:not-sr-only'>on</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>T</span>
<span class='sr-only sm:not-sr-only'>ue</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>W</span>
<span class='sr-only sm:not-sr-only'>ed</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>T</span>
<span class='sr-only sm:not-sr-only'>hu</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>F</span>
<span class='sr-only sm:not-sr-only'>ri</span>
</div>
<div class='flex justify-center bg-gray-900 py-2'>
<span>S</span>
<span class='sr-only sm:not-sr-only'>at</span>
</div>
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
<span>S</span>
<span class='sr-only sm:not-sr-only'>un</span>
</div>
</section>
<section class='flex bg-slate-500 text-xs leading-6 text-white lg:flex-auto rounded-b-md'>
<section class='w-full grid lg:grid-cols-7 lg:grid-rows-5 lg: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 startDayDate = new Date(shortIsoDate);
const endDayDate = new Date(shortIsoDate); const endDayDate = new Date(shortIsoDate);
endDayDate.setHours(23); endDayDate.setHours(23);
endDayDate.setMinutes(59); endDayDate.setMinutes(59);
endDayDate.setSeconds(59); endDayDate.setSeconds(59);
endDayDate.setMilliseconds(999); endDayDate.setMilliseconds(999);
const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0; const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0;
const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1; const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1;
const isToday = today === shortIsoDate; const isToday = today === shortIsoDate;
// TODO: Consider events that span multiple days // TODO: Consider events that span multiple days
const dayEvents = calendarEvents.value.filter((calendarEvent) => const dayEvents = calendarEvents.value.filter((calendarEvent) =>
new Date(calendarEvent.start_date) >= startDayDate && new Date(calendarEvent.start_date) >= startDayDate &&
new Date(calendarEvent.end_date) <= endDayDate new Date(calendarEvent.end_date) <= endDayDate
); );
return ( return (
<section <section
class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${ class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${
day.isSameMonth ? '' : 'text-slate-100' day.isSameMonth ? '' : 'text-slate-100'
} ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`} } ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`}
> >
<time <time
datetime={shortIsoDate} datetime={shortIsoDate}
class={`${ class={`${
isToday isToday
? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold' ? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold'
: '' : ''
}`} }`}
> >
{day.date.getDate()} {day.date.getDate()}
</time> </time>
{dayEvents.length > 0 {dayEvents.length > 0
? ( ? (
<ol class='mt-2'> <ol class='mt-2'>
{[...dayEvents].slice(0, 2).map((dayEvent) => ( {[...dayEvents].slice(0, 2).map((dayEvent) => (
<li class='mb-1'> <li class='mb-1'>
<a <a
href='#' href='javascript:void(0);'
class={`flex px-2 py-0 rounded-md hover:no-underline hover:opacity-60 ${ class={`flex px-2 py-0 rounded-md hover:no-underline hover:opacity-60 ${
visibleCalendars.find((calendar) => calendar.id === dayEvent.calendar_id) visibleCalendars.find((calendar) => calendar.id === dayEvent.calendar_id)
?.color || 'bg-gray-700' ?.color || 'bg-gray-700'
}`} }`}
> onClick={() => openEvent.value = dayEvent}
<time >
datetime={new Date(dayEvent.start_date).toISOString()} <time
class='mr-2 flex-none text-slate-100 block' datetime={new Date(dayEvent.start_date).toISOString()}
> class='mr-2 flex-none text-slate-100 block'
{hourFormat.format(new Date(dayEvent.start_date))} >
</time> {hourFormat.format(new Date(dayEvent.start_date))}
<p class='flex-auto truncate font-medium text-white'> </time>
{dayEvent.title} <p class='flex-auto truncate font-medium text-white'>
</p> {dayEvent.title}
</a> </p>
</li> </a>
))} </li>
{dayEvents.length > 2 ))}
? ( {dayEvents.length > 2
<li class='mb-1'> ? (
<a <li class='mb-1'>
href='#' <a
class='flex bg-purple-600 px-2 py-0 rounded-md hover:no-underline hover:bg-purple-500' href={`/calendar/view=day&startDate=${shortIsoDate}`}
> class='flex bg-gray-700 px-2 py-0 rounded-md hover:no-underline hover:opacity-60'
<p class='flex-auto truncate font-medium text-white'> target='_blank'
...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'} >
</p> <p class='flex-auto truncate font-medium text-white'>
</a> ...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}
</li> </p>
) </a>
: null} </li>
</ol> )
) : null}
: null} </ol>
</section> )
); : null}
}) </section>
)} );
})
)}
</section>
</section>
</section> </section>
</section> </section>
</section> )
</section> : null}
<span <span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`} class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
@@ -656,6 +688,60 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
<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/principals/</code>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/calendars/</code> <code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/calendars/</code>
</section> </section>
<section
class={`fixed ${openEvent.value ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
openEvent.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 space-y-5 drop-shadow-lg`}
>
<h1 class='text-2xl font-semibold'>{openEvent.value?.title || ''}</h1>
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between'>
<span>
{openEvent.value?.start_date ? allDayEventDateFormat.format(new Date(openEvent.value.start_date)) : ''}
</span>
{openEvent.value?.is_all_day ? <span>All-day</span> : (
<span>
{openEvent.value?.start_date ? hourFormat.format(new Date(openEvent.value.start_date)) : ''} -{' '}
{openEvent.value?.end_date ? hourFormat.format(new Date(openEvent.value.end_date)) : ''}
</span>
)}
</header>
{openEvent.value?.extra.description
? (
<section class='py-5 border-b border-slate-500'>
<p>{openEvent.value.extra.description}</p>
</section>
)
: null}
<section class='py-5 border-b border-slate-500'>
<p>TODO: location, calendar, recurrence, 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={() => onClickDeleteEvent(openEvent.value?.id || '')}
>
Delete
</button>
<a
href={`/calendar/events/${openEvent.value?.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={() => openEvent.value = null}
>
Close
</button>
</footer>
</section>
</> </>
); );
} }

View File

@@ -147,7 +147,6 @@ export interface Calendar {
created_at: Date; created_at: Date;
} }
// NOTE: I don't really invite people to events in my private calendars, so I don't think I'll need that complexity
// TODO: Finish (more fields) // TODO: Finish (more fields)
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
@@ -162,8 +161,17 @@ export interface CalendarEvent {
extra: { extra: {
description?: string; description?: string;
location?: string; location?: string;
attendees?: CalendarEventAttendee[];
visibility: 'default' | 'public' | 'private'; visibility: 'default' | 'public' | 'private';
is_recurring?: boolean;
recurring_rrule?: string;
}; };
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
export interface CalendarEventAttendee {
email: string;
status: 'accepted' | 'rejected' | 'invited';
name?: string;
}

View File

@@ -54,7 +54,7 @@ async function getCalendarEvents(userId: string, calendarIds: string[]): Promise
{ {
id: 'event-2', id: 'event-2',
user_id: userId, user_id: userId,
calendar_id: 'personal-1', calendar_id: 'family-1',
revision: 'fake-rev', revision: 'fake-rev',
title: 'Dermatologist', title: 'Dermatologist',
start_date: new Date('2024-03-17T16:30:00.000Z'), start_date: new Date('2024-03-17T16:30:00.000Z'),
@@ -83,6 +83,22 @@ async function getCalendarEvents(userId: string, calendarIds: string[]): Promise
updated_at: new Date(), updated_at: new Date(),
created_at: new Date(), created_at: new Date(),
}, },
{
id: 'event-4',
user_id: userId,
calendar_id: 'personal-1',
revision: 'fake-rev',
title: 'Schedule server updates',
start_date: new Date('2024-03-18T09:00:00.000Z'),
end_date: new Date('2024-03-18T21:00:00.000Z'),
is_all_day: true,
status: 'scheduled',
extra: {
visibility: 'default',
},
updated_at: new Date(),
created_at: new Date(),
},
]; ];
} }
@@ -101,7 +117,7 @@ export const handler: Handlers<Data, FreshContextState> = {
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;
const view = (searchParams.get('view') as Data['view']) || 'week'; const view = (searchParams.get('view') as Data['view']) || 'month';
const startDate = searchParams.get('startDate') || new Date().toISOString().substring(0, 10); const startDate = searchParams.get('startDate') || new Date().toISOString().substring(0, 10);
const userCalendars = await getCalendars(context.state.user.id); const userCalendars = await getCalendars(context.state.user.id);