From ce6c391d8186f00a98408f0c1a9ca883cc3f24ae Mon Sep 17 00:00:00 2001 From: Gleb Koval Date: Thu, 12 Jun 2025 23:50:24 +0100 Subject: [PATCH 1/4] feat: sort by location --- src/lib/index.ts | 17 +++++ src/routes/+page.svelte | 99 ++++++++++++++++++++++++- src/routes/space/[id]/+page.svelte | 4 +- src/routes/space/[id]/edit/+page.svelte | 2 +- 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 290e2b5..eef7a5f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -66,3 +66,20 @@ export function timeToMins(time: string) { const [hour, min] = time.split(":"); return Number(hour) * 60 + Number(min); } + +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/+page.svelte b/src/routes/+page.svelte index fa3f5b8..f2e2943 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,8 +3,16 @@ import defaultImg from "$lib/assets/study_space.png"; import crossUrl from "$lib/assets/cross.svg"; import Navbar from "$lib/components/Navbar.svelte"; - import { allTags, volumeTags, wifiTags, powerOutletTags } from "$lib"; + import { + allTags, + volumeTags, + wifiTags, + powerOutletTags, + gmapsLoader, + haversineDistance + } from "$lib"; import Button from "$lib/components/Button.svelte"; + import { onMount } from "svelte"; const { data } = $props(); const { @@ -137,6 +145,60 @@ tagFilter = ""; }; } + + let sortMapElem = $state(); + let sortNear = $state<{ lat: number; lng: number }>(); + const sortedStudySpaces = $derived( + sortNear + ? studySpaces.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 + ); + let marker: google.maps.marker.AdvancedMarkerElement | undefined; + onMount(async () => { + if (!sortMapElem) return console.error("sortMapElem is not defined"); + const loader = await gmapsLoader(); + const { Map } = await loader.importLibrary("maps"); + const { AdvancedMarkerElement } = await loader.importLibrary("marker"); + const map = new Map(sortMapElem, { + center: { lat: 53.6, lng: -1.56 }, + zoom: 5, + mapId: "9f4993cd3fb1504d495821a5" + }); + marker = new AdvancedMarkerElement({ + map, + title: "Find near here" + }); + map.addListener("click", (e: google.maps.MapMouseEvent) => { + console.log("Clicked map at", e.latLng); + sortNear = e.latLng + ? { + lat: e.latLng.lat(), + lng: e.latLng.lng() + } + : sortNear; + if (marker) marker.position = e.latLng; + }); + }); @@ -169,7 +231,7 @@
- {#each selectedTags as tagName (tagName)} + {#each selectedTags as tagName, idx (tagName + idx)} {#if dropdownVisible}
- {#each filteredTags as avaliableTag (avaliableTag)} + {#each filteredTags as avaliableTag, idx (avaliableTag + idx)}
+
+

Find nearby:

+ +
+
- {#each filteredStudySpaces as studySpace (studySpace.id)} + {#each sortedStudySpaces as studySpace (studySpace.id)} {@const imgUrl = studySpace.study_space_images.length > 0 ? supabase.storage @@ -284,6 +363,13 @@ justify-content: center; margin-bottom: 0.5rem; } + .location-filter-container { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } .time-filter-container label { display: flex; align-items: center; @@ -403,6 +489,11 @@ text-decoration: underline; } + .sortMap { + aspect-ratio: 1 / 1; + width: 100%; + } + @media (max-width: 20rem) { main { grid-template-columns: 1fr; diff --git a/src/routes/space/[id]/+page.svelte b/src/routes/space/[id]/+page.svelte index 9e37048..eed68de 100644 --- a/src/routes/space/[id]/+page.svelte +++ b/src/routes/space/[id]/+page.svelte @@ -137,7 +137,7 @@
{#if space.tags.length > 0}
- {#each space.tags as tag (tag)} + {#each space.tags as tag, idx (tag + idx)} {tag} @@ -172,7 +172,7 @@ {#if place.name} {place.name}
{/if} - {#each place.formatted_address?.split(",") || [] as line (line)} + {#each place.formatted_address?.split(",") || [] as line, idx (line + idx)} {line.trim()}
{/each}

diff --git a/src/routes/space/[id]/edit/+page.svelte b/src/routes/space/[id]/edit/+page.svelte index 067053d..78d9479 100644 --- a/src/routes/space/[id]/edit/+page.svelte +++ b/src/routes/space/[id]/edit/+page.svelte @@ -370,7 +370,7 @@
- {#each studySpaceData.opening_times as opening_time, index (index)} + {#each studySpaceData.opening_times as opening_time, index (opening_time)} Date: Fri, 13 Jun 2025 13:27:51 +0100 Subject: [PATCH 2/4] feat: map sorting and separate filter --- src/lib/assets/search.svg | 3 + src/lib/filter.ts | 46 +++++ src/lib/index.ts | 7 +- src/routes/+layout.svelte | 14 +- src/routes/+layout.ts | 11 +- src/routes/+page.svelte | 338 ++++---------------------------- src/routes/filter/+page.svelte | 348 +++++++++++++++++++++++++++++++++ 7 files changed, 465 insertions(+), 302 deletions(-) create mode 100644 src/lib/assets/search.svg create mode 100644 src/lib/filter.ts create mode 100644 src/routes/filter/+page.svelte 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 eef7a5f..d9029c9 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -62,9 +62,10 @@ 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( 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 f2e2943..4110555 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,17 +2,11 @@ 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, - gmapsLoader, - haversineDistance - } from "$lib"; + import { haversineDistance, timeToMins } from "$lib"; import Button from "$lib/components/Button.svelte"; - import { onMount } from "svelte"; + import { urldecodeSortFilter } from "$lib/filter.js"; const { data } = $props(); const { @@ -20,22 +14,19 @@ 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); + $inspect(selectedTags); // Toggle a space in/out of favourites async function handleToggleFavourite(id: string) { @@ -55,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 @@ -101,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; }) @@ -118,39 +87,20 @@ ); 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 = ""; - }; - } - - let sortMapElem = $state(); - let sortNear = $state<{ lat: number; lng: number }>(); const sortedStudySpaces = $derived( sortNear - ? studySpaces.toSorted((a, b) => { + ? 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; @@ -173,32 +123,6 @@ }) : filteredStudySpaces ); - let marker: google.maps.marker.AdvancedMarkerElement | undefined; - onMount(async () => { - if (!sortMapElem) return console.error("sortMapElem is not defined"); - const loader = await gmapsLoader(); - const { Map } = await loader.importLibrary("maps"); - const { AdvancedMarkerElement } = await loader.importLibrary("marker"); - const map = new Map(sortMapElem, { - center: { lat: 53.6, lng: -1.56 }, - zoom: 5, - mapId: "9f4993cd3fb1504d495821a5" - }); - marker = new AdvancedMarkerElement({ - map, - title: "Find near here" - }); - map.addListener("click", (e: google.maps.MapMouseEvent) => { - console.log("Clicked map at", e.latLng); - sortNear = e.latLng - ? { - lat: e.latLng.lat(), - lng: e.latLng.lng() - } - : sortNear; - if (marker) marker.position = e.latLng; - }); - }); @@ -210,6 +134,13 @@ {showFavourites ? "All spaces" : "My favourites"} {/if} +
+ +
@@ -218,85 +149,6 @@
{/if} -
- - -
-
-
-
- {#each selectedTags as tagName, idx (tagName + idx)} - - {/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, idx (avaliableTag + idx)} - - {/each} -
- {/if} -
-
-
-
-

Find nearby:

- -
-
- {#each sortedStudySpaces as studySpace (studySpace.id)} {@const imgUrl = studySpace.study_space_images.length > 0 @@ -349,127 +201,6 @@ transform: rotate(45deg); } - .tag-filter-container { - grid-column: 1 / -1; - display: flex; - justify-content: center; - margin-bottom: 0.5rem; - } - - .time-filter-container { - grid-column: 1 / -1; - display: flex; - gap: 1rem; - justify-content: center; - margin-bottom: 0.5rem; - } - .location-filter-container { - grid-column: 1 / -1; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - } - .time-filter-container label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1rem; - color: #eaffeb; - } - .time-filter-container input[type="time"] { - background: none; - border: 2px solid #eaffeb; - border-radius: 0.5rem; - padding: 0.25rem 0.5rem; - color: #eaffeb; - } - - form { - display: flex; - flex-direction: column; - gap: 0.5rem; - max-width: 32rem; - } - - .tagDisplay { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - align-items: left; - justify-content: left; - position: relative; - width: 100%; - height: auto; - padding: 0.5rem; - border-radius: 0.5rem; - border: 2px solid #eaffeb; - background: none; - color: #eaffeb; - font-size: 1rem; - } - .tagInput { - flex: 1 1 100%; - min-width: 10rem; - background: none; - color: #eaffeb; - font-size: 1rem; - border: none; - outline: none; - } - ::placeholder { - color: #859a90; - opacity: 1; - } - .tag { - display: flex; - align-items: center; - border-radius: 0.25rem; - background-color: #2e4653; - color: #eaffeb; - font-size: 0.9rem; - cursor: pointer; - border-width: 0rem; - } - .tag img { - width: 1rem; - height: 1rem; - margin-left: 0.2rem; - } - - .tagDropdown { - width: 100%; - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - position: absolute; - background-color: #2e4653; - box-shadow: 1px 1px 0.5rem rgba(0, 0, 0, 0.5); - border-radius: 0.5rem; - overflow-y: auto; - max-height: 10rem; - top: 100%; - left: 50%; - transform: translateX(-50%); - } - - .avaliableTag { - width: 100%; - text-align: left; - background: none; - border: none; - color: #eaffeb; - font-size: 0.9rem; - margin: 0%; - padding: 0 0.8rem 0.4rem; - } - .avaliableTag:first-child { - padding-top: 0.6rem; - background-color: hsl(201, 26%, 60%); - } - .avaliableTag:last-child { - padding-bottom: 0.6rem; - } .checkReports { grid-column: 1 / -1; display: flex; @@ -489,9 +220,24 @@ text-decoration: underline; } - .sortMap { - aspect-ratio: 1 / 1; - width: 100%; + .filterWrapper { + display: flex; + justify-content: center; + align-items: center; + margin-right: 2rem; + } + + .search { + display: flex; + align-items: center; + gap: 0.5rem; + color: #eaffeb; + font-size: 1rem; + } + + .search img { + width: 1.2rem; + height: 1.2rem; } @media (max-width: 20rem) { diff --git a/src/routes/filter/+page.svelte b/src/routes/filter/+page.svelte new file mode 100644 index 0000000..14881a8 --- /dev/null +++ b/src/routes/filter/+page.svelte @@ -0,0 +1,348 @@ + + + + + close + + + +
+
+ + +
+

Search options

+
+ + + + + +
+
+
+ {#each selectedTags as tagName, idx (tagName + idx)} + + {/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, idx (avaliableTag + idx)} + + {/each} +
+ {/if} +
+
+
+

Click to search nearby

+ +
+
+
+ + From 95c38c6f9fa2d826c03317eb44c5e91c132cf4f5 Mon Sep 17 00:00:00 2001 From: Gleb Koval Date: Fri, 13 Jun 2025 13:32:54 +0100 Subject: [PATCH 3/4] fix: signout without refresh --- src/routes/+page.svelte | 10 ++++++++-- src/routes/filter/+page.svelte | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4110555..dca84a5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,6 +7,7 @@ import { haversineDistance, timeToMins } from "$lib"; import Button from "$lib/components/Button.svelte"; import { urldecodeSortFilter } from "$lib/filter.js"; + import { invalidate, invalidateAll } from "$app/navigation"; const { data } = $props(); const { @@ -170,7 +171,12 @@