diff --git a/components/calendar/AddEventModal.tsx b/components/calendar/AddEventModal.tsx new file mode 100644 index 0000000..7502646 --- /dev/null +++ b/components/calendar/AddEventModal.tsx @@ -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[]; + onClickSave: (newEvent: NewCalendarEvent) => Promise; + onClose: () => void; +} + +export default function AddEventModal( + { isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps, +) { + const newEvent = useSignal(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 ( + <> +
+
+ +
+

New Event

+
+
+ + newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }} + placeholder='Dentist' + /> +
+
+ +
+ + calendar.id === newEvent.value?.calendar_id)?.color + } rounded-full`} + title={calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color} + > + +
+
+
+ + + newEvent.value = { ...newEvent.value!, start_date: new Date(event.currentTarget.value) }} + /> +
+
+ + + newEvent.value = { ...newEvent.value!, end_date: new Date(event.currentTarget.value) }} + /> +
+
+ + newEvent.value = { ...newEvent.value!, is_all_day: event.currentTarget.checked }} + /> +
+
+
+ + +
+
+ + ); +} diff --git a/components/calendar/CalendarViewDay.tsx b/components/calendar/CalendarViewDay.tsx new file mode 100644 index 0000000..df7c9b2 --- /dev/null +++ b/components/calendar/CalendarViewDay.tsx @@ -0,0 +1,225 @@ +import { Calendar, CalendarEvent } from '/lib/types.ts'; + +interface CalendarViewDayProps { + startDate: Date; + visibleCalendars: Pick[]; + 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 ( +
+
+
+ {dayFormat.format(startDate)} +
+
+
+
+ {allDayEvents.length > 0 + ? ( +
+ +
    + {allDayEvents.map((calendarEvent) => ( +
  1. + calendar.id === calendarEvent.calendar_id) + ?.color || 'bg-gray-700' + }`} + onClick={() => onClickOpenEvent(calendarEvent)} + > +

    + {calendarEvent.title} +

    +
    +
  2. + ))} +
+
+ ) + : 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 ( +
+ + {hourEvents.length > 0 + ? ( +
    + {hourEvents.map((hourEvent) => ( +
  1. + calendar.id === hourEvent.calendar_id) + ?.color || 'bg-gray-700' + }`} + onClick={() => onClickOpenEvent(hourEvent)} + > + +

    + {hourEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ ); + })} +
+
+
+ ); +} diff --git a/components/calendar/CalendarViewMonth.tsx b/components/calendar/CalendarViewMonth.tsx new file mode 100644 index 0000000..b14a9d4 --- /dev/null +++ b/components/calendar/CalendarViewMonth.tsx @@ -0,0 +1,162 @@ +import { Calendar, CalendarEvent } from '/lib/types.ts'; +import { getWeeksForMonth } from '/lib/utils.ts'; + +interface CalendarViewWeekProps { + startDate: Date; + visibleCalendars: Pick[]; + 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 ( +
+
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+
+ {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 ( +
+ + {dayEvents.length > 0 + ? ( +
    + {[...dayEvents].slice(0, 2).map((dayEvent) => ( +
  1. + calendar.id === dayEvent.calendar_id) + ?.color || 'bg-gray-700' + }`} + onClick={() => onClickOpenEvent(dayEvent)} + > + +

    + {dayEvent.title} +

    +
    +
  2. + ))} + {dayEvents.length > 2 + ? ( +
  3. + +

    + ...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'} +

    +
    +
  4. + ) + : null} +
