diff --git a/fresh.gen.ts b/fresh.gen.ts index 6d209c2..b07b214 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -17,6 +17,7 @@ import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx'; import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx'; import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; +import * as $calendar from './routes/calendar.tsx'; import * as $contacts from './routes/contacts.tsx'; import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx'; import * as $dashboard from './routes/dashboard.tsx'; @@ -35,6 +36,7 @@ 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_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'; @@ -60,6 +62,7 @@ const manifest = { './routes/api/news/import-feeds.tsx': $api_news_import_feeds, './routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, + './routes/calendar.tsx': $calendar, './routes/contacts.tsx': $contacts, './routes/contacts/[contactId].tsx': $contacts_contactId_, './routes/dashboard.tsx': $dashboard, @@ -80,6 +83,7 @@ const manifest = { }, islands: { './islands/Settings.tsx': $Settings, + './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/MainCalendar.tsx b/islands/calendar/MainCalendar.tsx new file mode 100644 index 0000000..ed73a21 --- /dev/null +++ b/islands/calendar/MainCalendar.tsx @@ -0,0 +1,726 @@ +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/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 dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); + + 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; + } + } + + function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') { + const today = new Date().toISOString().substring(0, 10); + 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} +
+
+ +
+

+ +

+
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ +
+
+
+
+
+ M + on +
+
+ T + ue +
+
+ W + ed +
+
+ T + hu +
+
+ F + ri +
+
+ S + at +
+
+ S + un +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + {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/islands/contacts/Contacts.tsx b/islands/contacts/Contacts.tsx index e40bd68..e70c773 100644 --- a/islands/contacts/Contacts.tsx +++ b/islands/contacts/Contacts.tsx @@ -212,7 +212,7 @@ export default function Contacts({ initialContacts, page, contactsCount, search