feat: map sorting and separate filter page #53
3
src/lib/assets/search.svg
Normal file
3
src/lib/assets/search.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42 42L33.3 33.3M38 22C38 30.8366 30.8366 38 22 38C13.1634 38 6 30.8366 6 22C6 13.1634 13.1634 6 22 6C30.8366 6 38 13.1634 38 22Z" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
46
src/lib/filter.ts
Normal file
46
src/lib/filter.ts
Normal file
@@ -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<SortFiler>): 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<SortFiler> {
|
||||
const params = new URLSearchParams(query);
|
||||
const filter: Partial<SortFiler> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
let tagFilter = $state("");
|
||||
let tagFilterElem = $state<HTMLInputElement>();
|
||||
|
||||
let openingFilter = $state("");
|
||||
let closingFilter = $state("");
|
||||
|
||||
let favouriteIds = $derived<string[]>(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
|
||||
);
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
@@ -148,6 +134,13 @@
|
||||
{showFavourites ? "All spaces" : "My favourites"}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="filterWrapper">
|
||||
<Button type="link" href="/filter?{searchParams}">
|
||||
<span class="search">
|
||||
<img src={searchUrl} alt="search" /> Search
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Navbar>
|
||||
|
||||
<main>
|
||||
@@ -156,69 +149,7 @@
|
||||
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="time-filter-container">
|
||||
<label>
|
||||
Open from:
|
||||
<input type="time" bind:value={openingFilter} />
|
||||
</label>
|
||||
<label>
|
||||
Open until:
|
||||
<input type="time" bind:value={closingFilter} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tag-filter-container">
|
||||
<form>
|
||||
<div class="tagDisplay">
|
||||
{#each selectedTags as tagName (tagName)}
|
||||
<button class="tag" onclick={deleteTag(tagName)} type="button">
|
||||
{tagName}
|
||||
<img src={crossUrl} alt="delete" /></button
|
||||
>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
name="tagInput"
|
||||
class="tagInput"
|
||||
bind:value={tagFilter}
|
||||
bind:this={tagFilterElem}
|
||||
onfocus={() => {
|
||||
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}
|
||||
<div class="tagDropdown">
|
||||
{#each filteredTags as avaliableTag (avaliableTag)}
|
||||
<button
|
||||
class="avaliableTag"
|
||||
onclick={addTag(avaliableTag)}
|
||||
onmousedown={(e) => {
|
||||
// Keep input focused
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{avaliableTag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#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 @@
|
||||
|
||||
<footer>
|
||||
{#if session}
|
||||
<Button onclick={() => supabase.auth.signOut()}>Signout</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await supabase.auth.signOut();
|
||||
invalidateAll();
|
||||
}}>Signout</Button
|
||||
>
|
||||
{:else}
|
||||
<Button href="/auth" type="link">Login / Signup</Button>
|
||||
{/if}
|
||||
@@ -252,7 +188,7 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 600px;
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -270,120 +206,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;
|
||||
}
|
||||
.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;
|
||||
@@ -403,6 +225,26 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
348
src/routes/filter/+page.svelte
Normal file
348
src/routes/filter/+page.svelte
Normal file
@@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
import { allTags, volumeTags, wifiTags, powerOutletTags, gmapsLoader } from "$lib";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import { urldecodeSortFilter, urlencodeSortFilter, type SortFiler } from "$lib/filter.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { searchParams } = $derived(data);
|
||||
const sortFilter = $derived(urldecodeSortFilter(searchParams));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const openAt = $state(sortFilter.openAt ?? ({} as Partial<SortFiler["openAt"]>));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let selectedTags = $state(sortFilter.tags ?? ([] as SortFiler["tags"]));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let sortNear = $state(sortFilter.nearby ?? undefined);
|
||||
|
||||
let tagFilter = $state("");
|
||||
|
||||
const newSearchParams = $derived(
|
||||
urlencodeSortFilter({
|
||||
openAt: openAt?.from ? (openAt as { from: string; to?: string }) : undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
nearby: sortNear
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
||||
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<HTMLDivElement>();
|
||||
let marker = $state<google.maps.marker.AdvancedMarkerElement>();
|
||||
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;
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (marker) {
|
||||
marker.position = sortNear;
|
||||
}
|
||||
});
|
||||
|
||||
function categorySelected(category: string[]) {
|
||||
return category.some((tag) => selectedTags.includes(tag));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
<a href="/?{searchParams}">
|
||||
<img src={crossUrl} alt="close" />
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
<main>
|
||||
<div class="controls">
|
||||
<Button type="link" href="/?{newSearchParams}">Show study spaces</Button>
|
||||
<Button
|
||||
style="red"
|
||||
onclick={() => {
|
||||
openAt.from = undefined;
|
||||
openAt.to = undefined;
|
||||
selectedTags = [];
|
||||
sortNear = undefined;
|
||||
tagFilter = "";
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<h1>Search options</h1>
|
||||
<div class="time-filter-container">
|
||||
<label>
|
||||
Open from
|
||||
<input type="time" bind:value={openAt.from} />
|
||||
</label>
|
||||
<label>
|
||||
until
|
||||
<input type="time" bind:value={openAt.to} />
|
||||
</label>
|
||||
<span class="setToNow">
|
||||
<Button
|
||||
onclick={() => {
|
||||
const now = new Date();
|
||||
openAt.from = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
|
||||
openAt.to = undefined;
|
||||
console.log(openAt);
|
||||
}}
|
||||
>
|
||||
Set to now
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tag-filter-container">
|
||||
<div class="tagDisplay">
|
||||
{#each selectedTags as tagName, idx (tagName + idx)}
|
||||
<button class="tag" onclick={deleteTag(tagName)} type="button">
|
||||
{tagName}
|
||||
<img src={crossUrl} alt="delete" /></button
|
||||
>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
name="tagInput"
|
||||
class="tagInput"
|
||||
bind:value={tagFilter}
|
||||
onfocus={() => {
|
||||
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}
|
||||
<div class="tagDropdown">
|
||||
{#each filteredTags as avaliableTag, idx (avaliableTag + idx)}
|
||||
<button
|
||||
class="avaliableTag"
|
||||
onclick={addTag(avaliableTag)}
|
||||
onmousedown={(e) => {
|
||||
// Keep input focused
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{avaliableTag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="location-filter-container">
|
||||
<h3 class="location-filter-title">Click to search nearby</h3>
|
||||
<Button
|
||||
onclick={() => {
|
||||
navigator.geolocation.getCurrentPosition((position) => {
|
||||
if (marker)
|
||||
sortNear = marker.position = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
Use current location
|
||||
</Button>
|
||||
</div>
|
||||
<div class="sortMap" bind:this={sortMapElem}></div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 32rem;
|
||||
margin: auto;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.tag-filter-container {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.time-filter-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.location-filter-container {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
.setToNow {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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%);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.location-filter-title {
|
||||
flex: 1;
|
||||
}
|
||||
.sortMap {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -137,7 +137,7 @@
|
||||
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
||||
{#if space.tags.length > 0}
|
||||
<div class="tagContainer">
|
||||
{#each space.tags as tag (tag)}
|
||||
{#each space.tags as tag, idx (tag + idx)}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
</span>
|
||||
@@ -172,7 +172,7 @@
|
||||
{#if place.name}
|
||||
{place.name} <br />
|
||||
{/if}
|
||||
{#each place.formatted_address?.split(",") || [] as line (line)}
|
||||
{#each place.formatted_address?.split(",") || [] as line, idx (line + idx)}
|
||||
{line.trim()} <br />
|
||||
{/each}
|
||||
</p>
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
</div>
|
||||
<label for="openingTimes">Opening times (Optional):</label>
|
||||
<div class="allDaysTiming">
|
||||
{#each studySpaceData.opening_times as opening_time, index (index)}
|
||||
{#each studySpaceData.opening_times as opening_time, index (opening_time)}
|
||||
<OpeningTimesDay
|
||||
{index}
|
||||
bind:openingValue={opening_time.opens_at}
|
||||
|
||||
Reference in New Issue
Block a user