+ ) + : null} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/components/calendar/CalendarViewWeek.tsx b/components/calendar/CalendarViewWeek.tsx new file mode 100644 index 0000000..bfd5c95 --- /dev/null +++ b/components/calendar/CalendarViewWeek.tsx @@ -0,0 +1,218 @@ +import { Calendar, CalendarEvent } from '/lib/types.ts'; +import { getDaysForWeek } from '/lib/utils.ts'; + +interface CalendarViewWeekProps { + startDate: Date; + visibleCalendars: Pick[]; + 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 ( +
+
+ {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 ( + <> +
+ {weekDayFormat.format(day.date)} +
+
+ + {allDayEvents.length > 0 + ? ( +
    + {allDayEvents.map((calendarEvent) => ( +
  1. + calendar.id === calendarEvent.calendar_id) + ?.color || 'bg-gray-700' + }`} + onClick={() => onClickOpenEvent(calendarEvent)} + > +

    + {calendarEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ {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 ( +
+ + {hourEvents.length > 0 + ? ( +
    + {hourEvents.map((hourEvent) => ( +
  1. + calendar.id === hourEvent.calendar_id) + ?.color || 'bg-gray-700' + }`} + onClick={() => onClickOpenEvent(hourEvent)} + > + +

    + {hourEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ ); + })} + + ); + })} +
