1 Commits

Author SHA1 Message Date
Temesgen, Tadios
49a1e93dfe Merge branch 'master' into 'feat/filter-by-time'
# Conflicts:
#   src/lib/database.d.ts
#   src/routes/+page.svelte
#   src/routes/space/[id]/+page.svelte
2025-06-12 14:54:04 +00:00
38 changed files with 479 additions and 1528 deletions

View File

@@ -12,6 +12,6 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents; background: inherit">%sveltekit.body%</div>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,7 +1,7 @@
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_117_282)">
<path d="M36.4168 30.0833C36.4168 30.9232 36.0832 31.7286 35.4893 32.3225C34.8955 32.9164 34.09 33.25 33.2502 33.25H4.75016C3.91031 33.25 3.10486 32.9164 2.51099 32.3225C1.91713 31.7286 1.5835 30.9232 1.5835 30.0833V12.6667C1.5835 11.8268 1.91713 11.0214 2.51099 10.4275C3.10486 9.83363 3.91031 9.5 4.75016 9.5H11.0835L14.2502 4.75H23.7502L26.9168 9.5H33.2502C34.09 9.5 34.8955 9.83363 35.4893 10.4275C36.0832 11.0214 36.4168 11.8268 36.4168 12.6667V30.0833Z" stroke="#189f5e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.0002 26.9167C22.498 26.9167 25.3335 24.0811 25.3335 20.5833C25.3335 17.0855 22.498 14.25 19.0002 14.25C15.5024 14.25 12.6668 17.0855 12.6668 20.5833C12.6668 24.0811 15.5024 26.9167 19.0002 26.9167Z" stroke="#189f5e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.4168 30.0833C36.4168 30.9232 36.0832 31.7286 35.4893 32.3225C34.8955 32.9164 34.09 33.25 33.2502 33.25H4.75016C3.91031 33.25 3.10486 32.9164 2.51099 32.3225C1.91713 31.7286 1.5835 30.9232 1.5835 30.0833V12.6667C1.5835 11.8268 1.91713 11.0214 2.51099 10.4275C3.10486 9.83363 3.91031 9.5 4.75016 9.5H11.0835L14.2502 4.75H23.7502L26.9168 9.5H33.2502C34.09 9.5 34.8955 9.83363 35.4893 10.4275C36.0832 11.0214 36.4168 11.8268 36.4168 12.6667V30.0833Z" stroke="#49BD85" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.0002 26.9167C22.498 26.9167 25.3335 24.0811 25.3335 20.5833C25.3335 17.0855 22.498 14.25 19.0002 14.25C15.5024 14.25 12.6668 17.0855 12.6668 20.5833C12.6668 24.0811 15.5024 26.9167 19.0002 26.9167Z" stroke="#49BD85" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_117_282">

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="131" height="113" viewBox="0 0 131 113" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 38.2831C0 70.0945 26.2938 87.0466 45.5413 102.22C52.3333 107.574 58.875 112.615 65.4167 112.615C71.9583 112.615 78.5 107.574 85.2922 102.22C104.54 87.0466 130.833 70.0945 130.833 38.2831C130.833 6.47134 94.8529 -16.0889 65.4167 14.4945C35.9802 -16.0889 0 6.47134 0 38.2831Z" fill="#FF6060"/>
</svg>

Before

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,8 +1,8 @@
<svg width="279" height="279" viewBox="0 0 279 279" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_194_118)">
<path d="M-6 20H216.872L269 70H-6V20Z" fill="#177665"/>
<path d="M-19 -12H84.9818L114 23H-19V-12Z" fill="#177665"/>
<path d="M249.467 33L281 63V283L-9 280V33H249.467Z" fill="#189f5e"/>
<path d="M-6 20H216.872L269 70H-6V20Z" fill="#53988B"/>
<path d="M-19 -12H84.9818L114 23H-19V-12Z" fill="#53988B"/>
<path d="M249.467 33L281 63V283L-9 280V33H249.467Z" fill="#49BD85"/>
<path d="M35.0419 177.732C34.843 175.727 33.9896 174.169 32.4815 173.059C30.9735 171.949 28.9268 171.393 26.3416 171.393C24.585 171.393 23.1018 171.642 21.892 172.139C20.6823 172.62 19.7543 173.291 19.108 174.153C18.4782 175.014 18.1634 175.992 18.1634 177.086C18.1302 177.997 18.3208 178.793 18.7351 179.472C19.166 180.152 19.7543 180.74 20.5 181.237C21.2457 181.718 22.1075 182.14 23.0852 182.505C24.063 182.853 25.107 183.151 26.2173 183.4L30.7912 184.494C33.0118 184.991 35.0502 185.654 36.9062 186.482C38.7623 187.311 40.3698 188.33 41.7287 189.54C43.0876 190.75 44.1399 192.175 44.8857 193.815C45.648 195.456 46.0374 197.337 46.054 199.458C46.0374 202.574 45.242 205.275 43.6676 207.562C42.1099 209.832 39.8561 211.597 36.9062 212.857C33.973 214.099 30.4349 214.721 26.2919 214.721C22.1821 214.721 18.6025 214.091 15.5533 212.832C12.5206 211.572 10.1508 209.708 8.44389 207.239C6.75355 204.753 5.86695 201.679 5.78409 198.016H16.1996C16.3156 199.723 16.8045 201.148 17.6662 202.292C18.5445 203.419 19.7128 204.272 21.1712 204.852C22.6461 205.416 24.3116 205.697 26.1676 205.697C27.9905 205.697 29.5732 205.432 30.9155 204.902C32.2744 204.372 33.3267 203.634 34.0724 202.69C34.8182 201.745 35.1911 200.66 35.1911 199.433C35.1911 198.29 34.8513 197.329 34.1719 196.55C33.509 195.771 32.5313 195.108 31.2386 194.561C29.9626 194.014 28.3965 193.517 26.5405 193.07L20.9972 191.678C16.705 190.634 13.3161 189.001 10.8303 186.781C8.34446 184.56 7.10985 181.569 7.12642 177.807C7.10985 174.724 7.93016 172.031 9.58736 169.728C11.2611 167.424 13.5563 165.626 16.473 164.334C19.3897 163.041 22.7041 162.395 26.4162 162.395C30.1946 162.395 33.4924 163.041 36.3097 164.334C39.1435 165.626 41.3475 167.424 42.9219 169.728C44.4962 172.031 45.3082 174.7 45.358 177.732H35.0419ZM51.3549 171.965V163.091H93.166V171.965H77.5801V214H66.9409V171.965H51.3549ZM131.497 163.091H142.26V196.152C142.26 199.864 141.374 203.112 139.601 205.896C137.844 208.68 135.383 210.851 132.218 212.409C129.053 213.95 125.365 214.721 121.156 214.721C116.93 214.721 113.235 213.95 110.069 212.409C106.904 210.851 104.443 208.68 102.687 205.896C100.93 203.112 100.052 199.864 100.052 196.152V163.091H110.815V195.232C110.815 197.171 111.238 198.895 112.083 200.403C112.945 201.911 114.154 203.096 115.712 203.957C117.27 204.819 119.085 205.25 121.156 205.25C123.244 205.25 125.059 204.819 126.6 203.957C128.158 203.096 129.359 201.911 130.204 200.403C131.066 198.895 131.497 197.171 131.497 195.232V163.091ZM169.163 214H151.116V163.091H169.312C174.433 163.091 178.841 164.11 182.537 166.148C186.232 168.17 189.074 171.079 191.063 174.874C193.068 178.669 194.071 183.209 194.071 188.496C194.071 193.799 193.068 198.356 191.063 202.168C189.074 205.979 186.216 208.904 182.487 210.942C178.775 212.981 174.333 214 169.163 214ZM161.88 204.778H168.716C171.897 204.778 174.574 204.214 176.745 203.087C178.932 201.944 180.573 200.179 181.667 197.793C182.777 195.39 183.332 192.291 183.332 188.496C183.332 184.734 182.777 181.66 181.667 179.273C180.573 176.887 178.94 175.13 176.77 174.004C174.599 172.877 171.922 172.313 168.74 172.313H161.88V204.778ZM195.985 163.091H208.041L219.65 185.016H220.147L231.755 163.091H243.811L225.243 196.003V214H214.554V196.003L195.985 163.091Z" fill="white"/>
<path d="M66.0419 233.732C65.843 231.727 64.9896 230.169 63.4815 229.059C61.9735 227.949 59.9268 227.393 57.3416 227.393C55.585 227.393 54.1018 227.642 52.892 228.139C51.6823 228.62 50.7543 229.291 50.108 230.153C49.4782 231.014 49.1634 231.992 49.1634 233.086C49.1302 233.997 49.3208 234.793 49.7351 235.472C50.166 236.152 50.7543 236.74 51.5 237.237C52.2457 237.718 53.1075 238.14 54.0852 238.505C55.063 238.853 56.107 239.151 57.2173 239.4L61.7912 240.494C64.0118 240.991 66.0502 241.654 67.9062 242.482C69.7623 243.311 71.3698 244.33 72.7287 245.54C74.0876 246.75 75.1399 248.175 75.8857 249.815C76.648 251.456 77.0374 253.337 77.054 255.458C77.0374 258.574 76.242 261.275 74.6676 263.562C73.1099 265.832 70.8561 267.597 67.9062 268.857C64.973 270.099 61.4349 270.721 57.2919 270.721C53.1821 270.721 49.6025 270.091 46.5533 268.832C43.5206 267.572 41.1508 265.708 39.4439 263.239C37.7536 260.753 36.867 257.679 36.7841 254.016H47.1996C47.3156 255.723 47.8045 257.148 48.6662 258.292C49.5445 259.419 50.7128 260.272 52.1712 260.852C53.6461 261.416 55.3116 261.697 57.1676 261.697C58.9905 261.697 60.5732 261.432 61.9155 260.902C63.2744 260.372 64.3267 259.634 65.0724 258.69C65.8182 257.745 66.1911 256.66 66.1911 255.433C66.1911 254.29 65.8513 253.329 65.1719 252.55C64.509 251.771 63.5313 251.108 62.2386 250.561C60.9626 250.014 59.3965 249.517 57.5405 249.07L51.9972 247.678C47.705 246.634 44.3161 245.001 41.8303 242.781C39.3445 240.56 38.1098 237.569 38.1264 233.807C38.1098 230.724 38.9302 228.031 40.5874 225.728C42.2611 223.424 44.5563 221.626 47.473 220.334C50.3897 219.041 53.7041 218.395 57.4162 218.395C61.1946 218.395 64.4924 219.041 67.3097 220.334C70.1435 221.626 72.3475 223.424 73.9219 225.728C75.4962 228.031 76.3082 230.7 76.358 233.732H66.0419ZM91.9066 270H80.3725L97.9471 219.091H111.818L129.368 270H117.833L105.081 230.724H104.684L91.9066 270ZM91.1857 249.989H118.43V258.391H91.1857V249.989ZM138.335 219.091L150.64 257.77H151.112L163.442 219.091H175.373L157.824 270H143.953L126.378 219.091H138.335ZM181.501 270V219.091H215.805V227.965H192.264V240.096H214.04V248.97H192.264V261.126H215.904V270H181.501ZM224.362 270V219.091H244.447C248.292 219.091 251.573 219.779 254.291 221.154C257.026 222.513 259.105 224.444 260.531 226.946C261.972 229.432 262.693 232.357 262.693 235.721C262.693 239.102 261.964 242.01 260.506 244.446C259.047 246.866 256.934 248.722 254.167 250.014C251.416 251.307 248.085 251.953 244.174 251.953H230.726V243.303H242.434C244.489 243.303 246.196 243.021 247.555 242.457C248.914 241.894 249.924 241.049 250.587 239.922C251.267 238.795 251.607 237.395 251.607 235.721C251.607 234.031 251.267 232.605 250.587 231.445C249.924 230.285 248.905 229.407 247.53 228.81C246.171 228.197 244.456 227.891 242.384 227.891H235.126V270H224.362ZM251.855 246.832L264.508 270H252.626L240.246 246.832H251.855Z" fill="white"/>
<path d="M262.946 92.0066C257.24 97.7121 249.502 100.917 241.433 100.917C233.365 100.917 225.626 97.7121 219.921 92.0066C214.216 86.3012 211.01 78.563 211.01 70.4943C211.01 62.4256 214.216 54.6873 219.921 48.9819L241.433 70.4943L262.946 92.0066Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 324 B

View File

@@ -1,4 +0,0 @@
<svg width="138" height="122" viewBox="0 0 138 122" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.0834961 46.7718C0.0834961 78.5833 26.3773 95.5354 45.6248 110.709C52.4168 116.063 58.9585 121.104 65.5002 121.104C72.0418 121.104 78.5835 116.063 85.3757 110.709C104.623 95.5354 130.917 78.5833 130.917 46.7718C130.917 14.9601 94.9364 -7.60011 65.5002 22.9833C36.0637 -7.60011 0.0834961 14.9601 0.0834961 46.7718Z" fill="#2E4653"/>
<path d="M21 103L125 13" stroke="white" stroke-width="25" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 533 B

View File

@@ -5,7 +5,7 @@
onclick?: (event: MouseEvent) => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
style?: "normal" | "red" | "invisible";
style?: "normal" | "red";
formaction?: string;
children?: Snippet;
}
@@ -33,21 +33,18 @@
padding: 0.5rem 1rem;
border-radius: 0.5rem;
box-shadow: 0rem 0rem 0.5rem #182125;
color: #ffffff;
color: #eaffeb;
border: none;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.normal {
background: linear-gradient(-83deg, rgb(1, 163, 117), #189f5e);
background: linear-gradient(-83deg, #3fb095, #49bd85);
}
.red {
background-color: #bd4949;
}
.invisible {
background: none;
}
.button:focus {
outline: 2px solid #007bff;
}

View File

@@ -12,7 +12,7 @@
"No Outlets": "compulsoryTagRed",
"Some Outlets": "compulsoryTagYellow",
"Good WiFi": "compulsoryTagGreen",
"Bad/No WiFi": "compulsoryTagRed",
"Bad WiFi": "compulsoryTagRed",
"Moderate WiFi": "compulsoryTagYellow",
"No WiFi": "compulsoryTagRed"
};

View File

@@ -1,55 +0,0 @@
<script lang="ts">
import heart from "../assets/heart.svg";
import un_heart from "../assets/un_heart.svg";
interface Props {
isFavourite: boolean;
onToggleFavourite: () => void;
imgSize?: number;
}
const { isFavourite, onToggleFavourite, imgSize }: Props = $props();
function handleClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
onToggleFavourite();
}
</script>
<button
type="button"
class="fav-button"
style="--imgSize: {imgSize || 20}%"
onclick={handleClick}
aria-label="Toggle favourite"
>
{#if isFavourite}
<img class="favImg shadow" src={heart} alt="heart" />
{:else}
<img class="unfav shadow" src={un_heart} alt="unheart" />
{/if}
</button>
<style>
.fav-button {
background: none;
display: flex;
justify-content: center;
align-items: center;
border: none;
cursor: pointer;
width: 100%;
height: 100%;
padding: 0;
}
.favImg {
transform: scale(var(--imgSize));
}
.shadow {
filter: drop-shadow(5px 5px 5px rgba(0, 0, 0, 0.3));
}
.unfav {
transform: scale(var(--imgSize)) translate(0.1rem);
}
</style>

View File

@@ -26,18 +26,12 @@
const { error: feedbackUpload } = await supabase
.from("study_spaces")
.update({
directions: newStudySpaceData.directions,
volume: newStudySpaceData.volume,
wifi: newStudySpaceData.wifi,
power: newStudySpaceData.power,
tags: newStudySpaceData.tags
})
.eq("id", newStudySpaceData.id ?? "");
await supabase.channel("study_space_updates").send({
type: "broadcast",
event: "study_space_updated",
payload: { id: newStudySpaceData.id }
});
invalidate("db:study_spaces");
if (feedbackUpload) return alert(`Error submitting feedback: ${feedbackUpload.message}`);
else alert("Feedback submitted successfully!");
@@ -80,7 +74,7 @@
}}
class="feedbackContainer"
>
<h1 class="submitHeader">Update Tags</h1>
<h1 class="submitHeader">Submit Feedback</h1>
<div class="compulsoryTags">
<div class="compulsoryContainer">
@@ -212,7 +206,7 @@
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
background-color: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;

View File

@@ -45,6 +45,5 @@
display: flex;
flex-direction: row-reverse;
flex: 1;
align-items: center;
}
</style>

View File

