Compare commits
125 Commits
feat/initi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a3fd0df2a | ||
|
|
8abcd3a979 | ||
|
|
ade34ac7ca | ||
|
|
3edae91e12 | ||
|
|
fcd11be506 | ||
|
670ecf3526
|
|||
|
e3e5e9eb69
|
|||
|
|
d086700d6d | ||
|
|
03bca19527 | ||
|
|
0239f86985 | ||
|
|
e518f63714 | ||
|
|
9306b42098 | ||
|
|
a2277a0c8b | ||
|
|
02c8b25b94 | ||
|
|
7d4e5bf4d1 | ||
|
|
f48d457f5b | ||
|
ba05fd478f
|
|||
|
9399a653a3
|
|||
|
|
7332c0376b | ||
|
b8af5c8374
|
|||
|
|
bfb361c82a | ||
|
|
947bb35f93 | ||
|
8af787c61c
|
|||
|
|
ef157d4015 | ||
|
|
958e6f61a4 | ||
|
|
9708713794 | ||
|
|
764385d660 | ||
|
|
9bcd1788bf | ||
|
|
37665bcb3a | ||
|
|
268392deed | ||
| e96aeb2cfc | |||
|
b22943968e
|
|||
|
|
f9812b3391 | ||
|
95c38c6f9f
|
|||
|
2ef9a63027
|
|||
|
|
602bf07d02 | ||
|
|
f4517ef467 | ||
|
|
b8f31aef5b | ||
|
|
d767bc4fad | ||
|
|
7f305287f0 | ||
|
|
2219f7a3b9 | ||
|
ce6c391d81
|
|||
|
|
b737c67377 | ||
|
|
93b3bf23be | ||
|
|
ee190d90db | ||
|
|
be04f2d869 | ||
|
|
ba0ae11abd | ||
|
|
e4a5641eeb | ||
|
|
8be06bba8b | ||
|
07742ad405
|
|||
|
|
9882594551 | ||
|
|
950ab5193a | ||
| 0c3d0a00b8 | |||
|
788007c1cc
|
|||
|
3bdee89eed
|
|||
|
|
30f44b0ac6 | ||
|
|
b516196d38 | ||
|
|
8a6f447202 | ||
|
|
5b7f63f63f | ||
|
|
afe7b3078d | ||
|
|
7117f85ef7 | ||
|
|
7c0f9b3f52 | ||
|
|
8de3d9d48c | ||
|
|
2c8d7e00b5 | ||
| ed2721ff4c | |||
|
|
c8eef97d99 | ||
|
|
8d3a21498f | ||
|
|
6bae8bb361 | ||
|
|
e7a7275af7 | ||
|
|
a33fba2cd6 | ||
| e2795ff257 | |||
|
|
2eceee2889 | ||
|
|
348229691f | ||
|
|
3d95ea3763 | ||
|
|
d7fcf9d9ff | ||
|
|
e9d6db605a | ||
|
|
7971e996fe | ||
|
|
be921610f6 | ||
|
|
4ced9347bb | ||
| 871891c842 | |||
| af08bc663b | |||
| f7969432ec | |||
| bd7f9bc991 | |||
|
|
e68cc4e0ea | ||
|
|
bdda39c7fe | ||
| 2b73fe13bd | |||
|
|
4d8cffc726 | ||
|
|
d982bf550e | ||
|
|
5eb7a9f58c | ||
|
|
61da21b7db | ||
|
|
201467c73a | ||
|
|
19d451fa8e | ||
|
|
b727665238 | ||
|
|
6a9f5ca21d | ||
|
|
61915cd6a6 | ||
|
|
3cec498192 | ||
|
|
f6f09c8492 | ||
| 769a20607b | |||
| b12076fc53 | |||
|
|
822beb8708 | ||
| 6f798bc479 | |||
| 1f5054efbb | |||
|
|
c4c8834d50 | ||
|
|
e625afd3b4 | ||
| 022d9089e0 | |||
| cf8d4725f7 | |||
| ebf33d47a2 | |||
|
|
ff414f242d | ||
| 485063f8d2 | |||
|
|
0e074e9301 | ||
| 6e45851892 | |||
| d4a9d5559e | |||
| a180e49466 | |||
|
|
a03a80a186 | ||
| 11a040a677 | |||
| f85adf9edc | |||
|
|
6e72580a6a | ||
|
|
1d1bd940bf | ||
|
|
f9878d1e48 | ||
|
|
4ee33398c1 | ||
|
|
55d9646b07 | ||
| b49f937dcb | |||
| c1de092525 | |||
| 2ef82a5d41 | |||
| b17b9ddb82 |
@@ -1,2 +1,3 @@
|
||||
PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
PUBLIC_GMAPS_API_KEY=your-google-maps-api-key-here
|
||||
@@ -21,6 +21,7 @@ check_types:
|
||||
variables:
|
||||
PUBLIC_SUPABASE_URL: $SUPABASE_URL
|
||||
PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY
|
||||
PUBLIC_GMAPS_API_KEY: $GMAPS_API_KEY
|
||||
script:
|
||||
- npm run check
|
||||
|
||||
@@ -72,6 +73,7 @@ build:
|
||||
variables:
|
||||
PUBLIC_SUPABASE_URL: $SUPABASE_URL
|
||||
PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY
|
||||
PUBLIC_GMAPS_API_KEY: $GMAPS_API_KEY
|
||||
script:
|
||||
- npm run build
|
||||
artifacts:
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- `npx supabase stop` will stop the local dev database (data is persisted unless you do a reset).
|
||||
- `npx supabase db push --local` will apply migrations to your local dev database. Useful if someone else has made new SQL migrations.
|
||||
- `npx supabase db reset` will completely reset the local dev database.
|
||||
- `npx supabase diff db -f <migration-name>` will generate a new migration file based on the current state of the database. This isn't 100% foolproof, so don't use it blindly.
|
||||
- `npx supabase db diff -f <migration-name>` will generate a new migration file based on the current state of the database. This isn't 100% foolproof, so don't use it blindly.
|
||||
|
||||
### What's where?
|
||||
|
||||
|
||||
923
package-lock.json
generated
@@ -20,12 +20,14 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@googlemaps/typescript-guards": "^2.0.3",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/svelte": "^5.2.4",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
@@ -42,7 +44,9 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^1.16.8",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.8"
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"posthog-js": "^1.250.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<div style="display: contents; background: inherit">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3
src/lib/assets/arrow_right.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="M10 24H38M38 24L24 10M38 24L24 38" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -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="#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"/>
|
||||
<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"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_117_282">
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
3
src/lib/assets/heart.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6.6 KiB |
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 |
4
src/lib/assets/un_heart.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -5,26 +5,54 @@
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
style?: "normal" | "red" | "invisible";
|
||||
formaction?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
const { children, ...rest }: Props = $props();
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
type: "link";
|
||||
style?: "normal" | "red";
|
||||
children?: Snippet;
|
||||
}
|
||||
const { children, type, style = "normal", ...rest }: Props | LinkProps = $props();
|
||||
</script>
|
||||
|
||||
<button {...rest}>
|
||||
{#if type === "link"}
|
||||
<a {...rest} class="button {style}">
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button class="button {style}" {type} {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
button {
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(-83deg, #3fb095, #49bd85);
|
||||
box-shadow: 0rem 0rem 0.5rem #182125;
|
||||
color: #eaffeb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
button:focus {
|
||||
.normal {
|
||||
background: linear-gradient(-83deg, rgb(1, 163, 117), #189f5e);
|
||||
}
|
||||
.red {
|
||||
background-color: #bd4949;
|
||||
}
|
||||
.invisible {
|
||||
background: none;
|
||||
}
|
||||
.button:focus {
|
||||
outline: 2px solid #007bff;
|
||||
}
|
||||
.button:disabled {
|
||||
background: linear-gradient(-18deg, #66697b, #4e4e5e);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
152
src/lib/components/Carousel.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import arrowRightUrl from "$lib/assets/arrow_right.svg";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
scrollPosition?: number;
|
||||
urls?: string[];
|
||||
ondelete?: (idx: number) => void;
|
||||
}
|
||||
|
||||
let { scrollPosition = $bindable(0), urls = [], ondelete }: Props = $props();
|
||||
let carousel = $state<HTMLDivElement>();
|
||||
let currentPosition = $state(0);
|
||||
let scrollWidth = $state(0);
|
||||
let clientWidth = $state(1);
|
||||
function updateScroll() {
|
||||
currentPosition = carousel?.scrollLeft || 0;
|
||||
scrollWidth = carousel?.scrollWidth || 0;
|
||||
clientWidth = carousel?.clientWidth || 1;
|
||||
}
|
||||
$effect(() => {
|
||||
carousel?.scrollTo({
|
||||
left: scrollPosition * clientWidth,
|
||||
behavior: "smooth"
|
||||
});
|
||||
});
|
||||
onMount(() => {
|
||||
const id = setInterval(() => {
|
||||
if (carousel) {
|
||||
updateScroll();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="controls">
|
||||
<div class="carousel" bind:this={carousel} onscroll={updateScroll} onscrollend={updateScroll}>
|
||||
{#each urls as url, idx (`${idx}|${url}`)}
|
||||
<div class="item">
|
||||
<img src={url} alt="carousel item" />
|
||||
{#if ondelete}
|
||||
<button class="delete" onclick={() => ondelete(idx)}>
|
||||
<img src={crossUrl} alt="delete item" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if currentPosition > clientWidth / 2}
|
||||
<button
|
||||
class="arrow left"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
if (carousel) carousel.scrollLeft -= carousel.clientWidth;
|
||||
}}
|
||||
>
|
||||
<img src={arrowRightUrl} alt="go to previous" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if currentPosition < scrollWidth - clientWidth * 1.5}
|
||||
<button
|
||||
class="arrow right"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
if (carousel) carousel.scrollLeft += carousel.clientWidth;
|
||||
}}
|
||||
>
|
||||
<img src={arrowRightUrl} alt="go to next" />
|
||||
</button>
|
||||
{/if}
|
||||
<span class="position">
|
||||
{Math.round(currentPosition / clientWidth) + 1} / {urls.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.controls {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
.item img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.delete,
|
||||
.position,
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.arrow,
|
||||
.delete {
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.arrow img,
|
||||
.delete img {
|
||||
width: 1.4rem;
|
||||
}
|
||||
.arrow:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.arrow.left {
|
||||
left: 0.2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.arrow.left img {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.arrow.right {
|
||||
right: 0.2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.delete {
|
||||
top: 0.4rem;
|
||||
right: 0.2rem;
|
||||
}
|
||||
.position {
|
||||
font-size: 0.8rem;
|
||||
top: 0.4rem;
|
||||
border-radius: 1rem;
|
||||
left: 0.2rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
</style>
|
||||
62
src/lib/components/CompulsoryTags.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Table } from "$lib";
|
||||
|
||||
interface Props {
|
||||
space: Table<"study_spaces">;
|
||||
}
|
||||
|
||||
const { space }: Props = $props();
|
||||
|
||||
const tagToColor: Record<string, string> = {
|
||||
"Many Outlets": "compulsoryTagGreen",
|
||||
"No Outlets": "compulsoryTagRed",
|
||||
"Some Outlets": "compulsoryTagYellow",
|
||||
"Good WiFi": "compulsoryTagGreen",
|
||||
"Bad/No WiFi": "compulsoryTagRed",
|
||||
"Moderate WiFi": "compulsoryTagYellow",
|
||||
"No WiFi": "compulsoryTagRed"
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="compulsoryTagGreen">{space.volume}</span>
|
||||
<span class={tagToColor[space.power]}>{space.power}</span>
|
||||
<span class={tagToColor[space.wifi]}>{space.wifi}</span>
|
||||
|
||||
<style>
|
||||
.compulsoryTagGreen {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #eaffeb;
|
||||
color: #2e4653;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.2rem;
|
||||
}
|
||||
.compulsoryTagYellow {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #ffffd4;
|
||||
color: #534b2e;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.2rem;
|
||||
}
|
||||
.compulsoryTagRed {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #ffcece;
|
||||
color: #532e2e;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.2rem;
|
||||
}
|
||||
</style>
|
||||
55
src/lib/components/Favourite.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
344
src/lib/components/Feedback.svelte
Normal file
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import type { Table } from "$lib";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/database.d.ts";
|
||||
import { availableStudySpaceTags, wifiTags, powerOutletTags, volumeTags } from "$lib";
|
||||
import { invalidate } from "$app/navigation";
|
||||
|
||||
type StudySpaceData = Omit<
|
||||
Table<"study_spaces">,
|
||||
"id" | "created_at" | "updated_at" | "building_location_old" | "building_location"
|
||||
> & {
|
||||
id?: string;
|
||||
building_location?: google.maps.places.PlaceResult;
|
||||
};
|
||||
interface Props {
|
||||
studySpaceData: StudySpaceData;
|
||||
supabase: SupabaseClient<Database>;
|
||||
hideFunc: () => void;
|
||||
}
|
||||
|
||||
const { studySpaceData, supabase, hideFunc }: Props = $props();
|
||||
let newStudySpaceData: StudySpaceData = $state({ ...studySpaceData });
|
||||
let uploading = $state(false);
|
||||
async function uploadFeedback() {
|
||||
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!");
|
||||
}
|
||||
|
||||
// Tag
|
||||
let tagFilter = $state("");
|
||||
let tagFilterElem = $state<HTMLInputElement>();
|
||||
let filteredTags = $derived(
|
||||
availableStudySpaceTags
|
||||
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
|
||||
.filter((tag) => !newStudySpaceData.tags.includes(tag))
|
||||
);
|
||||
let dropdownVisible = $state(false);
|
||||
|
||||
function deleteTag(tagName: string) {
|
||||
return () => {
|
||||
newStudySpaceData.tags = newStudySpaceData.tags.filter((tag) => tag !== tagName);
|
||||
};
|
||||
}
|
||||
|
||||
function addTag(tagName: string) {
|
||||
return () => {
|
||||
if (!newStudySpaceData.tags.includes(tagName)) {
|
||||
newStudySpaceData.tags.push(tagName);
|
||||
}
|
||||
tagFilter = "";
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay">
|
||||
<form
|
||||
onsubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
uploading = true;
|
||||
await uploadFeedback();
|
||||
uploading = false;
|
||||
hideFunc();
|
||||
}}
|
||||
class="feedbackContainer"
|
||||
>
|
||||
<h1 class="submitHeader">Update Tags</h1>
|
||||
|
||||
<div class="compulsoryTags">
|
||||
<div class="compulsoryContainer">
|
||||
<label for="volume">Sound level:</label>
|
||||
<select
|
||||
bind:value={newStudySpaceData.volume}
|
||||
name="volume"
|
||||
class="compulsoryTagSelect"
|
||||
>
|
||||
<option value="" disabled selected>How noisy is it?</option>
|
||||
{#each volumeTags as volumeTag (volumeTag)}
|
||||
<option value={volumeTag}>{volumeTag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="compulsoryContainer">
|
||||
<label for="powerOutlets">Power outlets:</label>
|
||||
<select
|
||||
bind:value={newStudySpaceData.power}
|
||||
name="poweOutlets"
|
||||
class="compulsoryTagSelect"
|
||||
>
|
||||
<option value="" disabled selected>Power outlets?</option>
|
||||
{#each powerOutletTags as powerOutletTag (powerOutletTag)}
|
||||
<option value={powerOutletTag}>{powerOutletTag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="compulsoryContainer">
|
||||
<label for="wifi">Wifi:</label>
|
||||
<select bind:value={newStudySpaceData.wifi} name="wifi" class="compulsoryTagSelect">
|
||||
<option value="" disabled selected>How's the wifi?</option>
|
||||
{#each wifiTags as wifiTag (wifiTag)}
|
||||
<option value={wifiTag}>{wifiTag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="tags">Additional tags:</label>
|
||||
<div class="tagDisplay">
|
||||
{#each newStudySpaceData.tags 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="Add 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>
|
||||
|
||||
<button disabled={uploading} class="submit">Submit</button>
|
||||
<button type="button" class="exit" aria-label="exit" onclick={hideFunc}
|
||||
><img src={crossUrl} alt="exit" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(8, 15, 18, 0.9);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.feedbackContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: #182125;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
|
||||
color: #eaffeb;
|
||||
position: absolute;
|
||||
translate: 0 -3.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
}
|
||||
|
||||
.submitHeader {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background-color: #189f5e;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.compulsoryTags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: none;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compulsoryContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: left;
|
||||
justify-content: top;
|
||||
border-radius: 0.5rem;
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
.compulsoryTagSelect {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
background: none;
|
||||
color: #eaffeb;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.compulsoryTagSelect option {
|
||||
background-color: #2e4653;
|
||||
color: #eaffeb;
|
||||
}
|
||||
</style>
|
||||
@@ -21,18 +21,22 @@
|
||||
display: flex;
|
||||
position: sticky;
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
height: 3.5rem;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(-77deg, #2e4653, #3a5b56);
|
||||
right: 0;
|
||||
background: linear-gradient(-77deg, #2e4653, #223a37);
|
||||
box-shadow: 0rem 0rem 0.5rem #182125;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -41,5 +45,6 @@
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
40
src/lib/components/OpeningTimes.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Table } from "$lib";
|
||||
import { formatTime } from "$lib";
|
||||
|
||||
interface Props {
|
||||
hours: Table<"study_space_hours">[];
|
||||
}
|
||||
|
||||
// Destructure hours with a safe default to avoid undefined
|
||||
const { hours }: Props = $props();
|
||||
|
||||
// Determine today's index (0 = Sunday, 6 = Saturday)
|
||||
const todayIndex = new Date().getDay();
|
||||
|
||||
// Find the hours entry matching today
|
||||
const todayHours = hours.find((h) => h.day_of_week === todayIndex);
|
||||
|
||||
// Compute the display string for opening times
|
||||
let openingDisplay = $state("");
|
||||
if (todayHours) {
|
||||
openingDisplay = todayHours.open_today_status
|
||||
? "Open All Day"
|
||||
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
|
||||
} else {
|
||||
openingDisplay = "Closed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="opening-times">
|
||||
<strong>Today's Opening Times:</strong>
|
||||
{openingDisplay}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.opening-times {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #eaffeb;
|
||||
}
|
||||
</style>
|
||||
146
src/lib/components/Report.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { reportTypes } from "$lib";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import Textarea from "./inputs/Textarea.svelte";
|
||||
import type { Table } from "$lib";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/database.d.ts";
|
||||
|
||||
interface Props {
|
||||
data: { supabase: SupabaseClient<Database> };
|
||||
studySpaceId: string;
|
||||
hideFunc: () => void;
|
||||
}
|
||||
|
||||
const { data, studySpaceId, hideFunc }: Props = $props();
|
||||
const { supabase } = $derived(data);
|
||||
|
||||
let uploading = $state(false);
|
||||
let reportData = $state<Omit<Table<"reports">, "id" | "created_at" | "updated_at">>({
|
||||
study_space_id: studySpaceId,
|
||||
type: "",
|
||||
content: ""
|
||||
});
|
||||
|
||||
async function uploadReport() {
|
||||
const { error: reportUploadError } = await supabase
|
||||
.from("reports")
|
||||
.insert(reportData)
|
||||
.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!");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay">
|
||||
<form
|
||||
onsubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
uploading = true;
|
||||
await uploadReport();
|
||||
uploading = false;
|
||||
hideFunc();
|
||||
}}
|
||||
class="reportContainer"
|
||||
>
|
||||
<h1 class="submitHeader">Submit a Report</h1>
|
||||
<p>What's the reason?</p>
|
||||
<select name="reportType" class="reportType" required bind:value={reportData.type}>
|
||||
<option value="" disabled selected>Report type</option>
|
||||
{#each reportTypes as reportType (reportType)}
|
||||
<option value={reportType}>{reportType}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label for="description">Briefly describe the problem:</label>
|
||||
<Textarea
|
||||
name="description"
|
||||
placeholder="Image is inappropriate..."
|
||||
rows={2}
|
||||
bind:value={reportData.content}
|
||||
/>
|
||||
<button
|
||||
disabled={!reportData.type || reportData.content?.length === 0 || uploading}
|
||||
class="submit">Submit</button
|
||||
>
|
||||
<button type="button" class="exit" aria-label="exit" onclick={hideFunc}
|
||||
><img src={crossUrl} alt="exit" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(8, 15, 18, 0.9);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.reportContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: #182125;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
|
||||
color: #eaffeb;
|
||||
position: absolute;
|
||||
translate: 0 -3.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
}
|
||||
|
||||
.submitHeader {
|
||||
width: 80%;
|
||||
}
|
||||
.reportType {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
background: none;
|
||||
color: #eaffeb;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reportType option {
|
||||
background-color: #2e4653;
|
||||
color: #eaffeb;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background-color: #189f5e;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import CompulsoryTags from "./CompulsoryTags.svelte";
|
||||
import Favourite from "./Favourite.svelte";
|
||||
import type { Table } from "$lib";
|
||||
|
||||
interface Props {
|
||||
space: Table<"study_spaces">;
|
||||
alt: string;
|
||||
imgSrc: string;
|
||||
description?: Snippet;
|
||||
href?: string;
|
||||
isFavourite: boolean;
|
||||
isAvailable?: boolean;
|
||||
onToggleFavourite?: () => void;
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
const { alt, imgSrc, description, href }: Props = $props();
|
||||
const { space, alt, imgSrc, href, isFavourite, onToggleFavourite, isAvailable, footer }: Props =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<a class="card" {href}>
|
||||
<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>
|
||||
<div class="description">
|
||||
{@render 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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="spacer"></div>
|
||||
{#if footer}
|
||||
<div class="footer">{footer}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -22,15 +49,99 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #38353f;
|
||||
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 {
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem;
|
||||
color: #edebe9;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
img {
|
||||
width: 16rem;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tagContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 0.25rem;
|
||||
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));
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import cameraUrl from "$lib/assets/camera.svg";
|
||||
import Carousel from "../Carousel.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@@ -7,9 +8,17 @@
|
||||
minHeight?: string;
|
||||
files?: FileList;
|
||||
required?: boolean;
|
||||
scrollPosition?: number;
|
||||
}
|
||||
|
||||
let { name, height, minHeight, files = $bindable(), ...rest }: Props = $props();
|
||||
let {
|
||||
name,
|
||||
height,
|
||||
minHeight,
|
||||
files = $bindable(),
|
||||
scrollPosition = $bindable(),
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<label
|
||||
@@ -18,14 +27,38 @@
|
||||
class:no-bg={files && files.length > 0}
|
||||
>
|
||||
{#if files && files.length > 0}
|
||||
<img src={URL.createObjectURL(files[0])} alt="uploaded study space" class="preview" />
|
||||
<Carousel
|
||||
urls={files
|
||||
? Array(files.length)
|
||||
.keys()
|
||||
.map((i) => URL.createObjectURL(files![i]))
|
||||
.toArray()
|
||||
: []}
|
||||
ondelete={(idx) => {
|
||||
if (!files) return;
|
||||
const dt = new DataTransfer();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (i !== idx) dt.items.add(files[i]);
|
||||
}
|
||||
files = dt.files;
|
||||
}}
|
||||
bind:scrollPosition
|
||||
/>
|
||||
{:else}
|
||||
<div class="message">
|
||||
<img src={cameraUrl} class="icon" alt="camera icon" />
|
||||
<span>Click to upload a photo</span>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="file" id={name} {name} accept=".png, .jpg, .jpeg, .svg" {...rest} bind:files />
|
||||
<input
|
||||
type="file"
|
||||
id={name}
|
||||
{name}
|
||||
multiple
|
||||
accept=".png, .jpg, .jpeg, .svg"
|
||||
{...rest}
|
||||
bind:files
|
||||
/>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
@@ -52,7 +85,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #49bd85;
|
||||
color: #189f5e;
|
||||
}
|
||||
.preview {
|
||||
max-height: 100%;
|
||||
131
src/lib/components/inputs/OpeningTimesDay.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<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>
|
||||
0
src/lib/components/inputs/TagFilter.svelte
Normal file
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
inputElem?: HTMLInputElement;
|
||||
name: string;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
type?: "text" | "password" | "email" | "number";
|
||||
maxlength?: number;
|
||||
}
|
||||
|
||||
let { value = $bindable(), name, ...rest }: Props = $props();
|
||||
let { inputElem = $bindable(), value = $bindable(), name, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="text" id={name} {name} bind:value {...rest} />
|
||||
<input id={name} {name} bind:value bind:this={inputElem} {...rest} />
|
||||
|
||||
<style>
|
||||
input {
|
||||
@@ -22,6 +25,11 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: #859a90;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #007bff;
|
||||
outline: none;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
let { value = $bindable(), name, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea id={name} {name} {...rest}></textarea>
|
||||
<textarea id={name} {name} bind:value {...rest}></textarea>
|
||||
|
||||
<style>
|
||||
textarea {
|
||||
@@ -25,6 +25,11 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: #859a90;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #007bff;
|
||||
outline: none;
|
||||
|
||||
171
src/lib/database.d.ts
vendored
@@ -34,6 +34,82 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
reports: {
|
||||
Row: {
|
||||
content: string | null
|
||||
created_at: string | null
|
||||
id: string
|
||||
study_space_id: string | null
|
||||
type: string
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
content?: string | null
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
study_space_id?: string | null
|
||||
type: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
content?: string | null
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
study_space_id?: string | null
|
||||
type?: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "reports_study_space_id_fkey"
|
||||
columns: ["study_space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "study_spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
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
|
||||
@@ -65,30 +141,105 @@ export type Database = {
|
||||
}
|
||||
study_spaces: {
|
||||
Row: {
|
||||
building_address: string | null
|
||||
building_location: Json | null
|
||||
building_location_old: string | null
|
||||
created_at: string | null
|
||||
description: string | null
|
||||
directions: string
|
||||
id: string
|
||||
location: string | null
|
||||
power: string
|
||||
tags: string[]
|
||||
updated_at: string | null
|
||||
volume: string
|
||||
wifi: string
|
||||
}
|
||||
Insert: {
|
||||
building_location?: Json | null
|
||||
building_location_old?: string | null
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
directions: string
|
||||
id?: string
|
||||
location?: string | null
|
||||
power: string
|
||||
tags?: string[]
|
||||
updated_at?: string | null
|
||||
volume: string
|
||||
wifi: string
|
||||
}
|
||||
Update: {
|
||||
building_location?: Json | null
|
||||
building_location_old?: string | null
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
directions?: string
|
||||
id?: string
|
||||
location?: string | null
|
||||
power?: string
|
||||
tags?: string[]
|
||||
updated_at?: string | null
|
||||
volume?: string
|
||||
wifi?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
users: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
is_admin: boolean
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id: string
|
||||
is_admin?: boolean
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_admin?: boolean
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
favourite_study_spaces: {
|
||||
Row: {
|
||||
user_id: string
|
||||
study_space_id: string
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
building_address?: string | null
|
||||
user_id: string
|
||||
study_space_id: string
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
location?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
building_address?: string | null
|
||||
user_id?: string
|
||||
study_space_id?: string
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
location?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
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: {
|
||||
|
||||
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;
|
||||
}
|
||||
107
src/lib/index.ts
@@ -1,5 +1,112 @@
|
||||
import { PUBLIC_GMAPS_API_KEY } from "$env/static/public";
|
||||
import type { Database } from "./database.d.ts";
|
||||
|
||||
export type Table<T extends keyof Database["public"]["Tables"]> =
|
||||
Database["public"]["Tables"][T]["Row"];
|
||||
export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
|
||||
|
||||
export const availableStudySpaceTags = [
|
||||
"Crowded",
|
||||
"Group study",
|
||||
"Food allowed",
|
||||
"No food allowed",
|
||||
"Well lit",
|
||||
"Poorly lit",
|
||||
"Whiteboard",
|
||||
"Restricted access",
|
||||
"Hot",
|
||||
"Air conditioned",
|
||||
"Cold",
|
||||
"PCs",
|
||||
"Rodent-ridden"
|
||||
];
|
||||
|
||||
export const volumeTags = ["Silent", "Some Noise", "Loud"];
|
||||
export const wifiTags = ["Good WiFi", "Moderate WiFi", "Bad/No WiFi"];
|
||||
export const powerOutletTags = ["Many Outlets", "Some Outlets", "No Outlets"];
|
||||
|
||||
export const allTags = [...availableStudySpaceTags, ...volumeTags, ...wifiTags, ...powerOutletTags];
|
||||
|
||||
export const reportTypes = [
|
||||
"Inappropriate content",
|
||||
"Duplicate content",
|
||||
"Incorrect content",
|
||||
"Other"
|
||||
];
|
||||
|
||||
export async function gmapsLoader() {
|
||||
const { Loader } = await import("@googlemaps/js-api-loader");
|
||||
return new Loader({
|
||||
apiKey: PUBLIC_GMAPS_API_KEY,
|
||||
version: "weekly",
|
||||
libraries: ["places"]
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(time: string) {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
const date = new Date();
|
||||
date.setHours(h, m);
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export const daysOfWeek = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"All Days",
|
||||
"All Other Days"
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
|
||||
export const load: LayoutServerLoad = async ({
|
||||
locals: { safeGetSession, supabase },
|
||||
cookies,
|
||||
depends
|
||||
}) => {
|
||||
depends("supabase:auth");
|
||||
const { session } = await safeGetSession();
|
||||
let adminMode = false;
|
||||
if (session) {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from("users")
|
||||
.select("*")
|
||||
.eq("id", session.user.id)
|
||||
.single();
|
||||
if (userError) {
|
||||
console.error("Failed to fetch user data:", userError);
|
||||
}
|
||||
if (userData?.is_admin) {
|
||||
adminMode = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
session,
|
||||
adminMode,
|
||||
cookies: cookies.getAll()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
<script lang="ts">
|
||||
import posthog from "posthog-js";
|
||||
import logoUrl from "$lib/assets/logo.svg";
|
||||
import { onMount } from "svelte";
|
||||
import { invalidate } from "$app/navigation";
|
||||
|
||||
const { children } = $props();
|
||||
let { data, children } = $props();
|
||||
let { session, supabase, route } = $derived(data);
|
||||
|
||||
onMount(() => {
|
||||
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
|
||||
api_host: "https://eu.i.posthog.com",
|
||||
person_profiles: "always"
|
||||
});
|
||||
const { data } = supabase.auth.onAuthStateChange((_, newSession) => {
|
||||
if (newSession?.expires_at !== session?.expires_at) {
|
||||
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");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -15,7 +48,8 @@
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
@@ -23,6 +57,10 @@
|
||||
color: #eaffeb;
|
||||
}
|
||||
|
||||
:global(body.coloured) {
|
||||
background: linear-gradient(-77deg, #2e4653, #223a37);
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
font-family: Inter;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/publi
|
||||
import type { Database } from "$lib/database";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
|
||||
export const load: LayoutLoad = async ({ data, url, route, depends, fetch }) => {
|
||||
/**
|
||||
* Declare a dependency so the layout can be invalidated, for example, on
|
||||
* session refresh.
|
||||
@@ -40,5 +40,12 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return { session, supabase, user };
|
||||
return {
|
||||
session,
|
||||
supabase,
|
||||
user,
|
||||
adminMode: data.adminMode,
|
||||
route,
|
||||
searchParams: url.searchParams.toString()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,10 +5,28 @@ export const load: PageServerLoad = async ({ depends, locals: { supabase } }) =>
|
||||
depends("db:study_spaces");
|
||||
const { data: studySpaces, error: err } = await supabase
|
||||
.from("study_spaces")
|
||||
.select("*, study_space_images(*)");
|
||||
.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 user’s 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
|
||||
studySpaces,
|
||||
session,
|
||||
favouriteIds
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,48 +2,351 @@
|
||||
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 } = $derived(data);
|
||||
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`;
|
||||
}
|
||||
|
||||
$inspect(sortedStudySpaces);
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
<a href="/space">
|
||||
{#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 studySpaces as studySpace (studySpace.id)}
|
||||
{@const imgUrl =
|
||||
studySpace.study_space_images.length > 0
|
||||
{#each sortedStudySpaces as studySpace (studySpace.id)}
|
||||
<SpaceCard
|
||||
alt="Photo of {studySpace.description}"
|
||||
href="/space/{studySpace.id}"
|
||||
imgSrc={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}
|
||||
>
|
||||
{#snippet description()}
|
||||
<p>{studySpace.description}</p>
|
||||
{/snippet}
|
||||
</SpaceCard>
|
||||
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>
|
||||
{#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: 1rem;
|
||||
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;
|
||||
width: min(600px, 100vw);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
14
src/routes/auth/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
|
||||
const { children } = $props();
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
<a href="/">
|
||||
<img src={crossUrl} alt="close" />
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
{@render children?.()}
|
||||
30
src/routes/auth/+page.server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { redirect, error } from "@sveltejs/kit";
|
||||
|
||||
import type { Actions } from "./$types";
|
||||
|
||||
export const actions: Actions = {
|
||||
signup: async ({ request, locals: { supabase } }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const { error: authError } = await supabase.auth.signUp({ email, password });
|
||||
if (authError) {
|
||||
error(400, "Failed to sign up: " + authError.message);
|
||||
} else {
|
||||
redirect(303, "/");
|
||||
}
|
||||
},
|
||||
login: async ({ request, locals: { supabase } }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const { error: authError } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (authError) {
|
||||
error(400, "Failed to log in: " + authError.message);
|
||||
} else {
|
||||
redirect(303, "/");
|
||||
}
|
||||
}
|
||||
};
|
||||
36
src/routes/auth/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import Text from "$lib/components/inputs/Text.svelte";
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/login">
|
||||
<label for="email">Email</label>
|
||||
<Text type="email" name="email" placeholder="your@email.com" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<Text type="password" name="password" placeholder="*********" />
|
||||
|
||||
<div class="actions">
|
||||
<Button type="submit">Login</Button>
|
||||
<Button formaction="?/signup">Signup</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 600px;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
label {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
margin-top: 0.5rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
362
src/routes/filter/+page.svelte
Normal file
@@ -0,0 +1,362 @@
|
||||
<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>
|
||||
@@ -1,118 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Text from "$lib/components/inputs/Text.svelte";
|
||||
import Textarea from "$lib/components/inputs/Textarea.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import Image from "$lib/components/inputs/Image.svelte";
|
||||
import type { Table } from "$lib";
|
||||
|
||||
const { data } = $props();
|
||||
const { supabase } = $derived(data);
|
||||
|
||||
let spaceImg = $state<FileList>();
|
||||
let studySpaceData = $state<Omit<Table<"study_spaces">, "id" | "created_at" | "updated_at">>({
|
||||
description: "",
|
||||
building_address: "",
|
||||
location: ""
|
||||
});
|
||||
|
||||
async function uploadStudySpace() {
|
||||
const imageFile = spaceImg?.[0];
|
||||
if (!imageFile) return alert("Please select an image file.");
|
||||
|
||||
const { data: studySpaceInsert, error: studySpaceError } = await supabase
|
||||
.from("study_spaces")
|
||||
.insert(studySpaceData)
|
||||
.select()
|
||||
.single();
|
||||
if (studySpaceError)
|
||||
return alert(`Error uploading study space: ${studySpaceError.message}`);
|
||||
|
||||
const { data: imgUpload, error: imageError } = await supabase.storage
|
||||
.from("files_bucket")
|
||||
.upload(`public/${studySpaceInsert.id}-${imageFile.name}`, imageFile, {
|
||||
contentType: imageFile.type
|
||||
});
|
||||
if (imageError) return alert(`Error uploading image: ${imageError.message}`);
|
||||
|
||||
const { error: imageInsertError } = await supabase
|
||||
.from("study_space_images")
|
||||
.insert({
|
||||
study_space_id: studySpaceInsert.id,
|
||||
image_path: imgUpload.path
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
|
||||
|
||||
alert("Thank you for your contribution!");
|
||||
// Redirect to the new study space page
|
||||
await goto(`/space/${studySpaceInsert.id}`, {
|
||||
invalidate: ["db:study_spaces"]
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
<a href="/">
|
||||
<img src={crossUrl} alt="close" />
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
<form
|
||||
onsubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await uploadStudySpace();
|
||||
}}
|
||||
>
|
||||
<Image name="study-space-image" minHeight="16rem" bind:files={spaceImg} />
|
||||
|
||||
<label for="location">Enter the name:</label>
|
||||
<Text name="location" bind:value={studySpaceData.location} placeholder="Room 123, Floor 1" />
|
||||
|
||||
<label for="description">Add a description:</label>
|
||||
<Textarea
|
||||
name="description"
|
||||
bind:value={studySpaceData.description}
|
||||
placeholder="A quiet, but small study space..."
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
<label for="address">Add an address:</label>
|
||||
<Text
|
||||
name="address"
|
||||
bind:value={studySpaceData.building_address}
|
||||
placeholder="180 Queen's Gate, London, SW7 5HF"
|
||||
/>
|
||||
|
||||
<div class="submit">
|
||||
<Button type="submit">Share this study space!</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #ffffff;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.submit {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
bottom: 0;
|
||||
margin-left: -0.5rem;
|
||||
width: calc(100% + 1rem);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
|
||||
export const load: PageServerLoad = async ({ depends, params, locals: { supabase } }) => {
|
||||
depends("db:study_spaces");
|
||||
const { data: space, error: err } = await supabase
|
||||
.from("study_spaces")
|
||||
.select("*, study_space_images(*)")
|
||||
.select("*, study_space_images(*), study_space_hours(*)")
|
||||
.eq("id", params.id)
|
||||
.single();
|
||||
if (err) error(500, "Failed to load study space");
|
||||
|
||||
@@ -2,17 +2,97 @@
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import placeholder from "$lib/assets/study_space.png";
|
||||
import Carousel from "$lib/components/Carousel.svelte";
|
||||
import CompulsoryTags from "$lib/components/CompulsoryTags.svelte";
|
||||
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 Button from "$lib/components/Button.svelte";
|
||||
import Favourite from "$lib/components/Favourite.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const { space, supabase } = $derived(data);
|
||||
const { space, supabase, adminMode } = $derived(data);
|
||||
|
||||
const imgUrl = $derived(
|
||||
space.study_space_images.length > 0
|
||||
? supabase.storage
|
||||
.from("files_bucket")
|
||||
.getPublicUrl(space.study_space_images[0].image_path).data.publicUrl
|
||||
: placeholder
|
||||
const place = $derived(space.building_location as google.maps.places.PlaceResult);
|
||||
const imgUrls = $derived(
|
||||
space.study_space_images.length === 0
|
||||
? [placeholder]
|
||||
: space.study_space_images.map(
|
||||
(img) =>
|
||||
supabase.storage.from("files_bucket").getPublicUrl(img.image_path).data
|
||||
.publicUrl
|
||||
)
|
||||
);
|
||||
|
||||
let isReportVisible = $state(false);
|
||||
function hideReport() {
|
||||
isReportVisible = false;
|
||||
}
|
||||
|
||||
let isFeedbackPromptVisible = $state(false);
|
||||
function hideFeedbackPrompt() {
|
||||
isFeedbackPromptVisible = false;
|
||||
}
|
||||
|
||||
let mapElem = $state<HTMLDivElement>();
|
||||
onMount(async () => {
|
||||
if (!mapElem) return console.error("Map element not found");
|
||||
const loader = await gmapsLoader();
|
||||
const { Map } = await loader.importLibrary("maps");
|
||||
const { AdvancedMarkerElement } = await loader.importLibrary("marker");
|
||||
const map = new Map(mapElem, {
|
||||
center: place.geometry?.location,
|
||||
zoom: 15,
|
||||
mapId: "9f4993cd3fb1504d495821a5"
|
||||
});
|
||||
new AdvancedMarkerElement({
|
||||
position: place.geometry?.location,
|
||||
map
|
||||
});
|
||||
});
|
||||
|
||||
let timingsPerDay = collectTimings(space.study_space_hours);
|
||||
|
||||
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 = "/";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
@@ -21,19 +101,111 @@
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
{#if isReportVisible}<Report {data} studySpaceId={space.id} hideFunc={hideReport} />
|
||||
{/if}
|
||||
{#if isFeedbackPromptVisible}
|
||||
<Feedback
|
||||
studySpaceData={{
|
||||
...space,
|
||||
building_location: place
|
||||
}}
|
||||
{supabase}
|
||||
hideFunc={hideFeedbackPrompt}
|
||||
/>
|
||||
{/if}
|
||||
<main>
|
||||
<img src={imgUrl} alt="the study space" />
|
||||
<div class="nameContainer">
|
||||
{space.location}
|
||||
<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>
|
||||
<div class="nameContainer">
|
||||
<div class="locationContainer">{space.location}</div>
|
||||
</div>
|
||||
{#if space.description != null && space.description.length > 0}
|
||||
<p class="descContainer">
|
||||
{space.description}
|
||||
</p>
|
||||
<hr />
|
||||
<div class="whereSubtitle">Where it is:</div>
|
||||
{/if}
|
||||
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
|
||||
{#if space.tags.length > 0}
|
||||
<div class="tagContainer">
|
||||
{#each space.tags as tag, idx (tag + idx)}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<hr />
|
||||
<div class="subtitle">Opening Times:</div>
|
||||
{#each Array(7).keys() as idx (idx)}
|
||||
{@const entries = timingsPerDay[idx]}
|
||||
<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>
|
||||
{:else}
|
||||
<span class="time">Not known</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<hr />
|
||||
|
||||
<div class="subtitle">Directions:</div>
|
||||
<p class="addrContainer">
|
||||
{space.building_address}
|
||||
{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)}
|
||||
{line.trim()} <br />
|
||||
{/each}
|
||||
</p>
|
||||
<div class="addrMap" bind:this={mapElem}></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="feedbackButton"
|
||||
onclick={() => {
|
||||
isFeedbackPromptVisible = true;
|
||||
}}
|
||||
>
|
||||
Update Tags
|
||||
</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>
|
||||
{:else}
|
||||
<Button onclick={() => (isReportVisible = true)} style="red">Report</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -58,19 +230,23 @@
|
||||
background-color: #2e3c42;
|
||||
width: 70%;
|
||||
border: none;
|
||||
margin: 0 auto;
|
||||
margin: 1rem auto 0;
|
||||
}
|
||||
|
||||
.nameContainer {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
margin-top: -0.5rem;
|
||||
object-position: center;
|
||||
background-color: #49bd85;
|
||||
background-color: #189f5e;
|
||||
border-radius: 8px;
|
||||
font-size: 2.8rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.descContainer {
|
||||
@@ -83,7 +259,7 @@
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.whereSubtitle {
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
@@ -92,6 +268,105 @@
|
||||
|
||||
.addrContainer {
|
||||
font-size: 1.2rem;
|
||||
padding: 0rem 1.4rem;
|
||||
padding: 0rem 1.4rem 1rem;
|
||||
}
|
||||
|
||||
.tagContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #2e4653;
|
||||
color: #eaffeb;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.compulsoryContainer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 1.4rem;
|
||||
font-size: 1.3rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.addrMap {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
}
|
||||
.feedbackButton {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background-color: #189f5e;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.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>
|
||||
|
||||
60
src/routes/space/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import type { Table } from "$lib";
|
||||
|
||||
type StudySpaceData = Omit<
|
||||
Table<"study_spaces">,
|
||||
"id" | "created_at" | "updated_at" | "building_location_old" | "building_location"
|
||||
> & {
|
||||
id?: string;
|
||||
building_location?: google.maps.places.PlaceResult;
|
||||
opening_times?: {
|
||||
day_of_week: number;
|
||||
opens_at: string;
|
||||
closes_at: string;
|
||||
open_today_status: boolean | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
|
||||
if (params.id === "new") {
|
||||
return {
|
||||
space: {
|
||||
description: "",
|
||||
directions: "",
|
||||
building_location: undefined,
|
||||
location: "",
|
||||
tags: [],
|
||||
volume: "",
|
||||
power: "",
|
||||
wifi: ""
|
||||
} as StudySpaceData
|
||||
};
|
||||
}
|
||||
|
||||
const { data: space, error: err } = await supabase
|
||||
.from("study_spaces")
|
||||
.select("*, study_space_images(*)")
|
||||
.eq("id", params.id)
|
||||
.single();
|
||||
if (err) error(500, "Failed to load study space");
|
||||
const studySpaceData = space as StudySpaceData & Partial<typeof space>;
|
||||
|
||||
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")
|
||||
.eq("study_space_id", params.id)
|
||||
.order("day_of_week", { ascending: true });
|
||||
if (hoursErr) error(500, "Failed to load opening times");
|
||||
studySpaceData.opening_times = hours;
|
||||
|
||||
delete studySpaceData.created_at;
|
||||
delete studySpaceData.updated_at;
|
||||
delete studySpaceData.study_space_images;
|
||||
|
||||
return {
|
||||
space: studySpaceData as StudySpaceData,
|
||||
images
|
||||
};
|
||||
};
|
||||
702
src/routes/space/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,702 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Text from "$lib/components/inputs/Text.svelte";
|
||||
import Textarea from "$lib/components/inputs/Textarea.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
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
|
||||
} from "$lib";
|
||||
import { onMount } from "svelte";
|
||||
import type { Json } from "$lib/database.js";
|
||||
|
||||
const { data } = $props();
|
||||
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[],
|
||||
...space
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!space) return;
|
||||
Object.assign(studySpaceData, space);
|
||||
studySpaceData.opening_times = space.opening_times ?? [];
|
||||
});
|
||||
|
||||
let scrollPosition = $state(0);
|
||||
const existingImages = $derived(
|
||||
Promise.all(
|
||||
images?.map(async ({ image_path }) => {
|
||||
const { data, error } = await supabase.storage
|
||||
.from("files_bucket")
|
||||
.download(image_path);
|
||||
if (error) {
|
||||
console.error(`Error downloading image ${image_path}:`, error);
|
||||
return null;
|
||||
}
|
||||
return { data, name: image_path.split("/").pop() || "image", type: data.type };
|
||||
}) || []
|
||||
)
|
||||
);
|
||||
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 { data: studySpaceInsert, error: studySpaceError } = await supabase
|
||||
.from("study_spaces")
|
||||
.upsert(
|
||||
{
|
||||
...spacePayload,
|
||||
building_location: studySpaceData.building_location as Json
|
||||
},
|
||||
{
|
||||
onConflict: "id"
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
if (studySpaceError)
|
||||
return alert(`Error uploading study space: ${studySpaceError.message}`);
|
||||
|
||||
const imgUploads = await Promise.all(
|
||||
Array(spaceImgs.length)
|
||||
.keys()
|
||||
.map(async (i) => {
|
||||
const imageFile = spaceImgs![i];
|
||||
const resp = await supabase.storage
|
||||
.from("files_bucket")
|
||||
.upload(
|
||||
`public/${studySpaceInsert.id}-${crypto.randomUUID()}-${imageFile.name}`,
|
||||
imageFile,
|
||||
{
|
||||
contentType: imageFile.type
|
||||
}
|
||||
);
|
||||
return resp;
|
||||
})
|
||||
);
|
||||
|
||||
const imageError = imgUploads.find(({ error }) => error)?.error;
|
||||
if (imageError) return alert(`Error uploading image: ${imageError.message}`);
|
||||
if (space.id) {
|
||||
const { error: imageOverwriteError } = await supabase
|
||||
.from("study_space_images")
|
||||
.delete()
|
||||
.eq("study_space_id", space.id);
|
||||
if (imageOverwriteError)
|
||||
return alert(`Error overwriting existing images: ${imageOverwriteError.message}`);
|
||||
}
|
||||
|
||||
const { error: imageInsertError } = await supabase
|
||||
.from("study_space_images")
|
||||
.insert(
|
||||
imgUploads.map(({ data }) => ({
|
||||
study_space_id: studySpaceInsert.id,
|
||||
image_path: data!.path
|
||||
}))
|
||||
)
|
||||
.select();
|
||||
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
|
||||
|
||||
const { error: deleteErr } = await supabase
|
||||
.from("study_space_hours")
|
||||
.delete()
|
||||
.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
|
||||
}
|
||||
});
|
||||
alert("Thank you for your contribution!");
|
||||
// Redirect to the new study space page
|
||||
await goto(`/space/${studySpaceInsert.id}`, {
|
||||
invalidate: ["db:study_spaces"]
|
||||
});
|
||||
}
|
||||
|
||||
// Tag
|
||||
let tagFilter = $state("");
|
||||
let tagFilterElem = $state<HTMLInputElement>();
|
||||
let filteredTags = $derived(
|
||||
availableStudySpaceTags
|
||||
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
|
||||
.filter((tag) => !studySpaceData.tags.includes(tag))
|
||||
);
|
||||
let dropdownVisible = $state(false);
|
||||
|
||||
function deleteTag(tagName: string) {
|
||||
return () => {
|
||||
studySpaceData.tags = studySpaceData.tags.filter((tag) => tag !== tagName);
|
||||
};
|
||||
}
|
||||
|
||||
function addTag(tagName: string) {
|
||||
return () => {
|
||||
if (!studySpaceData.tags.includes(tagName)) {
|
||||
studySpaceData.tags.push(tagName);
|
||||
}
|
||||
tagFilter = "";
|
||||
};
|
||||
}
|
||||
|
||||
let addressInput = $state<HTMLInputElement>();
|
||||
onMount(async () => {
|
||||
const loader = await gmapsLoader();
|
||||
const places = await loader.importLibrary("places");
|
||||
if (!addressInput) return console.error("Address input element not found");
|
||||
addressInput.value = studySpaceData.building_location?.formatted_address || "";
|
||||
if (studySpaceData.building_location?.name) {
|
||||
addressInput.value = `${studySpaceData.building_location.name}, ${addressInput.value}`;
|
||||
}
|
||||
const placeAutocomplete = new places.Autocomplete(addressInput, {
|
||||
componentRestrictions: { country: "gb" }
|
||||
});
|
||||
placeAutocomplete.addListener("place_changed", () => {
|
||||
studySpaceData.building_location = placeAutocomplete.getPlace();
|
||||
});
|
||||
});
|
||||
onMount(async () => {
|
||||
const images = await existingImages;
|
||||
const dt = new DataTransfer();
|
||||
images.forEach((response) => {
|
||||
if (response) {
|
||||
const file = new File([response.data], response.name, { type: response.type });
|
||||
dt.items.add(file);
|
||||
}
|
||||
});
|
||||
spaceImgs = dt.files;
|
||||
});
|
||||
// Opening times
|
||||
let allDays = $state({
|
||||
opens_at: "",
|
||||
closes_at: "",
|
||||
open_today_status: null
|
||||
});
|
||||
</script>
|
||||
|
||||
<Navbar>
|
||||
<a href="/">
|
||||
<img src={crossUrl} alt="close" />
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
<form
|
||||
onsubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
uploading = true;
|
||||
await uploadStudySpace();
|
||||
uploading = false;
|
||||
}}
|
||||
>
|
||||
<Images
|
||||
name="study-space-image"
|
||||
minHeight="16rem"
|
||||
bind:files={spaceImgs}
|
||||
bind:scrollPosition
|
||||
required
|
||||
/>
|
||||
{#if spaceImgs?.length || 0 > 0}
|
||||
<label class="additionalImages" for="additionalImages">
|
||||
Add more images
|
||||
<input
|
||||
type="file"
|
||||
name="additionalImages"
|
||||
id="additionalImages"
|
||||
multiple
|
||||
accept=".png, .jpg, .jpeg, .svg"
|
||||
onchange={function () {
|
||||
const dt = new DataTransfer();
|
||||
if (spaceImgs) {
|
||||
for (let i = 0; i < spaceImgs.length; i++) {
|
||||
dt.items.add(spaceImgs[i]);
|
||||
}
|
||||
}
|
||||
if (this.files) {
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
dt.items.add(this.files[i]);
|
||||
}
|
||||
}
|
||||
spaceImgs = dt.files;
|
||||
scrollPosition = dt.files.length - 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label for="location">Enter the name:</label>
|
||||
<Text
|
||||
name="location"
|
||||
bind:value={studySpaceData.location}
|
||||
placeholder="Huxeley Labs 225"
|
||||
maxlength={35}
|
||||
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>
|
||||
<select bind:value={studySpaceData.volume} name="volume" class="compulsoryTagSelect">
|
||||
<option value="" disabled selected>How noisy is it?</option>
|
||||
{#each volumeTags as volumeTag (volumeTag)}
|
||||
<option value={volumeTag}>{volumeTag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="compulsoryContainer">
|
||||
<label for="powerOutlets">Power outlets:</label>
|
||||
<select
|
||||
bind:value={studySpaceData.power}
|
||||
name="poweOutlets"
|
||||
class="compulsoryTagSelect"
|
||||
>
|
||||
<option value="" disabled selected>Power outlets?</option>
|
||||
{#each powerOutletTags as powerOutletTag (powerOutletTag)}
|
||||
<option value={powerOutletTag}>{powerOutletTag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="compulsoryContainer">
|
||||
<label for="wifi">Wifi:</label>
|
||||
<select bind:value={studySpaceData.wifi} name="wifi" class="compulsoryTagSelect">
|
||||
<option value="" disabled selected>How's the wifi?</option>
|
||||
{#each wifiTags as wifiTag (wifiTag)}
|
||||
<option value={wifiTag}>{wifiTag}</option>
|
||||
{/each}
|
||||
</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>
|
||||
<div class="tagDisplay">
|
||||
{#each studySpaceData.tags 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="Add 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>
|
||||
|
||||
<label for="description">Brief description (Optional):</label>
|
||||
<Textarea
|
||||
name="description"
|
||||
bind:value={studySpaceData.description}
|
||||
placeholder="A quiet room with a lovely view of the park."
|
||||
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"
|
||||
bind:inputElem={addressInput}
|
||||
placeholder="Huxley Building, Imperial South Kensington Campus"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="submit">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={(spaceImgs?.length || 0) === 0 ||
|
||||
!studySpaceData.location ||
|
||||
!studySpaceData.wifi ||
|
||||
!studySpaceData.volume ||
|
||||
!studySpaceData.power ||
|
||||
!studySpaceData.building_location ||
|
||||
uploading}
|
||||
>
|
||||
Share this study space!
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #ffffff;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.submit {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
bottom: 0;
|
||||
margin-left: -0.5rem;
|
||||
width: calc(100% + 1rem);
|
||||
}
|
||||
.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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.compulsoryTags {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: none;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.compulsoryContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: left;
|
||||
justify-content: top;
|
||||
border-radius: 0.5rem;
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
.compulsoryTagSelect {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
background: none;
|
||||
color: #eaffeb;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.compulsoryTagSelect option {
|
||||
background-color: #2e4653;
|
||||
color: #eaffeb;
|
||||
}
|
||||
.additionalImages {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(-83deg, #3fb095, #189f5e);
|
||||
box-shadow: 0rem 0rem 0.5rem #182125;
|
||||
color: #eaffeb;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.additionalImages:focus {
|
||||
outline: 2px solid #007bff;
|
||||
}
|
||||
.additionalImages input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Opening times layout and inputs styling */
|
||||
.allDaysTiming {
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.lengthPopup {
|
||||
background-color: #2e4653;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
14
src/routes/space/reports/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ depends, locals: { supabase } }) => {
|
||||
depends("db:reports");
|
||||
const { data: reports, error: err } = await supabase
|
||||
.from("reports")
|
||||
.select("*, study_spaces(location)");
|
||||
if (err) error(500, "Failed to load reports");
|
||||
|
||||
return {
|
||||
reports
|
||||
};
|
||||
};
|
||||
130
src/routes/space/reports/+page.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import crossUrl from "$lib/assets/cross.svg";
|
||||
import type { Table } from "$lib";
|
||||
const { data } = $props();
|
||||
const { reports, supabase } = $derived(data);
|
||||
import { invalidate } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let deleting = $state(false);
|
||||
|
||||
async function deleteReport(report: Table<"reports">) {
|
||||
const { error: reportDeleteError } = await supabase
|
||||
.from("reports")
|
||||
.delete()
|
||||
.eq("id", report.id);
|
||||
|
||||
if (reportDeleteError)
|
||||
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>
|
||||
<a href="/">
|
||||
<img src={crossUrl} alt="close" />
|
||||
</a>
|
||||
</Navbar>
|
||||
|
||||
<main>
|
||||
{#each reports as report (report.id)}
|
||||
<div class="reportContainer">
|
||||
<button
|
||||
type="button"
|
||||
class="deleteReport"
|
||||
aria-label="delete"
|
||||
disabled={deleting}
|
||||
onclick={async () => {
|
||||
deleting = true;
|
||||
await deleteReport(report);
|
||||
await invalidate("db:reports");
|
||||
deleting = false;
|
||||
}}><img src={crossUrl} alt="delete" /></button
|
||||
>
|
||||
<h1 class="submitHeader">
|
||||
{report.study_spaces?.location ?? "Study space doesn't exist"}
|
||||
</h1>
|
||||
<span class="tag">
|
||||
{report.type}
|
||||
</span>
|
||||
<p class="content">{report.content}</p>
|
||||
|
||||
<a href="/space/{report.study_space_id}" class="viewPage">View Space</a>
|
||||
</div>
|
||||
{/each}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
padding: 5rem 0 1rem 0;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
height: 200vh;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #2e4653;
|
||||
color: #eaffeb;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.6rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.reportContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: #182125;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
|
||||
color: #eaffeb;
|
||||
position: relative;
|
||||
translate: 0 -3.5rem;
|
||||
border: 2px solid #eaffeb;
|
||||
}
|
||||
|
||||
.viewPage {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background-color: #189f5e;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.deleteReport {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -2,3 +2,4 @@ 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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE study_spaces RENAME COLUMN "building_address" TO "building_location";
|
||||
3
supabase/migrations/20250605162005_add_tags.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
alter table "public"."study_spaces" add column "tags" text[] not null default ARRAY[]::text[];
|
||||
|
||||
|
||||
11
supabase/migrations/20250609142130_add-compulsory-tags.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
alter table "public"."study_spaces" add column "power" text;
|
||||
alter table "public"."study_spaces" add column "volume" text;
|
||||
alter table "public"."study_spaces" add column "wifi" text;
|
||||
|
||||
update "public"."study_spaces" set "power" = 'Many Outlets' where "power" is null;
|
||||
update "public"."study_spaces" set "volume" = 'Quiet' where "volume" is null;
|
||||
update "public"."study_spaces" set "wifi" = 'Good WiFi' where "wifi" is null;
|
||||
|
||||
alter table "public"."study_spaces" alter column "power" set not null;
|
||||
alter table "public"."study_spaces" alter column "volume" set not null;
|
||||
alter table "public"."study_spaces" alter column "wifi" set not null;
|
||||
3
supabase/migrations/20250609145614_gmaps_location.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- rename old colum nto building_location_old and create a new column instead of altering
|
||||
alter table "public"."study_spaces" rename column "building_location" to "building_location_old";
|
||||
alter table "public"."study_spaces" add column "building_location" jsonb;
|
||||
12
supabase/migrations/20250610163930_add_report_table.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE reports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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(),
|
||||
type text NOT NULL,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TRIGGER reports_updated_at
|
||||
AFTER UPDATE ON reports
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
@@ -0,0 +1,14 @@
|
||||
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), -- 0 = Sunday, 6 = Saturday
|
||||
opens_at TIME NOT NULL,
|
||||
closes_at TIME NOT NULL,
|
||||
is_24_7 BOOLEAN DEFAULT FALSE,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER study_space_hours_updated_at
|
||||
AFTER UPDATE ON study_space_hours
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
29
supabase/migrations/20250612104310_users-admin.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE TRIGGER users_handle_updated_at
|
||||
AFTER UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
|
||||
-- Auto-create users when auth.users are created
|
||||
CREATE FUNCTION handle_new_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.users (id, contact_email)
|
||||
VALUES (NEW.id, NEW.email);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER users_handle_new_user
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||
|
||||
12
supabase/migrations/20250612153946_fix-signup.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
$$;
|
||||
5
supabase/migrations/20250612170906_renamed-247.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
alter table "public"."study_space_hours" drop column "is_24_7";
|
||||
|
||||
alter table "public"."study_space_hours" add column "open_today_status" boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
alter table "public"."study_space_hours" alter column "day_of_week" set not null;
|
||||
|
||||
|
||||
12
supabase/migrations/20250613105939_favourites.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 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();
|
||||
1
supabase/migrations/20250613133130_directions.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE study_spaces ADD COLUMN IF NOT EXISTS directions text;
|
||||
@@ -9,9 +9,17 @@ 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,
|
||||
building_address text,
|
||||
-- Not bothered to write a proper data migration
|
||||
building_location_old text,
|
||||
building_location jsonb,
|
||||
tags text[] NOT NULL DEFAULT array[]::text[],
|
||||
volume text NOT NULL,
|
||||
wifi text NOT NULL,
|
||||
power text NOT NULL,
|
||||
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
@@ -24,6 +32,26 @@ CREATE TABLE study_space_images (
|
||||
PRIMARY KEY (study_space_id, image_path)
|
||||
);
|
||||
|
||||
CREATE TABLE reports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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(),
|
||||
type text NOT NULL,
|
||||
content text
|
||||
);
|
||||
|
||||
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
|
||||
opens_at TIME NOT NULL,
|
||||
closes_at TIME NOT NULL,
|
||||
open_today_status BOOLEAN,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Triggers
|
||||
CREATE TRIGGER study_spaces_updated_at
|
||||
AFTER UPDATE ON study_spaces
|
||||
@@ -32,3 +60,11 @@ FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
CREATE TRIGGER study_space_images_updated_at
|
||||
AFTER UPDATE ON study_space_images
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
|
||||
CREATE TRIGGER reports_updated_at
|
||||
AFTER UPDATE ON reports
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
|
||||
CREATE TRIGGER study_space_hours_updated_at
|
||||
AFTER UPDATE ON study_space_hours
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
|
||||
41
supabase/schemas/0001_users.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE TRIGGER users_handle_updated_at
|
||||
AFTER UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
|
||||
|
||||
-- Auto-create users when auth.users are created
|
||||
CREATE 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;
|
||||
$$;
|
||||
|
||||
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();
|
||||