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

+ +
+
+
+ +