+
+ ); +} diff --git a/components/calendar/MainCalendar.tsx b/components/calendar/MainCalendar.tsx new file mode 100644 index 0000000..73915b5 --- /dev/null +++ b/components/calendar/MainCalendar.tsx @@ -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[]; + initialCalendarEvents: CalendarEvent[]; + view: 'day' | 'week' | 'month'; + startDate: string; +} + +export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isExporting = useSignal(false); + const isImporting = useSignal(false); + const isSearching = useSignal(false); + const calendars = useSignal[]>(initialCalendars); + const isViewOptionsDropdownOpen = useSignal(false); + const isImportExportOptionsDropdownOpen = useSignal(false); + const calendarEvents = useSignal(initialCalendarEvents); + const searchTimeout = useSignal>(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 ( + <> +
+
+
+ Manage calendars + searchEvents(event.currentTarget.value)} + /> + {isSearching.value ? : null} +
+
+ +
+

+ +

+
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ +
+ {view === 'day' + ? ( + + ) + : null} + {view === 'week' + ? ( + + ) + : null} + {view === 'month' + ? ( + + ) + : null} + + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} + +
+ +
+ CalDAV URLs:{' '} + {baseUrl}/dav/principals/{' '} + {baseUrl}/dav/calendars/ +
+ + + + + + ); +} diff --git a/components/calendar/ViewEventModal.tsx b/components/calendar/ViewEventModal.tsx new file mode 100644 index 0000000..392eb87 --- /dev/null +++ b/components/calendar/ViewEventModal.tsx @@ -0,0 +1,112 @@ +import { Calendar, CalendarEvent } from '/lib/types.ts'; + +interface ViewEventModalProps { + isOpen: boolean; + calendarEvent: CalendarEvent; + calendar: Pick; + 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 ( + <> +
+
+ +
+

{calendarEvent.title}

+
+ + {calendarEvent.start_date ? allDayEventDateFormat.format(new Date(calendarEvent.start_date)) : ''} + + {calendarEvent.is_all_day ? All-day : ( + + {calendarEvent.start_date ? hourFormat.format(new Date(calendarEvent.start_date)) : ''} -{' '} + {calendarEvent.end_date ? hourFormat.format(new Date(calendarEvent.end_date)) : ''} + + )} +
+
+ + {calendar.name} + + +
+
+

TODO: recurrence

+
+ {calendarEvent.extra.description + ? ( +
+

{calendarEvent.extra.description}

+
+ ) + : null} + {calendarEvent.extra.url + ? ( +
+ + {calendarEvent.extra.url} + +
+ ) + : null} + {calendarEvent.extra.location + ? ( +
+ + {calendarEvent.extra.location} + +
+ ) + : null} +
+

TODO: reminders

+
+
+ + + Edit + + +
+
+ + ); +} diff --git a/fresh.gen.ts b/fresh.gen.ts index d87feff..2dacd97 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -7,7 +7,9 @@ import * as $_well_known_carddav from './routes/.well-known/carddav.tsx'; import * as $_404 from './routes/_404.tsx'; import * as $_app from './routes/_app.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_delete_event from './routes/api/calendar/delete-event.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_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 $signup from './routes/signup.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_MainCalendar from './islands/calendar/MainCalendar.tsx'; import * as $contacts_Contacts from './islands/contacts/Contacts.tsx'; import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; @@ -61,7 +63,9 @@ const manifest = { './routes/_404.tsx': $_404, './routes/_app.tsx': $_app, './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/delete-event.tsx': $api_calendar_delete_event, './routes/api/calendar/delete.tsx': $api_calendar_delete, './routes/api/calendar/update.tsx': $api_calendar_update, './routes/api/contacts/add.tsx': $api_contacts_add, @@ -100,8 +104,8 @@ const manifest = { }, islands: { './islands/Settings.tsx': $Settings, + './islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper, './islands/calendar/Calendars.tsx': $calendar_Calendars, - './islands/calendar/MainCalendar.tsx': $calendar_MainCalendar, './islands/contacts/Contacts.tsx': $contacts_Contacts, './islands/contacts/ViewContact.tsx': $contacts_ViewContact, './islands/dashboard/Links.tsx': $dashboard_Links, diff --git a/islands/calendar/CalendarWrapper.tsx b/islands/calendar/CalendarWrapper.tsx new file mode 100644 index 0000000..67db089 --- /dev/null +++ b/islands/calendar/CalendarWrapper.tsx @@ -0,0 +1,23 @@ +import { Calendar, CalendarEvent } from '/lib/types.ts'; +import MainCalendar from '/components/calendar/MainCalendar.tsx'; + +interface CalendarWrapperProps { + initialCalendars: Pick[]; + 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 ( + + ); +} diff --git a/islands/calendar/MainCalendar.tsx b/islands/calendar/MainCalendar.tsx deleted file mode 100644 index 99d7c56..0000000 --- a/islands/calendar/MainCalendar.tsx +++ /dev/null @@ -1,1159 +0,0 @@ -import { useSignal } from '@preact/signals'; -import { useEffect } from 'preact/hooks'; - -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { baseUrl, capitalizeWord, getDaysForWeek, getWeeksForMonth } from '/lib/utils.ts'; -// import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; -// import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; -// import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; -// import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; - -interface MainCalendarProps { - initialCalendars: Pick[]; - initialCalendarEvents: CalendarEvent[]; - view: 'day' | 'week' | 'month'; - startDate: string; -} - -export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) { - const isAdding = useSignal(false); - const isDeleting = useSignal(false); - const isExporting = useSignal(false); - const isImporting = useSignal(false); - const isSearching = useSignal(false); - const calendars = useSignal[]>(initialCalendars); - const isViewOptionsDropdownOpen = useSignal(false); - const isImportExportOptionsDropdownOpen = useSignal(false); - const calendarEvents = useSignal(initialCalendarEvents); - const searchTimeout = useSignal>(0); - const openEvent = useSignal(null); - - 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 dayFormat = new Intl.DateTimeFormat('en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric', - }); - const weekDayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'short' }); - const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }); - const today = new Date().toISOString().substring(0, 10); - - function onClickAddEvent() { - if (isAdding.value) { - return; - } - - const title = (prompt(`What's the **title** for the new event?`) || '').trim(); - - if (!title) { - alert('A title is required for a new event!'); - return; - } - - const startDate = - (prompt(`What's the **start date** for the new event (YYYY-MM-DD)?`, new Date().toISOString().substring(0, 10)) || - '').trim(); - const startHour = - (prompt(`What's the **start hour** for the new event (HH:mm)?`, new Date().toISOString().substring(11, 5)) || '') - .trim(); - - if (!startDate || !startHour) { - alert('A start date and hour are required for a new event!'); - return; - } - - isAdding.value = true; - - // try { - // const requestBody: AddRequestBody = { title, startDate, startHour }; - // 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 contact!'); - // } - - // contacts.value = [...result.contacts]; - // } catch (error) { - // console.error(error); - // } - - isAdding.value = false; - } - - function toggleImportExportOptionsDropdown() { - isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value; - } - - function toggleViewOptionsDropdown() { - isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value; - } - - 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 = { calendarEventId, view, startDay }; - // 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!'); - // } - - // contacts.value = [...result.contacts]; - // } catch (error) { - // console.error(error); - // } - - isDeleting.value = false; - - openEvent.value = null; - } - } - - 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); - - const weeks = view === 'month' ? getWeeksForMonth(new Date(startDate)) : []; - - const hours: { date: Date; isCurrentHour: boolean }[] = view === 'day' - ? 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, - }; - }) - : []; - - const allDayEvents: CalendarEvent[] = view === 'day' - ? calendarEvents.value.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 days = view === 'week' ? getDaysForWeek(new Date(startDate)) : []; - - return ( - <> -
-
-
- Manage calendars - searchEvents(event.currentTarget.value)} - /> - {isSearching.value ? : null} -
-
- -
-