@@ -18,8 +18,8 @@
// Compute the display string for opening times
let openingDisplay = $state("");
if (todayHours) {
openingDisplay = todayHours.open_today_status
? "Open All Day"
openingDisplay = todayHours.is_24_7
? "Open 24/7"
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
} else {
openingDisplay = "Closed";

View File

@@ -29,12 +29,6 @@
.select()
.single();
await supabase.channel("report_updates").send({
type: "broadcast",
event: "reports_updated",
payload: { study_space_id: studySpaceId }
});
if (reportUploadError)
return alert(`Error submitting report: ${reportUploadError.message}`);
else alert("Report submitted successfully!");
@@ -129,7 +123,7 @@
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
background-color: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;

View File

@@ -1,47 +1,32 @@
<script lang="ts">
import CompulsoryTags from "./CompulsoryTags.svelte";
import Favourite from "./Favourite.svelte";
import OpeningTimes from "./OpeningTimes.svelte";
import type { Table } from "$lib";
interface Props {
space: Table<"study_spaces">;
hours: Table<"study_space_hours">[];
alt: string;
imgSrc: string;
href?: string;
isFavourite: boolean;
isAvailable?: boolean;
onToggleFavourite?: () => void;
footer?: string;
}
const { space, alt, imgSrc, href, isFavourite, onToggleFavourite, isAvailable, footer }: Props =
$props();
const { space, hours, alt, imgSrc, href }: Props = $props();
</script>
<a class="card {isAvailable ? 'green' : 'grey'}" {href}>
<!-- <img src={imgSrc} {alt} /> -->
<div class="image-container">
<img src={imgSrc} {alt} />
{#if onToggleFavourite}
<div class="fav-button">
<Favourite {isFavourite} {onToggleFavourite} />
</div>
{/if}
</div>
<a class="card" {href}>
<img src={imgSrc} {alt} />
<div class="description">
<h1>{space.location}</h1>
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0}
<div class="tagContainer">
{#each space.tags as tag (tag)}
<span class="tag {isAvailable ? 'tagGreen' : 'tagGrey'}">{tag}</span>
<span class="tag">{tag}</span>
{/each}
</div>
{/if}
<div class="spacer"></div>
{#if footer}
<div class="footer">{footer}</div>
{/if}
<div class="openingTimesContainer"><OpeningTimes {hours} /></div>
</div>
</a>
@@ -49,26 +34,15 @@
.card {
display: flex;
flex-direction: column;
background-color: #49bd85;
width: 100%;
max-width: 20rem;
border-radius: 0.5rem;
overflow: hidden;
text-decoration: none;
}
.green {
background-color: #189f5e;
}
.grey {
background-color: #2e4653;
}
.spacer {
flex: 1;
}
.description {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.4rem;
padding: 0.5rem;
color: #edebe9;
}
img {
@@ -80,7 +54,6 @@
h1 {
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.tagContainer {
@@ -98,50 +71,17 @@
justify-content: center;
text-align: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.875rem;
cursor: pointer;
padding: 0.2rem 0.6rem;
}
.tagGreen {
background-color: #2e4653;
}
.tagGrey {
background-color: #189f5e;
}
.compulsoryContainer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(3.7rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(3.75rem, 1fr));
gap: 0.3rem;
font-size: 0.8rem;
}
.image-container {
position: relative;
}
.image-container .fav-button {
position: absolute;
top: 0;
right: 0;
background: #189f5e;
border-radius: 0 0 0 0.5rem;
z-index: 1;
width: 2.75rem;
height: 2.75rem;
}
.footer {
width: 100%;
color: #2e4653;
background-color: #eaffeb;
align-self: flex-end;
border-radius: 0.5rem;
padding: 0.1rem;
text-align: center;
font-weight: bold;
margin-top: 0.4rem;
}
</style>

View File

@@ -85,7 +85,7 @@
display: flex;
flex-direction: column;
align-items: center;
color: #189f5e;
color: #49bd85;
}
.preview {
max-height: 100%;

View File

@@ -1,131 +0,0 @@
<script lang="ts">
import { daysOfWeek } from "$lib";
import CrossUrl from "../../assets/cross.svg";
interface Props {
index: number;
openingValue: string;
closingValue: string;
openTodayStatus: boolean | null;
onHide?: () => void;
day: number; //0-6 for Sunday-Saturday, 7 for all days, 8 for all other days
}
let {
index,
openingValue = $bindable(),
closingValue = $bindable(),
openTodayStatus = $bindable(),
day = $bindable(),
onHide
}: Props = $props();
</script>
<div class="opening-time-item">
{#if day <= 6}
<select bind:value={day} class="dayOfWeek">
{#each [0, 1, 2, 3, 4, 5, 6] as dayNum (dayNum)}
<option value={dayNum}>{daysOfWeek[dayNum]}</option>
{/each}
</select>
{:else}
<label for={"opens-" + index} class="dayOfWeek">{daysOfWeek[day]}</label>
{/if}
<label for={"isAllDay-" + index}>
<input
id={"isAllDay-" + index}
class="checkbox"
type="checkbox"
bind:checked={
() => openTodayStatus === true, (v) => (openTodayStatus = v ? true : null)
}
/>
Open all day
</label>
<label for={"isclosed-" + index}>
<input
id={"isclosed-" + index}
class="checkbox"
type="checkbox"
bind:checked={
() => openTodayStatus === false, (v) => (openTodayStatus = v ? false : null)
}
/>
Closed
</label>
{#if onHide}
<button class="hideButton" onclick={onHide} type="button">
<img src={CrossUrl} alt="nah" />
</button>
{/if}
{#if openTodayStatus === null}
<div class="timeRange">
<input id={"opens-" + index} type="time" bind:value={openingValue} />
<span class="to">to</span>
<input id={"closes-" + index} type="time" bind:value={closingValue} />
</div>
{/if}
</div>
<style>
.opening-time-item {
display: flex;
flex-direction: row;
gap: 0.25rem 1rem;
padding: 0.5rem;
align-items: center;
flex-wrap: wrap;
position: relative;
}
input[type="time"] {
border: 2px solid #000000;
border-radius: 4px;
background: none;
color: #000000;
padding: 0.25rem;
flex: 1;
filter: brightness(0) saturate(100%) invert(98%) sepia(8%) saturate(555%) hue-rotate(54deg)
brightness(100%) contrast(102%);
}
input[type="checkbox"] {
margin: 0;
padding: 0;
}
.timeRange {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.dayOfWeek {
display: flex;
align-items: center;
font-weight: bold;
justify-content: center;
text-align: center;
border-radius: 0.25rem;
background-color: #eaffeb;
color: #2e4653;
cursor: pointer;
padding: 0.2rem 0.4rem;
font-size: 1rem;
}
.hideButton {
background: none;
border: none;
margin: 0 0.5% 0 auto;
width: 5%;
padding: 0;
overflow: visible;
}
.hideButton img {
width: 120%;
transform: scale(2);
}
</style>

View File

@@ -6,7 +6,6 @@
placeholder?: string;
required?: boolean;
type?: "text" | "password" | "email" | "number";
maxlength?: number;
}
let { inputElem = $bindable(), value = $bindable(), name, ...rest }: Props = $props();

124
src/lib/database.d.ts vendored
View File

@@ -69,47 +69,6 @@ export type Database = {
},
]
}
study_space_hours: {
Row: {
closes_at: string
created_at: string | null
day_of_week: number
id: string
open_today_status: boolean | null
opens_at: string
study_space_id: string | null
updated_at: string | null
}
Insert: {
closes_at: string
created_at?: string | null
day_of_week: number
id?: string
open_today_status?: boolean | null
opens_at: string
study_space_id?: string | null
updated_at?: string | null
}
Update: {
closes_at?: string
created_at?: string | null
day_of_week?: number
id?: string
open_today_status?: boolean | null
opens_at?: string
study_space_id?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "study_space_hours_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
}
study_space_images: {
Row: {
created_at: string | null
@@ -145,7 +104,6 @@ export type Database = {
building_location_old: string | null
created_at: string | null
description: string | null
directions: string
id: string
location: string | null
power: string
@@ -159,7 +117,6 @@ export type Database = {
building_location_old?: string | null
created_at?: string | null
description?: string | null
directions: string
id?: string
location?: string | null
power: string
@@ -173,7 +130,6 @@ export type Database = {
building_location_old?: string | null
created_at?: string | null
description?: string | null
directions?: string
id?: string
location?: string | null
power?: string
@@ -184,6 +140,48 @@ export type Database = {
}
Relationships: []
}
study_space_hours: {
Row: {
id: string
study_space_id: string
day_of_week: number
opens_at: string
closes_at: string
is_24_7: boolean
created_at: string | null
updated_at: string | null
}
Insert: {
id?: string
study_space_id: string
day_of_week: number
opens_at: string
closes_at: string
is_24_7: boolean
created_at?: string | null
updated_at?: string | null
}
Update: {
id?: string
study_space_id?: string
day_of_week?: number
opens_at?: string
closes_at?: string
is_24_7?: boolean
created_at?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "study_space_hours_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
users: {
Row: {
created_at: string
@@ -204,42 +202,7 @@ export type Database = {
updated_at?: string
}
Relationships: []
}
favourite_study_spaces: {
Row: {
user_id: string
study_space_id: string
created_at: string | null
updated_at: string | null
}
Insert: {
user_id: string
study_space_id: string
created_at?: string | null
updated_at?: string | null
}
Update: {
user_id?: string
study_space_id?: string
created_at?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "favourite_study_spaces_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
{
foreignKeyName: "favourite_study_spaces_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
}
]
}
}
Views: {
@@ -370,4 +333,3 @@ export const Constants = {
Enums: {},
},
} as const

View File

@@ -1,46 +0,0 @@
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;
}

View File

@@ -18,7 +18,7 @@ export const availableStudySpaceTags = [
"Air conditioned",
"Cold",
"PCs",
"Rodent-ridden"
"Cringe"
];
export const volumeTags = ["Silent", "Some Noise", "Loud"];
@@ -57,56 +57,5 @@ export const daysOfWeek = [
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"All Days",
"All Other Days"
"Saturday"
];
// 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));
}
export function collectTimings(
study_space_hours: Omit<
Table<"study_space_hours">,
"id" | "created_at" | "updated_at" | "study_space_id"
>[]
) {
// Collect all timing entries
const timingsPerDay: Record<
number,
Omit<Table<"study_space_hours">, "id" | "created_at" | "updated_at" | "study_space_id">[]
> = {
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
6: []
};
for (const entry of study_space_hours) {
timingsPerDay[entry.day_of_week].push(entry);
}
return timingsPerDay;
}

View File

@@ -5,7 +5,7 @@
import { invalidate } from "$app/navigation";
let { data, children } = $props();
let { session, supabase, route } = $derived(data);
let { session, supabase } = $derived(data);
onMount(() => {
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
@@ -17,23 +17,7 @@
invalidate("supabase:auth");
}
});
const spacesChannel = supabase
.channel("study_space_updates")
.on("broadcast", { event: "study_space_updated" }, () => {
invalidate("db:study_spaces");
})
.subscribe();
return () => {
data.subscription.unsubscribe();
spacesChannel.unsubscribe();
};
});
$effect(() => {
if (route.id === "/filter") {
document.body.classList.add("coloured");
} else {
document.body.classList.remove("coloured");
}
return () => data.subscription.unsubscribe();
});
</script>
@@ -49,7 +33,6 @@
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
}
:global(html) {
@@ -57,10 +40,6 @@
color: #eaffeb;
}
:global(body.coloured) {
background: linear-gradient(-77deg, #2e4653, #223a37);
}
:global(*) {
box-sizing: border-box;
font-family: Inter;

View File

@@ -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, url, route, depends, fetch }) => {
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
/**
* Declare a dependency so the layout can be invalidated, for example, on
* session refresh.
@@ -40,12 +40,5 @@ export const load: LayoutLoad = async ({ data, url, route, depends, fetch }) =>
data: { user }
} = await supabase.auth.getUser();
return {
session,
supabase,
user,
adminMode: data.adminMode,
route,
searchParams: url.searchParams.toString()
};
return { session, supabase, user, adminMode: data.adminMode };
};

View File

@@ -8,25 +8,7 @@ export const load: PageServerLoad = async ({ depends, locals: { supabase } }) =>
.select("*, study_space_images(*), study_space_hours(*)");
if (err) error(500, "Failed to load study spaces");
const {
data: { session }
} = await supabase.auth.getSession();
// Fetch this users favourites
let favouriteIds: string[] = [];
if (session?.user?.id) {
const { data: favs, error: favErr } = await supabase
.from("favourite_study_spaces")
.select("study_space_id")
.eq("user_id", session.user.id);
if (!favErr && favs) {
favouriteIds = favs.map((f) => f.study_space_id);
}
}
return {
studySpaces,
session,
favouriteIds
studySpaces
};
};

View File

@@ -2,56 +2,48 @@
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 { allTags, volumeTags, wifiTags, powerOutletTags } 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);
const { studySpaces, supabase, session, adminMode } = $derived(data);
let favouriteIds = $derived<string[]>(initialFavourites);
let showFavourites = $state(false);
let selectedTags = $state<string[]>([]);
let tagFilter = $state("");
let openingFilter = $state("");
let closingFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
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);
function categorySelected(category: string[]) {
return category.some((tag) => selectedTags.includes(tag));
}
// 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];
}
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
// only include favourites when showFavourites===true
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
// tag filtering
.filter((space) => {
if (selectedTags.length === 0) return true;
@@ -70,13 +62,13 @@
(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 (entry.is_24_7) return true;
const openMin = toMinutes(entry.opens_at);
let closeMin = toMinutes(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);
const filterMin = toMinutes(openingFilter);
// Include spaces open at the filter time
return filterMin >= openMin && filterMin < closeMin;
})
@@ -87,111 +79,34 @@
(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 (entry.is_24_7) return true;
const openMin = toMinutes(entry.opens_at);
let closeMin = toMinutes(entry.closes_at);
if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin =
timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
toMinutes(closingFilter) === 0 ? 24 * 60 : toMinutes(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;
}
}
}
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);
}
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" };
tagFilter = "";
};
}
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`;
}
$inspect(sortedStudySpaces);
</script>
<Navbar>
@@ -200,74 +115,112 @@
<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)}
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={studySpace.study_space_images.length > 0
{#if adminMode}
<div class="checkReports">
<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)}
{@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}
hours={studySpace.study_space_hours}
/>
{/each}
</main>
<footer>
{#if session}
<Button
onclick={async () => {
await supabase.auth.signOut();
invalidateAll();
}}>Signout</Button
>
<Button onclick={() => supabase.auth.signOut()}>Signout</Button>
{:else}
<Button href="/auth" type="link">Login / Signup</Button>
{/if}
</footer>
{#if adminMode}
<div class="adminMode">You are in admin mode</div>
{/if}
<style>
main {
display: grid;
box-sizing: border-box;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.5rem;
max-width: 32rem;
gap: 1rem;
padding: 1rem;
max-width: 600px;
width: 100%;
margin: 0 auto;
}
@@ -285,43 +238,126 @@
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;
padding: 0.5rem;
}
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;
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;
font-size: 1.2rem;
}
@media (max-width: 20rem) {
@@ -329,24 +365,4 @@
grid-template-columns: 1fr;
}
}
.adminMode {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
position: sticky;
left: 0;
gap: 0.5rem;
padding: 0.75rem;
font-size: 1rem;
background-color: #182125;
bottom: 0;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
color: #eaffeb;
border: 2px solid #eaffeb;
z-index: 1000;
}
</style>

View File

@@ -1,362 +0,0 @@
<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: 51.5087393, lng: -0.1667442 },
zoom: 10,
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>
<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>
<div class="controls">
<div class="controls-inner">
<Button type="link" href="/?{newSearchParams}">Back to study spaces</Button>
<Button
style="red"
onclick={() => {
openAt.from = undefined;
openAt.to = undefined;
selectedTags = [];
sortNear = undefined;
tagFilter = "";
}}
>
Clear
</Button>
</div>
</div>
<style>
main {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 32rem;
margin: auto;
}
.controls {
position: sticky;
background: inherit;
background-attachment: local;
padding: 0.5rem 1rem;
bottom: 0;
width: 100%;
}
.controls-inner {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 32rem;
gap: 1rem;
margin: auto;
}
.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 #000000;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #000000;
filter: brightness(0) saturate(100%) invert(98%) sepia(8%) saturate(555%) hue-rotate(54deg)
brightness(100%) contrast(102%);
}
.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>

View File

@@ -7,9 +7,8 @@
import Report from "$lib/components/Report.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import { onMount } from "svelte";
import { gmapsLoader, daysOfWeek, formatTime, collectTimings } from "$lib";
import { gmapsLoader, daysOfWeek, formatTime } from "$lib";
import Button from "$lib/components/Button.svelte";
import Favourite from "$lib/components/Favourite.svelte";
const { data } = $props();
const { space, supabase, adminMode } = $derived(data);
@@ -52,47 +51,12 @@
});
});
let timingsPerDay = collectTimings(space.study_space_hours);
const hoursByDay = $derived(new Map(space.study_space_hours.map((h) => [h.day_of_week, h])));
let isFavourite = $state(false);
onMount(async () => {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
const { data: fav } = await supabase
.from("favourite_study_spaces")
.select("study_space_id")
.match({ user_id: session.user.id, study_space_id: space.id })
.single();
isFavourite = !!fav;
});
// Toggle a space in/out of favourites
async function handleToggleFavourite() {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
if (isFavourite) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: space.id });
isFavourite = false;
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: space.id }]);
isFavourite = true;
}
}
async function deleteSpace() {
if (!confirm("Are you sure you want to delete this study space?")) return;
await supabase.from("study_spaces").delete().eq("id", space.id);
window.location.href = "/";
}
const openingEntries = daysOfWeek.map((day, idx) => ({
day,
entry: hoursByDay.get(idx)
}));
</script>
<Navbar>
@@ -114,22 +78,9 @@
/>
{/if}
<main>
<div class="imgContainer">
{#await supabase.auth.getSession() then resp}
{#if resp.data.session}
<div class="title-fav">
<Favourite
{isFavourite}
onToggleFavourite={handleToggleFavourite}
imgSize={27}
/>
</div>
{/if}
{/await}
<Carousel urls={imgUrls} />
</div>
<Carousel urls={imgUrls} />
<div class="nameContainer">
<div class="locationContainer">{space.location}</div>
{space.location}
</div>
{#if space.description != null && space.description.length > 0}
<p class="descContainer">
@@ -140,7 +91,7 @@
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0}
<div class="tagContainer">
{#each space.tags as tag, idx (tag + idx)}
{#each space.tags as tag (tag)}
<span class="tag">
{tag}
</span>
@@ -149,38 +100,27 @@
{/if}
<hr />
<div class="subtitle">Opening Times:</div>
{#each Array(7).keys() as idx (idx)}
{@const entries = timingsPerDay[idx]}
{#each openingEntries as { day, entry } (entry)}
<div class="opening-entry">
<span class="day">{daysOfWeek[idx]}</span>
<div class="times">
{#each entries as entry (entry)}
<span class="time">
{entry.open_today_status
? "Open All Day"
: entry.open_today_status === false
? "Closed"
: `${formatTime(entry.opens_at)} ${formatTime(entry.closes_at)}`}
</span>
<span class="day">{day}:</span>
<span class="times">
{#if entry}
{entry.is_24_7
? "Open All Day"
: `${formatTime(entry.opens_at)} ${formatTime(entry.closes_at)}`}
{:else}
<span class="time">Not known</span>
{/each}
</div>
Closed
{/if}
</span>
</div>
{/each}
<hr />
<div class="subtitle">Directions:</div>
<p class="addrContainer">
{space.directions}
</p>
<div class="subtitle">Where it is:</div>
<p class="addrContainer">
{#if place.name}
{place.name} <br />
{/if}
{#each place.formatted_address?.split(",") || [] as line, idx (line + idx)}
{#each place.formatted_address?.split(",") || [] as line (line)}
{line.trim()} <br />
{/each}
</p>
@@ -193,15 +133,12 @@
isFeedbackPromptVisible = true;
}}
>
Update Tags
Help categorise this space
</button>
<div class="actions">
{#if adminMode}
<div class="buttonContainer">
<Button href="/space/{space.id}/edit" type="link">Edit</Button>
<Button type="button" style="red" onclick={deleteSpace}>Delete</Button>
</div>
<Button href="/space/{space.id}/edit" type="link">Edit</Button>
{:else}
<Button onclick={() => (isReportVisible = true)} style="red">Report</Button>
{/if}
@@ -230,23 +167,21 @@
background-color: #2e3c42;
width: 70%;
border: none;
margin: 1rem auto 0;
margin: 0 auto;
}
.nameContainer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
z-index: 10;
display: block;
width: 100%;
padding: 0.6rem;
margin-top: -0.5rem;
background-color: #189f5e;
object-position: center;
background-color: #49bd85;
border-radius: 8px;
font-size: 2.8rem;
font-weight: bold;
color: #ffffff;
z-index: 1;
}
.descContainer {
@@ -311,62 +246,36 @@
padding: 0.7rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
background-color: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
text-align: center;
}
.opening-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
padding: 0.5rem 1.4rem;
align-items: center;
}
.opening-entry .day {
font-weight: bold;
color: #ffffff;
white-space: nowrap;
}
.opening-entry .times {
font-family: monospace;
background-color: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
color: #eaffeb;
.actions {
display: flex;
flex-direction: column;
padding-top: 1rem;
}
.opening-entry {
display: flex;
gap: 0.75rem;
padding: 0.5rem 1.4rem;
align-items: center;
background-color: #2e4653;
margin: 0.2rem;
border-radius: 0.5rem;
}
.opening-entry .day {
font-weight: bold;
color: #ffffff;
align-items: center;
justify-content: center;
}
.opening-entry .times {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.25rem 1.5rem;
flex: 1;
align-items: end;
}
.opening-entry .time {
font-family: monospace;
color: #eaffeb;
}
.imgContainer {
position: relative;
}
.title-fav {
position: absolute;
top: 0;
right: 0;
background: #189f5e;
border-radius: 0 0 0 0.5rem;
z-index: 1;
width: 3.75rem;
height: 3.75rem;
}
.buttonContainer {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
</style>

View File

@@ -12,7 +12,7 @@ type StudySpaceData = Omit<
day_of_week: number;
opens_at: string;
closes_at: string;
open_today_status: boolean | null;
is_24_7: boolean;
}[];
};
@@ -21,7 +21,6 @@ export const load: PageServerLoad = async ({ params, locals: { supabase } }) =>
return {
space: {
description: "",
directions: "",
building_location: undefined,
location: "",
tags: [],
@@ -43,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals: { supabase } }) =>
const images = studySpaceData.study_space_images || [];
const { data: hours, error: hoursErr } = await supabase
.from("study_space_hours")
.select("day_of_week, opens_at, closes_at, open_today_status")
.select("day_of_week, opens_at, closes_at, is_24_7")
.eq("study_space_id", params.id)
.order("day_of_week", { ascending: true });
if (hoursErr) error(500, "Failed to load opening times");

View File

@@ -6,16 +6,13 @@
import crossUrl from "$lib/assets/cross.svg";
import Button from "$lib/components/Button.svelte";
import Images from "$lib/components/inputs/Images.svelte";
import OpeningTimesDay from "$lib/components/inputs/OpeningTimesDay.svelte";
import {
availableStudySpaceTags,
wifiTags,
powerOutletTags,
volumeTags,
gmapsLoader,
daysOfWeek,
timeToMins,
collectTimings
daysOfWeek
} from "$lib";
import { onMount } from "svelte";
import type { Json } from "$lib/database.js";
@@ -24,22 +21,23 @@
const { supabase } = $derived(data);
const { space, images } = $derived(data);
interface OpeningTime {
day_of_week: number;
opens_at: string;
closes_at: string;
open_today_status: boolean | null;
}
const studySpaceData = $state({
opening_times: [] as OpeningTime[],
opening_times: daysOfWeek.map((_, index) => ({
day_of_week: index,
opens_at: "",
closes_at: "",
is_24_7: false
})),
...space
});
$effect(() => {
if (!space) return;
Object.assign(studySpaceData, space);
studySpaceData.opening_times = space.opening_times ?? [];
const { opening_times, ...rest } = space;
Object.assign(studySpaceData, rest);
if (opening_times) {
studySpaceData.opening_times = opening_times;
}
});
let scrollPosition = $state(0);
@@ -59,96 +57,12 @@
);
let spaceImgs = $state<FileList>();
let uploading = $state(false);
function checkTimings() {
let cannotExist = [] as number[];
let hasAllDays = Object.values(collectTimings(studySpaceData.opening_times)).every(
(item) => !(Array.isArray(item) && item.length === 0)
);
if (
(allDays.closes_at === "" || allDays.opens_at === "") &&
allDays.open_today_status === null &&
studySpaceData.opening_times.length > 0 &&
!hasAllDays
) {
alert(`No opening time provided for all other days.`);
return false;
}
const opensAtMinsAll = timeToMins(allDays.opens_at);
const closesAtMinsAll = timeToMins(allDays.closes_at);
if (opensAtMinsAll >= closesAtMinsAll) {
alert(`Opening time for all days is after closing time.`);
return false;
}
for (const entry of studySpaceData.opening_times) {
if (cannotExist.includes(entry.day_of_week)) {
alert(
"You marked a day as either closed or open all day, and then provided another timing."
);
return false;
}
if (entry.open_today_status != null) {
cannotExist.push(entry.day_of_week);
}
const opensAtMins = timeToMins(entry.opens_at);
const closesAtMins = timeToMins(entry.closes_at);
if (opensAtMins >= closesAtMins) {
alert(`Opening time for ${daysOfWeek[entry.day_of_week]} is after closing time.`);
return false;
}
}
return true;
}
function genTimings(studySpaceId: string) {
const fullDayOfWeek = [0, 1, 2, 3, 4, 5, 6];
// all day only
if (
studySpaceData.opening_times.length === 0 &&
((allDays.closes_at != "" && allDays.opens_at != "") ||
allDays.open_today_status != null)
) {
return fullDayOfWeek.map((day) => ({
study_space_id: studySpaceId,
day_of_week: day,
opens_at: allDays.open_today_status === null ? allDays.opens_at : "00:00",
closes_at: allDays.open_today_status === null ? allDays.closes_at : "00:01",
open_today_status: allDays.open_today_status
}));
}
// some days specified
const nonDefinedDays = fullDayOfWeek.filter(
(day) => !new Set(studySpaceData.opening_times.map((h) => h.day_of_week)).has(day)
);
return studySpaceData.opening_times
.map((h) => ({
study_space_id: studySpaceId,
day_of_week: h.day_of_week,
opens_at: h.open_today_status === null ? h.opens_at : "00:00",
closes_at: h.open_today_status === null ? h.closes_at : "00:01",
open_today_status: h.open_today_status
}))
.concat(
nonDefinedDays.map((day) => ({
study_space_id: studySpaceId,
day_of_week: day,
opens_at: allDays.open_today_status === null ? allDays.opens_at : "00:00",
closes_at: allDays.open_today_status === null ? allDays.closes_at : "00:01",
open_today_status: allDays.open_today_status
}))
);
}
async function uploadStudySpace() {
if (!checkTimings()) return;
if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file.");
if (!studySpaceData.building_location) return alert("Please select a building location.");
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { opening_times: _, ...spacePayload } = studySpaceData;
const { opening_times, ...spacePayload } = studySpaceData;
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
@@ -212,24 +126,17 @@
.eq("study_space_id", studySpaceInsert.id);
if (deleteErr) return alert(`Error clearing old hours: ${deleteErr.message}`);
// Nothing is provided
if (
(allDays.closes_at != "" && allDays.opens_at != "") ||
studySpaceData.opening_times.length === 7 ||
allDays.open_today_status != null
) {
const { error: hoursErr } = await supabase
.from("study_space_hours")
.insert(genTimings(studySpaceInsert.id));
if (hoursErr) return alert(`Error saving opening times: ${hoursErr.message}`);
}
await supabase.channel("study_space_updates").send({
type: "broadcast",
event: "study_space_updated",
payload: {
study_space_id: studySpaceInsert.id
}
});
const { error: hoursErr } = await supabase.from("study_space_hours").insert(
opening_times.map((h) => ({
study_space_id: studySpaceInsert.id,
day_of_week: h.day_of_week,
opens_at: h.opens_at,
closes_at: h.closes_at,
is_24_7: h.is_24_7
}))
);
if (hoursErr) return alert(`Error saving opening times: ${hoursErr.message}`);
alert("Thank you for your contribution!");
// Redirect to the new study space page
await goto(`/space/${studySpaceInsert.id}`, {
@@ -289,12 +196,20 @@
});
spaceImgs = dt.files;
});
// Opening times
let allDays = $state({
opens_at: "",
closes_at: "",
open_today_status: null
});
// --- Helper functions for opening times ---
function toggle247(index: number) {
const ot = studySpaceData.opening_times[index];
if (ot.is_24_7) {
ot.opens_at = "00:00";
ot.closes_at = "00:00";
}
}
function updateTimes(index: number) {
const ot = studySpaceData.opening_times[index];
ot.is_24_7 = ot.opens_at === "00:00" && ot.closes_at === "00:00";
}
</script>
<Navbar>
@@ -350,18 +265,10 @@
<Text
name="location"
bind:value={studySpaceData.location}
placeholder="Huxeley Labs 225"
maxlength={35}
placeholder="Room 123, Floor 1"
required
/>
{#if (studySpaceData.location ?? "").length > 25}
<p class="lengthPopup">
Try to keep the name succinct—for example, building + room name. Put any further
information like floor number in the description.
</p>
{/if}
<div class="compulsoryTags">
<div class="compulsoryContainer">
<label for="volume">Sound level:</label>
@@ -395,43 +302,41 @@
</select>
</div>
</div>
<label for="openingTimes">Opening times (Optional):</label>
<div class="allDaysTiming">
{#each studySpaceData.opening_times as opening_time, index (opening_time)}
<OpeningTimesDay
{index}
bind:openingValue={opening_time.opens_at}
bind:closingValue={opening_time.closes_at}
bind:openTodayStatus={opening_time.open_today_status}
bind:day={opening_time.day_of_week}
onHide={() => {
studySpaceData.opening_times.splice(index, 1);
}}
/>
<hr />
{/each}
<OpeningTimesDay
index={-1}
bind:openingValue={allDays.opens_at}
bind:closingValue={allDays.closes_at}
bind:openTodayStatus={allDays.open_today_status}
day={studySpaceData.opening_times.length === 0 ? 7 : 8}
/>
</div>
<Button
style="normal"
type="button"
onclick={() => {
studySpaceData.opening_times.push({
day_of_week: 0,
opens_at: "09:00",
closes_at: "17:00",
open_today_status: null
});
}}>Add new day</Button
>
<label for="tags">Additional tags (Optional):</label>
<label for="opening-times-label">Opening Times:</label>
<div class="opening-times">
{#each daysOfWeek as day, index (index)}
<div class="opening-time-item">
<label for={"opens-" + index}>{day}</label>
<input
id={"opens-" + index}
type="time"
bind:value={studySpaceData.opening_times[index].opens_at}
required
onchange={() => updateTimes(index)}
/>
<span>to</span>
<input
id={"closes-" + index}
type="time"
bind:value={studySpaceData.opening_times[index].closes_at}
required
onchange={() => updateTimes(index)}
/>
<label for={"is247-" + index}>
<input
id={"is247-" + index}
type="checkbox"
bind:checked={studySpaceData.opening_times[index].is_24_7}
onchange={() => toggle247(index)}
/>
All day
</label>
</div>
{/each}
</div>
<label for="tags">Additional tags:</label>
<div class="tagDisplay">
{#each studySpaceData.tags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
@@ -480,7 +385,7 @@
{/if}
</div>
<label for="description">Brief description (Optional):</label>
<label for="description">Optional brief description:</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
@@ -488,14 +393,6 @@
rows={2}
/>
<label for="directions">Give directions:</label>
<Textarea
name="directions"
bind:value={studySpaceData.directions}
placeholder="Turn left once you enter Huxley and walk straight."
rows={2}
/>
<label for="building-location">Add the building location:</label>
<Text
name="building-location"
@@ -660,7 +557,7 @@
.additionalImages {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(-83deg, #3fb095, #189f5e);
background: linear-gradient(-83deg, #3fb095, #49bd85);
box-shadow: 0rem 0rem 0.5rem #182125;
color: #eaffeb;
border: none;
@@ -675,28 +572,34 @@
}
/* Opening times layout and inputs styling */
.allDaysTiming {
border-radius: 0.5rem;
background: none;
.opening-times {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.5rem;
}
hr {
margin: 0%;
padding: 0;
width: 100%;
height: 2px;
border: none;
background-color: #eaffeb;
border-radius: 5rem;
.opening-time-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.lengthPopup {
background-color: #2e4653;
border-radius: 0.5rem;
.opening-time-item label {
margin-top: 0;
width: 6rem;
}
.opening-time-item input[type="time"] {
padding: 0.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
}
.opening-time-item span {
margin: 0 0.5rem;
color: #eaffeb;
}
</style>

View File

@@ -5,7 +5,6 @@
const { data } = $props();
const { reports, supabase } = $derived(data);
import { invalidate } from "$app/navigation";
import { onMount } from "svelte";
let deleting = $state(false);
@@ -19,16 +18,6 @@
return alert(`Error submitting report: ${reportDeleteError.message}`);
else alert("Report deleted successfully!");
}
onMount(() => {
const reportsChannel = supabase
.channel("report_updates")
.on("broadcast", { event: "reports_updated" }, () => {
invalidate("db:reports");
})
.subscribe();
return () => reportsChannel.unsubscribe();
});
</script>
<Navbar>
@@ -110,7 +99,7 @@
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
background-color: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;

View File

@@ -2,4 +2,3 @@ ALTER TABLE study_spaces DROP COLUMN title;
ALTER TABLE study_spaces ADD COLUMN building_address text;
ALTER TABLE study_spaces ADD COLUMN description text;
ALTER TABLE study_spaces ADD COLUMN location text;
ALTER TABLE study_spaces ADD COLUMN directions text;

View File

@@ -26,4 +26,3 @@ $$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();

View File

@@ -1,12 +0,0 @@
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.users (id)
VALUES (NEW.id);
RETURN NEW;
END;
$$;

View File

@@ -1,5 +0,0 @@
alter table "public"."study_space_hours" drop column "is_24_7";
alter table "public"."study_space_hours" add column "open_today_status" boolean;

View File

@@ -1,3 +0,0 @@
alter table "public"."study_space_hours" alter column "day_of_week" set not null;

View File

@@ -1,12 +0,0 @@
-- Table to store users' favourite study spaces
CREATE TABLE favourite_study_spaces (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, study_space_id)
);
CREATE TRIGGER favourite_study_spaces_updated_at
AFTER UPDATE ON favourite_study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();

View File

@@ -1 +0,0 @@
ALTER TABLE study_spaces ADD COLUMN IF NOT EXISTS directions text;

View File

@@ -9,7 +9,6 @@ CREATE POLICY "Whack"
CREATE TABLE study_spaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
description text,
directions text,
-- Location within building, e.g., "Room 101"
location text,
-- Not bothered to write a proper data migration
@@ -44,10 +43,10 @@ CREATE TABLE reports (
CREATE TABLE study_space_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
study_space_id UUID REFERENCES study_spaces(id) ON DELETE CASCADE,
day_of_week INT CHECK (day_of_week BETWEEN 0 AND 6) NOT NULL, -- 0 = Sunday, 6 = Saturday
day_of_week INT CHECK (day_of_week BETWEEN 0 AND 6), -- 0 = Sunday, 6 = Saturday
opens_at TIME NOT NULL,
closes_at TIME NOT NULL,
open_today_status BOOLEAN,
is_24_7 BOOLEAN DEFAULT FALSE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);

View File

@@ -2,7 +2,7 @@ CREATE TABLE users (
id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
is_admin boolean NOT NULL DEFAULT false,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
updated_at timestamp with time zone NOT NULL DEFAULT now(),
);
CREATE TRIGGER users_handle_updated_at
@@ -17,8 +17,8 @@ LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.users (id)
VALUES (NEW.id);
INSERT INTO public.users (id, contact_email)
VALUES (NEW.id, NEW.email);
RETURN NEW;
END;
$$;
@@ -26,16 +26,3 @@ $$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Table to store users' favourite study spaces
CREATE TABLE favourite_study_spaces (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, study_space_id)
);
CREATE TRIGGER favourite_study_spaces_updated_at
AFTER UPDATE ON favourite_study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();