diff --git a/src/lib/assets/search.svg b/src/lib/assets/search.svg new file mode 100644 index 0000000..797878b --- /dev/null +++ b/src/lib/assets/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/filter.ts b/src/lib/filter.ts new file mode 100644 index 0000000..659fb46 --- /dev/null +++ b/src/lib/filter.ts @@ -0,0 +1,46 @@ +export interface SortFiler { + tags: string[]; + /** Time strings of opening range. */ + openAt: { + from: string; + to?: string; + }; + nearby: { + lat: number; + lng: number; + }; +} + +export function urlencodeSortFilter(filter: Partial): string { + const params = new URLSearchParams(); + if (filter.tags) { + filter.tags.forEach((tag) => params.append("tags", tag)); + } + if (filter.openAt) { + params.set("open_from", filter.openAt.from); + if (filter.openAt.to) params.set("open_to", filter.openAt.to); + } + if (filter.nearby) { + params.set("nearby", `${filter.nearby.lat},${filter.nearby.lng}`); + } + return params.toString(); +} + +export function urldecodeSortFilter(query: string): Partial { + const params = new URLSearchParams(query); + const filter: Partial = {}; + if (params.has("tags")) { + filter.tags = params.getAll("tags"); + } + if (params.has("open_from")) { + filter.openAt = { + from: params.get("open_from")!, + to: params.get("open_to") ?? undefined + }; + } + if (params.has("nearby")) { + const [lat, lng] = params.get("nearby")!.split(",").map(Number); + filter.nearby = { lat, lng }; + } + return filter; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 290e2b5..d9029c9 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -62,7 +62,25 @@ export const daysOfWeek = [ "All Other Days" ]; -export function timeToMins(time: string) { - const [hour, min] = time.split(":"); - return Number(hour) * 60 + Number(min); +// Convert "HH:MM" or "HH:MM:SS" to minutes since midnight +export function timeToMins(timeStr: string): number { + const [h, m] = timeStr.slice(0, 5).split(":").map(Number); + return h * 60 + m; +} + +export function haversineDistance( + lat1Deg: number, + lng1Deg: number, + lat2Deg: number, + lng2Deg: number, + radius: number = 6371e3 +): number { + const lat1 = lat1Deg * (Math.PI / 180); + const lat2 = lat2Deg * (Math.PI / 180); + const deltaLat = (lat2Deg - lat1Deg) * (Math.PI / 180); + const deltaLng = (lng2Deg - lng1Deg) * (Math.PI / 180); + const e1 = + Math.pow(Math.sin(deltaLat / 2), 2) + + Math.pow(Math.sin(deltaLng / 2), 2) * Math.cos(lat1) * Math.cos(lat2); + return radius * 2 * Math.asin(Math.sqrt(e1)); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3d788ef..309f8aa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,7 +5,7 @@ import { invalidate } from "$app/navigation"; let { data, children } = $props(); - let { session, supabase } = $derived(data); + let { session, supabase, route } = $derived(data); onMount(() => { posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", { @@ -19,6 +19,13 @@ }); return () => data.subscription.unsubscribe(); }); + $effect(() => { + if (route.id === "/filter") { + document.body.classList.add("coloured"); + } else { + document.body.classList.remove("coloured"); + } + }); @@ -33,6 +40,7 @@ margin: 0; padding: 0; width: 100%; + min-height: 100vh; } :global(html) { @@ -40,6 +48,10 @@ color: #eaffeb; } + :global(body.coloured) { + background: linear-gradient(-77deg, #2e4653, #223a37); + } + :global(*) { box-sizing: border-box; font-family: Inter; diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index cd6a8ee..3e00455 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -3,7 +3,7 @@ import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/publi import type { Database } from "$lib/database"; import type { LayoutLoad } from "./$types"; -export const load: LayoutLoad = async ({ data, depends, fetch }) => { +export const load: LayoutLoad = async ({ data, url, route, depends, fetch }) => { /** * Declare a dependency so the layout can be invalidated, for example, on * session refresh. @@ -40,5 +40,12 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => { data: { user } } = await supabase.auth.getUser(); - return { session, supabase, user, adminMode: data.adminMode }; + return { + session, + supabase, + user, + adminMode: data.adminMode, + route, + searchParams: url.searchParams.toString() + }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9db01ea..921b00e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,9 +2,12 @@ import SpaceCard from "$lib/components/SpaceCard.svelte"; import defaultImg from "$lib/assets/study_space.png"; import crossUrl from "$lib/assets/cross.svg"; + import searchUrl from "$lib/assets/search.svg"; import Navbar from "$lib/components/Navbar.svelte"; - import { allTags, volumeTags, wifiTags, powerOutletTags } from "$lib"; + import { haversineDistance, timeToMins } from "$lib"; import Button from "$lib/components/Button.svelte"; + import { urldecodeSortFilter } from "$lib/filter.js"; + import { invalidateAll } from "$app/navigation"; const { data } = $props(); const { @@ -12,22 +15,18 @@ supabase, session, adminMode, + searchParams, favouriteIds: initialFavourites = [] } = $derived(data); - let selectedTags = $state([]); - let tagFilter = $state(""); - let tagFilterElem = $state(); - - let openingFilter = $state(""); - let closingFilter = $state(""); - let favouriteIds = $derived(initialFavourites); let showFavourites = $state(false); - function categorySelected(category: string[]) { - return category.some((tag) => selectedTags.includes(tag)); - } + const sortFilter = $derived(urldecodeSortFilter(searchParams)); + const selectedTags = $derived(sortFilter.tags ?? []); + const openingFilter = $derived(sortFilter.openAt?.from); + const closingFilter = $derived(sortFilter.openAt?.to); + const sortNear = $derived(sortFilter.nearby); // Toggle a space in/out of favourites async function handleToggleFavourite(id: string) { @@ -47,28 +46,6 @@ } } - let filteredTags = $derived( - allTags - .filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase())) - .filter((tag) => !selectedTags.includes(tag)) - .filter((tag) => { - if (selectedTags.includes(tag)) return false; - - if (categorySelected(volumeTags) && volumeTags.includes(tag)) return false; - if (categorySelected(wifiTags) && wifiTags.includes(tag)) return false; - if (categorySelected(powerOutletTags) && powerOutletTags.includes(tag)) - return false; - - return true; - }) - ); - - // Convert "HH:MM" or "HH:MM:SS" to minutes since midnight - function toMinutes(timeStr: string): number { - const [h, m] = timeStr.slice(0, 5).split(":").map(Number); - return h * 60 + m; - } - // Combine tag and time filtering let filteredStudySpaces = $derived( studySpaces @@ -93,12 +70,12 @@ ); if (!entry) return false; if (entry.open_today_status) return true; - const openMin = toMinutes(entry.opens_at); - let closeMin = toMinutes(entry.closes_at); + const openMin = timeToMins(entry.opens_at); + let closeMin = timeToMins(entry.closes_at); // Treat midnight as end of day and handle overnight spans if (closeMin === 0) closeMin = 24 * 60; if (closeMin <= openMin) closeMin += 24 * 60; - const filterMin = toMinutes(openingFilter); + const filterMin = timeToMins(openingFilter); // Include spaces open at the filter time return filterMin >= openMin && filterMin < closeMin; }) @@ -110,33 +87,42 @@ ); if (!entry) return false; if (entry.open_today_status) return true; - const openMin = toMinutes(entry.opens_at); - let closeMin = toMinutes(entry.closes_at); + const openMin = timeToMins(entry.opens_at); + let closeMin = timeToMins(entry.closes_at); if (closeMin === 0) closeMin = 24 * 60; if (closeMin <= openMin) closeMin += 24 * 60; const filterMin = - toMinutes(closingFilter) === 0 ? 24 * 60 : toMinutes(closingFilter); + timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter); // Include spaces still open at the filter time return filterMin > openMin && filterMin <= closeMin; }) ); - let dropdownVisible = $state(false); - - function deleteTag(tagName: string) { - return () => { - selectedTags = selectedTags.filter((tag) => tag !== tagName); - }; - } - - function addTag(tagName: string) { - return () => { - if (!selectedTags.includes(tagName)) { - selectedTags.push(tagName); - } - tagFilter = ""; - }; - } + const sortedStudySpaces = $derived( + sortNear + ? filteredStudySpaces.toSorted((a, b) => { + if (!sortNear) return 0; + type DBLatLng = { lat: number; lng: number } | undefined; + const aLoc = a.building_location as unknown as google.maps.places.PlaceResult; + const bLoc = b.building_location as unknown as google.maps.places.PlaceResult; + const aLatLng = aLoc.geometry?.location as DBLatLng; + const bLatLng = bLoc.geometry?.location as DBLatLng; + const aDistance = haversineDistance( + sortNear.lat, + sortNear.lng, + aLatLng?.lat || sortNear.lat, + aLatLng?.lng || sortNear.lng + ); + const bDistance = haversineDistance( + sortNear.lat, + sortNear.lng, + bLatLng?.lat || sortNear.lat, + bLatLng?.lng || sortNear.lng + ); + return aDistance - bDistance; + }) + : filteredStudySpaces + ); @@ -148,6 +134,13 @@ {showFavourites ? "All spaces" : "My favourites"} {/if} + + + + Search + + + @@ -156,69 +149,7 @@ Check Reports {/if} - - - Open from: - - - - Open until: - - - - - - - {#each selectedTags as tagName (tagName)} - - {tagName} - - {/each} - { - dropdownVisible = true; - }} - onblur={() => { - dropdownVisible = false; - }} - onkeypress={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - const tag = filteredTags[0]; - if (tag) addTag(tag)(); - } - }} - placeholder="Search by tags..." - /> - {#if dropdownVisible} - - {#each filteredTags as avaliableTag (avaliableTag)} - { - // Keep input focused - e.preventDefault(); - e.stopPropagation(); - }} - type="button" - > - {avaliableTag} - - {/each} - - {/if} - - - - - {#each filteredStudySpaces as studySpace (studySpace.id)} + {#each sortedStudySpaces as studySpace (studySpace.id)} {@const imgUrl = studySpace.study_space_images.length > 0 ? supabase.storage @@ -239,7 +170,12 @@