Merge branch 'map-sorting' into 'master'
feat: map sorting and separate filter page See merge request gk1623/drp-48!23
This commit is contained in:
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"
|
"All Other Days"
|
||||||
];
|
];
|
||||||
|
|
||||||
export function timeToMins(time: string) {
|
// Convert "HH:MM" or "HH:MM:SS" to minutes since midnight
|
||||||
const [hour, min] = time.split(":");
|
export function timeToMins(timeStr: string): number {
|
||||||
return Number(hour) * 60 + Number(min);
|
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";
|
import { invalidate } from "$app/navigation";
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let { session, supabase } = $derived(data);
|
let { session, supabase, route } = $derived(data);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
|
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
|
||||||
@@ -19,6 +19,13 @@
|
|||||||
});
|
});
|
||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (route.id === "/filter") {
|
||||||
|
document.body.classList.add("coloured");
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove("coloured");
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -33,6 +40,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html) {
|
:global(html) {
|
||||||
@@ -40,6 +48,10 @@
|
|||||||
color: #eaffeb;
|
color: #eaffeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(body.coloured) {
|
||||||
|
background: linear-gradient(-77deg, #2e4653, #223a37);
|
||||||
|
}
|
||||||
|
|
||||||
:global(*) {
|
:global(*) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: Inter;
|
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 { Database } from "$lib/database";
|
||||||
import type { LayoutLoad } from "./$types";
|
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
|
* Declare a dependency so the layout can be invalidated, for example, on
|
||||||
* session refresh.
|
* session refresh.
|
||||||
@@ -40,5 +40,12 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
|
|||||||
data: { user }
|
data: { user }
|
||||||
} = await supabase.auth.getUser();
|
} = 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 SpaceCard from "$lib/components/SpaceCard.svelte";
|
||||||
import defaultImg from "$lib/assets/study_space.png";
|
import defaultImg from "$lib/assets/study_space.png";
|
||||||
import crossUrl from "$lib/assets/cross.svg";
|
import crossUrl from "$lib/assets/cross.svg";
|
||||||
|
import searchUrl from "$lib/assets/search.svg";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
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 Button from "$lib/components/Button.svelte";
|
||||||
|
import { urldecodeSortFilter } from "$lib/filter.js";
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const {
|
const {
|
||||||
@@ -12,22 +15,18 @@
|
|||||||
supabase,
|
supabase,
|
||||||
session,
|
session,
|
||||||
adminMode,
|
adminMode,
|
||||||
|
searchParams,
|
||||||
favouriteIds: initialFavourites = []
|
favouriteIds: initialFavourites = []
|
||||||
} = $derived(data);
|
} = $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 favouriteIds = $derived<string[]>(initialFavourites);
|
||||||
let showFavourites = $state(false);
|
let showFavourites = $state(false);
|
||||||
|
|
||||||
function categorySelected(category: string[]) {
|
const sortFilter = $derived(urldecodeSortFilter(searchParams));
|
||||||
return category.some((tag) => selectedTags.includes(tag));
|
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
|
// Toggle a space in/out of favourites
|
||||||
async function handleToggleFavourite(id: string) {
|
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
|
// Combine tag and time filtering
|
||||||
let filteredStudySpaces = $derived(
|
let filteredStudySpaces = $derived(
|
||||||
studySpaces
|
studySpaces
|
||||||
@@ -93,12 +70,12 @@
|
|||||||
);
|
);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (entry.open_today_status) return true;
|
if (entry.open_today_status) return true;
|
||||||
const openMin = toMinutes(entry.opens_at);
|
const openMin = timeToMins(entry.opens_at);
|
||||||
let closeMin = toMinutes(entry.closes_at);
|
let closeMin = timeToMins(entry.closes_at);
|
||||||
// Treat midnight as end of day and handle overnight spans
|
// Treat midnight as end of day and handle overnight spans
|
||||||
if (closeMin === 0) closeMin = 24 * 60;
|
if (closeMin === 0) closeMin = 24 * 60;
|
||||||
if (closeMin <= openMin) closeMin += 24 * 60;
|
if (closeMin <= openMin) closeMin += 24 * 60;
|
||||||
const filterMin = toMinutes(openingFilter);
|
const filterMin = timeToMins(openingFilter);
|
||||||
// Include spaces open at the filter time
|
// Include spaces open at the filter time
|
||||||
return filterMin >= openMin && filterMin < closeMin;
|
return filterMin >= openMin && filterMin < closeMin;
|
||||||
})
|
})
|
||||||
@@ -110,33 +87,42 @@
|
|||||||
);
|
);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (entry.open_today_status) return true;
|
if (entry.open_today_status) return true;
|
||||||
const openMin = toMinutes(entry.opens_at);
|
const openMin = timeToMins(entry.opens_at);
|
||||||
let closeMin = toMinutes(entry.closes_at);
|
let closeMin = timeToMins(entry.closes_at);
|
||||||
if (closeMin === 0) closeMin = 24 * 60;
|
if (closeMin === 0) closeMin = 24 * 60;
|
||||||
if (closeMin <= openMin) closeMin += 24 * 60;
|
if (closeMin <= openMin) closeMin += 24 * 60;
|
||||||
const filterMin =
|
const filterMin =
|
||||||
toMinutes(closingFilter) === 0 ? 24 * 60 : toMinutes(closingFilter);
|
timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
|
||||||
// Include spaces still open at the filter time
|
// Include spaces still open at the filter time
|
||||||
return filterMin > openMin && filterMin <= closeMin;
|
return filterMin > openMin && filterMin <= closeMin;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let dropdownVisible = $state(false);
|
const sortedStudySpaces = $derived(
|
||||||
|
sortNear
|
||||||
function deleteTag(tagName: string) {
|
? filteredStudySpaces.toSorted((a, b) => {
|
||||||
return () => {
|
if (!sortNear) return 0;
|
||||||
selectedTags = selectedTags.filter((tag) => tag !== tagName);
|
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;
|
||||||
function addTag(tagName: string) {
|
const bLatLng = bLoc.geometry?.location as DBLatLng;
|
||||||
return () => {
|
const aDistance = haversineDistance(
|
||||||
if (!selectedTags.includes(tagName)) {
|
sortNear.lat,
|
||||||
selectedTags.push(tagName);
|
sortNear.lng,
|
||||||
}
|
aLatLng?.lat || sortNear.lat,
|
||||||
tagFilter = "";
|
aLatLng?.lng || sortNear.lng
|
||||||
};
|
);
|
||||||
}
|
const bDistance = haversineDistance(
|
||||||
|
sortNear.lat,
|
||||||
|
sortNear.lng,
|
||||||
|
bLatLng?.lat || sortNear.lat,
|
||||||
|
bLatLng?.lng || sortNear.lng
|
||||||
|
);
|
||||||
|
return aDistance - bDistance;
|
||||||
|
})
|
||||||
|
: filteredStudySpaces
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Navbar>
|
<Navbar>
|
||||||
@@ -148,6 +134,13 @@
|
|||||||
{showFavourites ? "All spaces" : "My favourites"}
|
{showFavourites ? "All spaces" : "My favourites"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="filterWrapper">
|
||||||
|
<Button type="link" href="/filter?{searchParams}">
|
||||||
|
<span class="search">
|
||||||
|
<img src={searchUrl} alt="search" /> Search
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -156,69 +149,7 @@
|
|||||||
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
|
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="time-filter-container">
|
{#each sortedStudySpaces as studySpace (studySpace.id)}
|
||||||
<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)}
|
|
||||||
{@const imgUrl =
|
{@const imgUrl =
|
||||||
studySpace.study_space_images.length > 0
|
studySpace.study_space_images.length > 0
|
||||||
? supabase.storage
|
? supabase.storage
|
||||||
@@ -239,7 +170,12 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{#if session}
|
{#if session}
|
||||||
<Button onclick={() => supabase.auth.signOut()}>Signout</Button>
|
<Button
|
||||||
|
onclick={async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
invalidateAll();
|
||||||
|
}}>Signout</Button
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<Button href="/auth" type="link">Login / Signup</Button>
|
<Button href="/auth" type="link">Login / Signup</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -252,7 +188,7 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 600px;
|
max-width: 32rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -270,120 +206,6 @@
|
|||||||
transform: rotate(45deg);
|
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 {
|
.checkReports {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -403,6 +225,26 @@
|
|||||||
text-decoration: underline;
|
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) {
|
@media (max-width: 20rem) {
|
||||||
main {
|
main {
|
||||||
grid-template-columns: 1fr;
|
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>
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
||||||
{#if space.tags.length > 0}
|
{#if space.tags.length > 0}
|
||||||
<div class="tagContainer">
|
<div class="tagContainer">
|
||||||
{#each space.tags as tag (tag)}
|
{#each space.tags as tag, idx (tag + idx)}
|
||||||
<span class="tag">
|
<span class="tag">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
{#if place.name}
|
{#if place.name}
|
||||||
{place.name} <br />
|
{place.name} <br />
|
||||||
{/if}
|
{/if}
|
||||||
{#each place.formatted_address?.split(",") || [] as line (line)}
|
{#each place.formatted_address?.split(",") || [] as line, idx (line + idx)}
|
||||||
{line.trim()} <br />
|
{line.trim()} <br />
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -370,7 +370,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label for="openingTimes">Opening times (Optional):</label>
|
<label for="openingTimes">Opening times (Optional):</label>
|
||||||
<div class="allDaysTiming">
|
<div class="allDaysTiming">
|
||||||
{#each studySpaceData.opening_times as opening_time, index (index)}
|
{#each studySpaceData.opening_times as opening_time, index (opening_time)}
|
||||||
<OpeningTimesDay
|
<OpeningTimesDay
|
||||||
{index}
|
{index}
|
||||||
bind:openingValue={opening_time.opens_at}
|
bind:openingValue={opening_time.opens_at}
|
||||||
|
|||||||
Reference in New Issue
Block a user