330 lines
11 KiB
Svelte
330 lines
11 KiB
Svelte
<script lang="ts">
|
|
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 { collectTimings, timeToMins, haversineDistance } from "$lib";
|
|
import Button from "$lib/components/Button.svelte";
|
|
import { urldecodeSortFilter } from "$lib/filter.js";
|
|
import { invalidateAll } from "$app/navigation";
|
|
import type { Table } from "$lib";
|
|
|
|
const { data } = $props();
|
|
const {
|
|
studySpaces,
|
|
supabase,
|
|
session,
|
|
adminMode,
|
|
searchParams,
|
|
favouriteIds: initialFavourites = []
|
|
} = $derived(data);
|
|
|
|
let favouriteIds = $derived<string[]>(initialFavourites);
|
|
let showFavourites = $state(false);
|
|
|
|
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) {
|
|
if (!session?.user) return;
|
|
const already = favouriteIds.includes(id);
|
|
if (already) {
|
|
await supabase
|
|
.from("favourite_study_spaces")
|
|
.delete()
|
|
.match({ user_id: session.user.id, study_space_id: id });
|
|
favouriteIds = favouriteIds.filter((x) => x !== id);
|
|
} else {
|
|
await supabase
|
|
.from("favourite_study_spaces")
|
|
.insert([{ user_id: session.user.id, study_space_id: id }]);
|
|
favouriteIds = [...favouriteIds, id];
|
|
}
|
|
}
|
|
|
|
// Combine tag and time filtering
|
|
let filteredStudySpaces = $derived(
|
|
studySpaces
|
|
// only include favourites when showFavourites===true
|
|
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
|
|
// tag filtering
|
|
.filter((space) => {
|
|
if (selectedTags.length === 0) return true;
|
|
const allTags = [
|
|
...(space.tags || []),
|
|
space.volume,
|
|
space.wifi,
|
|
space.power
|
|
].filter(Boolean);
|
|
return selectedTags.every((tag) => allTags.includes(tag));
|
|
})
|
|
// opening time filter
|
|
.filter((space) => {
|
|
if (!openingFilter) return true;
|
|
const entry = space.study_space_hours?.find(
|
|
(h) => h.day_of_week === new Date().getDay()
|
|
);
|
|
if (!entry) return false;
|
|
if (entry.open_today_status) return true;
|
|
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 = timeToMins(openingFilter);
|
|
// Include spaces open at the filter time
|
|
return filterMin >= openMin && filterMin < closeMin;
|
|
})
|
|
// closing time filter
|
|
.filter((space) => {
|
|
if (!closingFilter) return true;
|
|
const entry = space.study_space_hours?.find(
|
|
(h) => h.day_of_week === new Date().getDay()
|
|
);
|
|
if (!entry) return false;
|
|
if (entry.open_today_status) return true;
|
|
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 =
|
|
timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
|
|
// Include spaces still open at the filter time
|
|
return filterMin > openMin && filterMin <= closeMin;
|
|
})
|
|
);
|
|
|
|
const sortedByOpenNow = $derived(
|
|
filteredStudySpaces.toSorted((a, b) => {
|
|
const now = new Date();
|
|
const time = now.toTimeString().slice(0, 5);
|
|
const today = now.getDay();
|
|
let openUntil = [0, 0] as number[];
|
|
for (const [index, day] of [a, b].entries()) {
|
|
const timingsPerDay = collectTimings(day.study_space_hours);
|
|
for (const timing of timingsPerDay[today]) {
|
|
if (timing.open_today_status === true) {
|
|
openUntil[index] = 24 * 60;
|
|
break;
|
|
} else if (timing.open_today_status === false) {
|
|
break;
|
|
} else {
|
|
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
|
|
if (opensFor) {
|
|
openUntil[index] = opensFor;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return openUntil[1] - openUntil[0];
|
|
})
|
|
);
|
|
|
|
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;
|
|
})
|
|
: sortedByOpenNow
|
|
);
|
|
|
|
// Open now
|
|
function isOpenNow(all_study_space_hours: Table<"study_space_hours">[]) {
|
|
const now = new Date();
|
|
const time = now.toTimeString().slice(0, 5);
|
|
const day = now.getDay();
|
|
|
|
const timingsPerDay = collectTimings(all_study_space_hours);
|
|
for (const timing of timingsPerDay[day]) {
|
|
if (timing.open_today_status === true) {
|
|
return { isOpen: true, message: `Open all day` };
|
|
} else if (timing.open_today_status === false) {
|
|
return { isOpen: false, message: `Closed today` };
|
|
} else {
|
|
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
|
|
if (opensFor) {
|
|
return {
|
|
isOpen: true,
|
|
message: `Open now for: ${minsToReadableHours(opensFor)}`
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { isOpen: false, message: "Closed right now" };
|
|
}
|
|
|
|
function timeUntilClosing(openingTime: string, closingTime: string, currentTime: string) {
|
|
const currTimeInMins = timeToMins(currentTime);
|
|
const OpeningTimeInMins = timeToMins(openingTime);
|
|
const closingTimeInMins = timeToMins(closingTime);
|
|
if (currTimeInMins >= OpeningTimeInMins && currTimeInMins < closingTimeInMins) {
|
|
return closingTimeInMins - currTimeInMins;
|
|
}
|
|
}
|
|
|
|
function minsToReadableHours(mins: number) {
|
|
return `${Math.floor(mins / 60)} hrs, ${mins % 60} mins`;
|
|
}
|
|
</script>
|
|
|
|
<Navbar>
|
|
{#if session}
|
|
<a href="/space/new/edit">
|
|
<img src={crossUrl} alt="new" class="new-space" />
|
|
</a>
|
|
{/if}
|
|
{#if adminMode}
|
|
<span class="checkReports">
|
|
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
|
|
</span>
|
|
{/if}
|
|
{#if session}
|
|
<button class="fav-button" onclick={() => (showFavourites = !showFavourites)} type="button">
|
|
{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>
|
|
{#each sortedStudySpaces as studySpace (studySpace.id)}
|
|
{@const imgUrl =
|
|
studySpace.study_space_images.length > 0
|
|
? supabase.storage
|
|
.from("files_bucket")
|
|
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
|
|
: defaultImg}
|
|
<SpaceCard
|
|
alt="Photo of {studySpace.description}"
|
|
href="/space/{studySpace.id}"
|
|
imgSrc={imgUrl}
|
|
space={studySpace}
|
|
isFavourite={favouriteIds.includes(studySpace.id)}
|
|
onToggleFavourite={session ? () => handleToggleFavourite(studySpace.id) : undefined}
|
|
isAvailable={studySpace.study_space_hours.length === 0
|
|
? undefined
|
|
: isOpenNow(studySpace.study_space_hours).isOpen}
|
|
footer={studySpace.study_space_hours.length === 0
|
|
? undefined
|
|
: isOpenNow(studySpace.study_space_hours).message}
|
|
/>
|
|
{/each}
|
|
</main>
|
|
|
|
<footer>
|
|
{#if session}
|
|
<Button
|
|
onclick={async () => {
|
|
await supabase.auth.signOut();
|
|
invalidateAll();
|
|
}}>Signout</Button
|
|
>
|
|
{:else}
|
|
<Button href="/auth" type="link">Login / Signup</Button>
|
|
{/if}
|
|
</footer>
|
|
|
|
<style>
|
|
main {
|
|
display: grid;
|
|
box-sizing: border-box;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem;
|
|
max-width: 32rem;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
footer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 600px;
|
|
width: 100%;
|
|
padding: 1rem;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.new-space {
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
.checkReports {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 1rem;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.fav-button {
|
|
background: none;
|
|
border: none;
|
|
color: #eaffeb;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
}
|
|
.fav-button:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.filterWrapper {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
color: #eaffeb;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.search img {
|
|
width: 1.2rem;
|
|
height: 1.2rem;
|
|
}
|
|
|
|
@media (max-width: 20rem) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|