- -

-
- - - -
-
-
- -
- - -
-
-
- -
- - -
- -
-
- -
- {view === 'day' - ? ( -
-
-
- {dayFormat.format(new Date(startDate))} -
-
-
-
- {allDayEvents.length > 0 - ? ( -
- -
    - {allDayEvents.map((calendarEvent) => ( -
  1. - calendar.id === calendarEvent.calendar_id) - ?.color || 'bg-gray-700' - }`} - onClick={() => openEvent.value = calendarEvent} - > -

    - {calendarEvent.title} -

    -
    -
  2. - ))} -
-
- ) - : 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.value.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 ( -
- - {hourEvents.length > 0 - ? ( -
    - {hourEvents.map((hourEvent) => ( -
  1. - calendar.id === hourEvent.calendar_id) - ?.color || 'bg-gray-700' - }`} - onClick={() => openEvent.value = hourEvent} - > - -

    - {hourEvent.title} -

    -
    -
  2. - ))} -
- ) - : null} -
- ); - })} -
-
-
- ) - : null} - {view === 'week' - ? ( -
-
- {days.map((day, dayIndex) => { - const allDayEvents: CalendarEvent[] = calendarEvents.value.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 ( - <> -
- {weekDayFormat.format(day.date)} -
-
- - {allDayEvents.length > 0 - ? ( -
    - {allDayEvents.map((calendarEvent) => ( -
  1. - calendar.id === calendarEvent.calendar_id) - ?.color || 'bg-gray-700' - }`} - onClick={() => openEvent.value = calendarEvent} - > -

    - {calendarEvent.title} -

    -
    -
  2. - ))} -
- ) - : null} -
- {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.value.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 ( -
- - {hourEvents.length > 0 - ? ( -
    - {hourEvents.map((hourEvent) => ( -
  1. - calendar.id === hourEvent.calendar_id) - ?.color || 'bg-gray-700' - }`} - onClick={() => openEvent.value = hourEvent} - > - -

    - {hourEvent.title} -

    -
    -
  2. - ))} -
- ) - : null} -
- ); - })} - - ); - })} -
-
- ) - : null} - {view === 'month' - ? ( -
-
-
- Mon -
-
- Tue -
-
- Wed -
-
- Thu -
-
- Fri -
-
- Sat -
-
- Sun -
-
-
-
- {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.value.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 ( -
- - {dayEvents.length > 0 - ? ( -
    - {[...dayEvents].slice(0, 2).map((dayEvent) => ( -
  1. - calendar.id === dayEvent.calendar_id) - ?.color || 'bg-gray-700' - }`} - onClick={() => openEvent.value = dayEvent} - > - -

    - {dayEvent.title} -

    -
    -
  2. - ))} - {dayEvents.length > 2 - ? ( -
  3. - -

    - ...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'} -

    -
    -
  4. - ) - : null} -
- ) - : null} -
- ); - }) - )} -
-
-
- ) - : null} - - - {isDeleting.value - ? ( - <> - Deleting... - - ) - : null} - {isExporting.value - ? ( - <> - Exporting... - - ) - : null} - {isImporting.value - ? ( - <> - Importing... - - ) - : null} - {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} - -
- -
- CalDAV URLs:{' '} - {baseUrl}/dav/principals/{' '} - {baseUrl}/dav/calendars/ -
- -
-
- -
-

{openEvent.value?.title || ''}

-
- - {openEvent.value?.start_date ? allDayEventDateFormat.format(new Date(openEvent.value.start_date)) : ''} - - {openEvent.value?.is_all_day ? All-day : ( - - {openEvent.value?.start_date ? hourFormat.format(new Date(openEvent.value.start_date)) : ''} -{' '} - {openEvent.value?.end_date ? hourFormat.format(new Date(openEvent.value.end_date)) : ''} - - )} -
-
-

TODO: calendar, recurrence

-
- {openEvent.value?.extra.description - ? ( -
-

{openEvent.value.extra.description}

-
- ) - : null} - {openEvent.value?.extra.url - ? ( -
- - {openEvent.value.extra.url} - -
- ) - : null} - {openEvent.value?.extra.location - ? ( -
- - {openEvent.value.extra.location} - -
- ) - : null} -
-

TODO: reminders

-
-
- - - Edit - - -
-
- - ); -} diff --git a/lib/data/calendar.ts b/lib/data/calendar.ts index 4c93a31..d2d20af 100644 --- a/lib/data/calendar.ts +++ b/lib/data/calendar.ts @@ -1,7 +1,7 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { Calendar, CalendarEvent } from '/lib/types.ts'; import { CALENDAR_COLOR_OPTIONS, getRandomItem } from '/lib/utils.ts'; -// import { getUserById } from './user.ts'; +import { getUserById } from './user.ts'; const db = new Database(); @@ -16,16 +16,34 @@ export async function getCalendars(userId: string): Promise { return calendars; } -export async function getCalendarEvents(userId: string, calendarIds: string[]): Promise { - const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`, - [ - userId, - calendarIds, - ], - ); +export async function getCalendarEvents( + userId: string, + calendarIds: string[], + dateRange?: { start: Date; end: Date }, +): Promise { + if (!dateRange) { + const calendarEvents = await db.query( + sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`, + [ + userId, + calendarIds, + ], + ); - return calendarEvents; + return calendarEvents; + } else { + const calendarEvents = await db.query( + 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 { @@ -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( + 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); +} diff --git a/routes/api/calendar/add-event.tsx b/routes/api/calendar/add-event.tsx new file mode 100644 index 0000000..4c39370 --- /dev/null +++ b/routes/api/calendar/add-event.tsx @@ -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 = { + 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)); + }, +}; diff --git a/routes/api/calendar/delete-event.tsx b/routes/api/calendar/delete-event.tsx new file mode 100644 index 0000000..8c6afa2 --- /dev/null +++ b/routes/api/calendar/delete-event.tsx @@ -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 = { + 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)); + }, +}; diff --git a/routes/calendar.tsx b/routes/calendar.tsx index f234a87..0f71cb6 100644 --- a/routes/calendar.tsx +++ b/routes/calendar.tsx @@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.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 { userCalendars: Pick[]; @@ -24,6 +24,20 @@ export const handler: Handlers = { const userCalendars = await getCalendars(context.state.user.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); return await context.render({ userCalendars, userCalendarEvents, view, startDate }); @@ -33,7 +47,7 @@ export const handler: Handlers = { export default function CalendarPage({ data }: PageProps) { return (
-