diff --git a/components/calendar/MainCalendar.tsx b/components/calendar/MainCalendar.tsx index 73915b5..dc7a68b 100644 --- a/components/calendar/MainCalendar.tsx +++ b/components/calendar/MainCalendar.tsx @@ -1,5 +1,4 @@ import { useSignal } from '@preact/signals'; -import { useEffect } from 'preact/hooks'; import { Calendar, CalendarEvent } from '/lib/types.ts'; import { baseUrl, capitalizeWord } from '/lib/utils.ts'; @@ -15,6 +14,7 @@ import CalendarViewWeek from './CalendarViewWeek.tsx'; import CalendarViewMonth from './CalendarViewMonth.tsx'; import AddEventModal, { NewCalendarEvent } from './AddEventModal.tsx'; import ViewEventModal from './ViewEventModal.tsx'; +import SearchEvents from './SearchEvents.tsx'; interface MainCalendarProps { initialCalendars: Pick[]; @@ -28,12 +28,10 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents, 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 }); @@ -344,44 +342,6 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents, 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 ( @@ -390,14 +350,7 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
Manage calendars - searchEvents(event.currentTarget.value)} - /> - {isSearching.value ? : null} +
diff --git a/components/calendar/SearchEvents.tsx b/components/calendar/SearchEvents.tsx new file mode 100644 index 0000000..5dc1e11 --- /dev/null +++ b/components/calendar/SearchEvents.tsx @@ -0,0 +1,149 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Calendar, CalendarEvent } from '/lib/types.ts'; +import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx'; +interface SearchEventsProps { + calendars: Pick[]; + onClickOpenEvent: (calendarEvent: CalendarEvent) => void; +} + +export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) { + const isSearching = useSignal(false); + const areResultsVisible = useSignal(false); + const calendarEvents = useSignal([]); + const searchTimeout = useSignal>(0); + const closeTimeout = useSignal>(0); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + const calendarIds = calendars.map((calendar) => calendar.id); + + function searchEvents(searchTerm: string) { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (searchTerm.trim().length < 2) { + return; + } + + areResultsVisible.value = false; + + searchTimeout.value = setTimeout(async () => { + isSearching.value = true; + + try { + const requestBody: RequestBody = { calendarIds, searchTerm }; + const response = await fetch(`/api/calendar/search-events`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to search events!'); + } + + calendarEvents.value = result.matchingCalendarEvents; + + if (calendarEvents.value.length > 0) { + areResultsVisible.value = true; + } + } catch (error) { + console.error(error); + } + + isSearching.value = false; + }, 500); + } + + function onFocus() { + if (calendarEvents.value.length > 0) { + areResultsVisible.value = true; + } + } + + function onBlur() { + if (closeTimeout.value) { + clearTimeout(closeTimeout.value); + } + + closeTimeout.value = setTimeout(() => { + areResultsVisible.value = false; + }, 300); + } + + useEffect(() => { + return () => { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (closeTimeout.value) { + clearTimeout(closeTimeout.value); + } + }; + }, []); + + return ( + <> + searchEvents(event.currentTarget.value)} + onFocus={() => onFocus()} + onBlur={() => onBlur()} + /> + {isSearching.value ? : null} + {areResultsVisible.value + ? ( +
+ +
+ ) + : null} + + ); +} diff --git a/deno.json b/deno.json index ef3ce61..580f9f5 100644 --- a/deno.json +++ b/deno.json @@ -10,30 +10,15 @@ "update": "deno run -A -r https://fresh.deno.dev/update .", "test": "deno test -A --check" }, - "fmt": { - "useTabs": false, - "lineWidth": 120, - "indentWidth": 2, - "singleQuote": true, - "proseWrap": "preserve" - }, + "fmt": { "useTabs": false, "lineWidth": 120, "indentWidth": 2, "singleQuote": true, "proseWrap": "preserve" }, "lint": { "rules": { - "tags": [ - "fresh", - "recommended" - ], + "tags": ["fresh", "recommended"], "exclude": ["no-explicit-any", "no-empty-interface", "ban-types", "no-window"] } }, - "exclude": [ - "./_fresh/*", - "./node_modules/*" - ], - "importMap": "./import_map.json", - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "preact" - }, - "nodeModulesDir": true + "exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"], + "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }, + "nodeModulesDir": true, + "importMap": "./import_map.json" } diff --git a/fresh.gen.ts b/fresh.gen.ts index 648ae8f..fabfc53 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -11,6 +11,7 @@ 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_search_events from './routes/api/calendar/search-events.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_delete from './routes/api/contacts/delete.tsx'; @@ -69,6 +70,7 @@ const manifest = { './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/search-events.tsx': $api_calendar_search_events, './routes/api/calendar/update.tsx': $api_calendar_update, './routes/api/contacts/add.tsx': $api_contacts_add, './routes/api/contacts/delete.tsx': $api_contacts_delete, diff --git a/import_map.json b/import_map.json index 1c6e2f4..2b31925 100644 --- a/import_map.json +++ b/import_map.json @@ -4,12 +4,12 @@ "./": "./", "xml/": "https://deno.land/x/xml@2.1.3/", - "fresh/": "https://deno.land/x/fresh@1.6.5/", - "$fresh/": "https://deno.land/x/fresh@1.6.5/", - "preact": "https://esm.sh/preact@10.19.2", - "preact/": "https://esm.sh/preact@10.19.2/", - "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", - "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", + "fresh/": "https://deno.land/x/fresh@1.6.8/", + "$fresh/": "https://deno.land/x/fresh@1.6.8/", + "preact": "https://esm.sh/preact@10.19.6", + "preact/": "https://esm.sh/preact@10.19.6/", + "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", + "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "tailwindcss": "npm:tailwindcss@3.4.1", "tailwindcss/": "npm:/tailwindcss@3.4.1/", "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", diff --git a/lib/data/calendar.ts b/lib/data/calendar.ts index bffbb56..a35901a 100644 --- a/lib/data/calendar.ts +++ b/lib/data/calendar.ts @@ -288,3 +288,20 @@ export async function deleteCalendarEvent(id: string, calendarId: string, userId await updateCalendarRevision(calendar); } + +export async function searchCalendarEvents( + searchTerm: string, + 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) AND ("title" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "start_date" ASC`, + [ + userId, + calendarIds, + `%${searchTerm.split(' ').join('%')}%`, + ], + ); + + return calendarEvents; +} diff --git a/lib/data/contacts.ts b/lib/data/contacts.ts index 51234df..5e536d6 100644 --- a/lib/data/contacts.ts +++ b/lib/data/contacts.ts @@ -28,13 +28,13 @@ export async function getContactsCount(userId: string) { return Number(results[0]?.count || 0); } -export async function searchContacts(search: string, userId: string, pageIndex: number) { +export async function searchContacts(searchTerm: string, userId: string, pageIndex: number) { const contacts = await db.query>( sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`, [ userId, pageIndex * CONTACTS_PER_PAGE_COUNT, - `%${search}%`, + `%${searchTerm.split(' ').join('%')}%`, ], ); diff --git a/routes/api/calendar/search-events.tsx b/routes/api/calendar/search-events.tsx new file mode 100644 index 0000000..103018d --- /dev/null +++ b/routes/api/calendar/search-events.tsx @@ -0,0 +1,42 @@ +import { Handlers } from 'fresh/server.ts'; + +import { CalendarEvent, FreshContextState } from '/lib/types.ts'; +import { searchCalendarEvents } from '/lib/data/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; + searchTerm: string; +} + +export interface ResponseBody { + success: boolean; + matchingCalendarEvents: 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.calendarIds || !requestBody.searchTerm + ) { + return new Response('Bad Request', { status: 400 }); + } + + const matchingCalendarEvents = await searchCalendarEvents( + requestBody.searchTerm, + context.state.user.id, + requestBody.calendarIds, + ); + + const responseBody: ResponseBody = { success: true, matchingCalendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +};