41 Commits

Author SHA1 Message Date
Ling, Alex
c8eef97d99 Merge branch 'feat/admin-mode' into 'master'
Feat/admin mode

See merge request gk1623/drp-48!12

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
Co-authored-by: Gleb Koval <gleb@koval.net>
2025-06-11 18:00:54 +00:00
Barf-Vader
8d3a21498f fix: no longer can delete when deleting a report 2025-06-11 18:22:14 +01:00
Barf-Vader
6bae8bb361 feat: added edit button to admin mode 2025-06-11 18:06:04 +01:00
Barf-Vader
e7a7275af7 Merge remote-tracking branch 'origin/edit-mode' into feat/admin-mode 2025-06-11 17:57:18 +01:00
Barf-Vader
a33fba2cd6 feat: reports page 2025-06-11 17:56:45 +01:00
e2795ff257 feat: edit page 2025-06-11 17:53:55 +01:00
Temesgen, Tadios
2eceee2889 Merge branch 'feat/filter-by-tags' into 'master'
feat: Added filtering by optional tags in the main page. Created TagFilter...

See merge request gk1623/drp-48!10

Co-authored-by: TadiosT <tadios.temesgen@gmail.com>
2025-06-11 02:45:22 +00:00
TadiosT
348229691f Merge branch 'feat/filter-by-tags' of https://gitlab.doc.ic.ac.uk/gk1623/drp-48 into feat/filter-by-tags 2025-06-11 03:40:34 +01:00
TadiosT
3d95ea3763 fix: Changed filteredStudySpaces from function to string[].
Co-Authored By: Caspar Asaam caspar@dyn3159-95.wlan.ic.ac.uk
2025-06-11 03:40:20 +01:00
Temesgen, Tadios
d7fcf9d9ff Merge branch 'master' into 'feat/filter-by-tags'
# Conflicts:
#   src/lib/index.ts
2025-06-11 02:30:09 +00:00
TadiosT
e9d6db605a fix: Fix to filteredTags in main page. Removed use of TagFilter component.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-11 03:22:19 +01:00
Ling, Alex
7971e996fe Merge branch 'fix/overflow' into 'master'
Fix/overflow

See merge request gk1623/drp-48!11

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-11 00:11:09 +00:00
Barf-Vader
be921610f6 fix: overflow in report and space page 2025-06-11 01:06:39 +01:00
Barf-Vader
4ced9347bb fix: actually fixed overwlow in SpaceCard 2025-06-11 00:56:56 +01:00
871891c842 Merge branch 'google-maps-integration' into 'master'
feat: google maps

See merge request gk1623/drp-48!9
2025-06-10 23:18:56 +00:00
af08bc663b feat: add gmaps api key to CI 2025-06-11 00:14:02 +01:00
f7969432ec fix(lint): remove unused marker 2025-06-11 00:14:02 +01:00
bd7f9bc991 feat: google maps 2025-06-11 00:14:02 +01:00
Ling, Alex
e68cc4e0ea Merge branch 'feat/reports' into 'master'
feat: reports

See merge request gk1623/drp-48!8

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-10 23:11:10 +00:00
Barf-Vader
bdda39c7fe refactor: added typing for props, fixed overflow 2025-06-11 00:03:29 +01:00
2b73fe13bd feat: setup posthog for metrics (might or might not be useful) 2025-06-10 23:44:21 +01:00
Barf-Vader
4d8cffc726 fix: cant submit report while uploading 2025-06-10 18:30:09 +01:00
Barf-Vader
d982bf550e feat: reports 2025-06-10 18:25:42 +01:00
TadiosT
5eb7a9f58c feat: Centred the filtering bar in the main page.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-10 15:00:12 +01:00
TadiosT
61da21b7db fix: Style and type check changes.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-10 05:45:29 +01:00
TadiosT
201467c73a feat: Added filtering by compulsory tags and changed filtering logic.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-10 05:37:52 +01:00
TadiosT
19d451fa8e feat: Added filtering by optional tags in the main page. Created TagFilter component for modularisation.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-10 05:24:15 +01:00
Ling, Alex
b727665238 Merge branch 'compulsory-tags' into 'master'
Compulsory tags

See merge request gk1623/drp-48!7

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-09 18:30:56 +00:00
Barf-Vader
6a9f5ca21d refactor: Fixed minor typing issue with space 2025-06-09 19:26:57 +01:00
Barf-Vader
61915cd6a6 refactor: used a record for tag colouring 2025-06-09 19:16:55 +01:00
Barf-Vader
3cec498192 feat: added compulsory tags 2025-06-09 17:25:12 +01:00
Barf-Vader
f6f09c8492 Added compulsory tags in db table 2025-06-09 15:02:00 +01:00
769a20607b fix: remove individual study (usually implicit) 2025-06-06 04:29:44 +01:00
b12076fc53 feat: add "crowded", "well lit", "poorly lit" tags 2025-06-06 04:27:17 +01:00
Temesgen, Tadios
822beb8708 Merge branch 'feat/upload-tags' into 'master'
feat: added tags on upload and view tweaked navbar

See merge request gk1623/drp-48!6

Co-authored-by: Gleb Koval <gleb@koval.net>
Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-06 03:16:06 +00:00
6f798bc479 feat: display tags instead of description on main page 2025-06-06 04:11:22 +01:00
1f5054efbb feat: enter to select tag, and some fixes
fix: keep tag input focused on tag select (no delay hack)
fix: entire tag button clickable (padding within button rather than menu)
2025-06-06 04:11:22 +01:00
Barf-Vader
c4c8834d50 fix: added key to each block 2025-06-06 04:11:22 +01:00
Barf-Vader
e625afd3b4 feat: added tags on upload and view tweaked navbar 2025-06-06 04:11:22 +01:00
022d9089e0 fix: disallow duplicate creation
Contributes to #25
2025-06-06 03:26:15 +01:00
cf8d4725f7 fix: certain content can result in horizontal scroll 2025-06-06 03:25:02 +01:00
29 changed files with 1512 additions and 182 deletions

View File

@@ -1,2 +1,3 @@
PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
PUBLIC_GMAPS_API_KEY=your-google-maps-api-key-here

View File

@@ -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:

View File

@@ -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?

83
package-lock.json generated
View File

@@ -8,18 +8,22 @@
"name": "drp-48",
"version": "0.0.1",
"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"
},
"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",
@@ -818,6 +822,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@googlemaps/js-api-loader": {
"version": "1.16.8",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz",
"integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==",
"license": "Apache-2.0"
},
"node_modules/@googlemaps/typescript-guards": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@googlemaps/typescript-guards/-/typescript-guards-2.0.3.tgz",
"integrity": "sha512-3iHuO8H0jPehftsMK0kgyJzPYU/g/oiTRw+wu/yltqSZ7wJPt3vfsJHkPiuRpQjbnnWygX+T3mkRGyK/eyZ/lw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1724,6 +1741,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
"integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2451,6 +2475,17 @@
"node": ">= 0.6"
}
},
"node_modules/core-js": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz",
"integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3030,6 +3065,12 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4090,6 +4131,40 @@
"node": ">=4"
}
},
"node_modules/posthog-js": {
"version": "1.250.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.250.1.tgz",
"integrity": "sha512-Dl9BQjPa5FFVUauLSX/8HP6naRfU605GQYq+oAHFqwyv20NQDCHT0uRtFT1x2EYrY0kIQdd0d/rOY6Tg4wgqEg==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/preact": {
"version": "10.26.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz",
"integrity": "sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5117,6 +5192,12 @@
"node": ">= 8"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@@ -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"
}
}

View File

@@ -4,20 +4,27 @@
import { onMount } from "svelte";
interface Props {
scrollPosition?: number;
urls?: string[];
ondelete?: (idx: number) => void;
}
const { urls = [], ondelete }: Props = $props();
let { scrollPosition = $bindable(0), urls = [], ondelete }: Props = $props();
let carousel = $state<HTMLDivElement>();
let scrollPosition = $state(0);
let currentPosition = $state(0);
let scrollWidth = $state(0);
let clientWidth = $state(1);
function updateScroll() {
scrollPosition = carousel?.scrollLeft || 0;
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) {
@@ -41,7 +48,7 @@
</div>
{/each}
</div>
{#if scrollPosition > clientWidth / 2}
{#if currentPosition > clientWidth / 2}
<button
class="arrow left"
onclick={(e) => {
@@ -52,7 +59,7 @@
<img src={arrowRightUrl} alt="go to previous" />
</button>
{/if}
{#if scrollPosition < scrollWidth - clientWidth * 1.5}
{#if currentPosition < scrollWidth - clientWidth * 1.5}
<button
class="arrow right"
onclick={(e) => {
@@ -64,7 +71,7 @@
</button>
{/if}
<span class="position">
{Math.round(scrollPosition / clientWidth) + 1} / {urls.length}
{Math.round(currentPosition / clientWidth) + 1} / {urls.length}
</span>
</div>

View 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 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>

View File

@@ -21,11 +21,11 @@
display: flex;
position: sticky;
width: 100%;
height: 4rem;
height: 3.5rem;
top: 0;
left: 0;
right: 0;
background: linear-gradient(-77deg, #2e4653, #3a5b56);
background: linear-gradient(-77deg, #2e4653, #223a37);
box-shadow: 0rem 0rem 0.5rem #182125;
align-items: center;
overflow: hidden;

View File

@@ -0,0 +1,140 @@
<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();
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: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
.exit {
position: absolute;
top: 0.1rem;
right: 0.1rem;
background: none;
border: none;
cursor: pointer;
}
</style>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import CompulsoryTags from "./CompulsoryTags.svelte";
import type { Table } from "$lib";
interface Props {
@@ -15,7 +16,14 @@
<img src={imgSrc} {alt} />
<div class="description">
<h1>{space.location}</h1>
<p>{space.description}</p>
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0}
<div class="tagContainer">
{#each space.tags as tag (tag)}
<span class="tag">{tag}</span>
{/each}
</div>
{/if}
</div>
</a>
@@ -33,7 +41,6 @@
.description {
padding: 0.5rem;
color: #edebe9;
font-size: 0.875rem;
}
img {
width: 100%;
@@ -41,4 +48,37 @@
aspect-ratio: 1 / 1;
object-fit: cover;
}
h1 {
margin-bottom: 0.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;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.875rem;
cursor: pointer;
padding: 0.2rem 0.6rem;
}
.compulsoryContainer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(3.75rem, 1fr));
gap: 0.3rem;
font-size: 0.8rem;
}
</style>

View File

@@ -8,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
@@ -34,6 +42,7 @@
}
files = dt.files;
}}
bind:scrollPosition
/>
{:else}
<div class="message">

View File

@@ -1,15 +1,16 @@
<script lang="ts">
interface Props {
inputElem?: HTMLInputElement;
name: string;
value?: string | null;
placeholder?: string;
required?: boolean;
}
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 type="text" id={name} {name} bind:value bind:this={inputElem} {...rest} />
<style>
input {
@@ -22,6 +23,11 @@
font-size: 1rem;
}
::placeholder {
color: #859a90;
opacity: 1;
}
input:focus {
border-color: #007bff;
outline: none;

View File

@@ -25,6 +25,11 @@
font-size: 1rem;
}
::placeholder {
color: #859a90;
opacity: 1;
}
textarea:focus {
border-color: #007bff;
outline: none;

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

@@ -34,6 +34,41 @@ 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_images: {
Row: {
created_at: string | null
@@ -65,28 +100,43 @@ export type Database = {
}
study_spaces: {
Row: {
building_location: string | null
building_location: Json | null
building_location_old: string | null
created_at: string | null
description: string | null
id: string
location: string | null
power: string
tags: string[]
updated_at: string | null
volume: string
wifi: string
}
Insert: {
building_location?: string | null
building_location?: Json | null
building_location_old?: string | null
created_at?: string | null
description?: string | null
id?: string
location?: string | null
power: string
tags?: string[]
updated_at?: string | null
volume: string
wifi: string
}
Update: {
building_location?: string | null
building_location?: Json | null
building_location_old?: string | null
created_at?: string | null
description?: string | null
id?: string
location?: string | null
power?: string
tags?: string[]
updated_at?: string | null
volume?: string
wifi?: string
}
Relationships: []
}

View File

@@ -1,3 +1,4 @@
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"]> =
@@ -5,23 +6,40 @@ export type Table<T extends keyof Database["public"]["Tables"]> =
export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
export const availableStudySpaceTags = [
"Quiet",
"Loud",
"Silent",
"Crowded",
"Group study",
"Individual study",
"Power outlets",
"No power outlets",
"24/7",
"Food allowed",
"No food allowed",
"Good wifi",
"Bad wifi",
"No wifi",
"Well lit",
"Poorly lit",
"Whiteboard",
"Restricted access",
"Hot",
"Air conditioned",
"Cold",
"PCs",
"Cringe"
];
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"]
});
}

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import posthog from "posthog-js";
import logoUrl from "$lib/assets/logo.svg";
import { onMount } from "svelte";
const { children } = $props();
onMount(() => {
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
api_host: "https://eu.i.posthog.com",
person_profiles: "always"
});
});
</script>
<svelte:head>
@@ -15,7 +24,7 @@
:global(body) {
margin: 0;
padding: 0;
width: 100vw;
width: 100%;
}
:global(html) {

View File

@@ -3,19 +3,113 @@
import defaultImg from "$lib/assets/study_space.png";
import crossUrl from "$lib/assets/cross.svg";
import Navbar from "$lib/components/Navbar.svelte";
import { allTags } from "$lib";
const { data } = $props();
const { studySpaces, supabase } = $derived(data);
let selectedTags = $state<string[]>([]);
let tagFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
let filteredTags = $derived(
allTags
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
.filter((tag) => allTags.includes(tag))
);
let filteredStudySpaces = $derived(
selectedTags.length === 0
? studySpaces
: studySpaces.filter((space) => {
const allTags = [
...(space.tags || []),
space.volume,
space.wifi,
space.power
].filter(Boolean);
return selectedTags.every((tag) => allTags.includes(tag));
})
);
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 = "";
};
}
</script>
<Navbar>
<a href="/space">
<a href="/space/new/edit">
<img src={crossUrl} alt="new" class="new-space" />
</a>
</Navbar>
<main>
{#each studySpaces as studySpace (studySpace.id)}
<a href="/space/reports" class="checkReports">Check Reports</a>
<div class="tag-filter-container">
<form>
<div class="tagDisplay">
{#each selectedTags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
{tagName}
<img src={crossUrl} alt="delete" /></button
>
{/each}
<input
type="text"
name="tagInput"
class="tagInput"
bind:value={tagFilter}
bind:this={tagFilterElem}
onfocus={() => {
dropdownVisible = true;
}}
onblur={() => {
dropdownVisible = false;
}}
onkeypress={(event) => {
if (event.key === "Enter") {
const tag = filteredTags[0];
if (tag) addTag(tag)();
}
}}
placeholder="Search by tags..."
/>
{#if dropdownVisible}
<div class="tagDropdown">
{#each filteredTags as avaliableTag (avaliableTag)}
<button
class="avaliableTag"
onclick={addTag(avaliableTag)}
onmousedown={(e) => {
// Keep input focused
e.preventDefault();
e.stopPropagation();
}}
type="button"
>
{avaliableTag}
</button>
{/each}
</div>
{/if}
</div>
</form>
</div>
{#each filteredStudySpaces as studySpace (studySpace.id)}
{@const imgUrl =
studySpace.study_space_images.length > 0
? supabase.storage
@@ -47,6 +141,112 @@
transform: rotate(45deg);
}
.tag-filter-container {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
form {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.5rem;
max-width: 32rem;
margin: 0 auto;
}
.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;
}
.checkReports {
grid-column: 1 / -1;
display: block;
text-align: center;
color: #ffeaea;
font-size: 1.2rem;
text-decoration: none;
padding: 0.5rem;
background-color: #bd4949;
border-radius: 0.5rem;
}
@media (max-width: 20rem) {
main {
grid-template-columns: 1fr;

View File

@@ -1,142 +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 Images from "$lib/components/inputs/Images.svelte";
import type { Table } from "$lib";
const { data } = $props();
const { supabase } = $derived(data);
let spaceImgs = $state<FileList>();
let studySpaceData = $state<Omit<Table<"study_spaces">, "id" | "created_at" | "updated_at">>({
description: "",
building_location: "",
location: ""
});
async function uploadStudySpace() {
if (!spaceImgs || spaceImgs.length < 1) 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 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}-${imageFile.name}`, imageFile, {
contentType: imageFile.type
});
return resp;
})
);
const imageError = imgUploads.find(({ error }) => error)?.error;
if (imageError) return alert(`Error uploading image: ${imageError.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}`);
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();
}}
>
<Images name="study-space-image" minHeight="16rem" bind:files={spaceImgs} required />
<label for="location">Enter the name:</label>
<Text
name="location"
bind:value={studySpaceData.location}
placeholder="Room 123, Floor 1"
required
/>
<label for="description">Add a description:</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
placeholder="A quiet and well-lit, but small space..."
rows={5}
required
/>
<label for="building-location">Add the building location:</label>
<Text
name="building-location"
bind:value={studySpaceData.building_location}
placeholder="Huxley Building, Imperial South Kensington Campus"
required
/>
<div class="submit">
<Button
type="submit"
disabled={(spaceImgs?.length || 0) === 0 ||
!studySpaceData.location ||
!studySpaceData.description ||
!studySpaceData.building_location}
>
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>

View File

@@ -3,10 +3,17 @@
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 { onMount } from "svelte";
import { gmapsLoader } from "$lib";
const { data } = $props();
const { space, supabase } = $derived(data);
let adminMode = $state(true);
const place = $derived(space.building_location as google.maps.places.PlaceResult);
const imgUrls = $derived(
space.study_space_images.length === 0
? [placeholder]
@@ -16,6 +23,28 @@
.publicUrl
)
);
let isReportVisible = $state(false);
function hideFunc() {
isReportVisible = 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
});
});
</script>
<Navbar>
@@ -24,19 +53,51 @@
</a>
</Navbar>
{#if isReportVisible}<Report {data} studySpaceId={space.id} {hideFunc} />
{/if}
<main>
<Carousel urls={imgUrls} />
<div class="nameContainer">
{space.location}
</div>
<p class="descContainer">
{space.description}
</p>
{#if space.description != null && space.description.length > 0}
<p class="descContainer">
{space.description}
</p>
<hr />
{/if}
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0}
<div class="tagContainer">
{#each space.tags as tag (tag)}
<span class="tag">
{tag}
</span>
{/each}
</div>
{/if}
<hr />
<div class="whereSubtitle">Where it is:</div>
<div class="subtitle">Where it is:</div>
<p class="addrContainer">
{space.building_location}
{#if place.name}
{place.name} <br />
{/if}
{#each place.formatted_address?.split(",") || [] as line (line)}
{line.trim()} <br />
{/each}
</p>
<div class="addrMap" bind:this={mapElem}></div>
{#if adminMode}
<a href={`/space/${space.id}/edit`} class="editButton">Edit</a>
{:else}
<button
type="button"
class="reportButton"
onclick={() => {
isReportVisible = true;
}}>Report</button
>
{/if}
</main>
<style>
@@ -87,7 +148,7 @@
font-size: 1.2rem;
}
.whereSubtitle {
.subtitle {
font-size: 1.2rem;
font-weight: bold;
color: #ffffff;
@@ -96,6 +157,67 @@
.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;
}
.reportButton {
width: 100%;
padding: 0.4rem;
border-radius: 0.5rem;
border: none;
background-color: #bd4949;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.editButton {
width: 100%;
padding: 0.4rem;
border-radius: 0.5rem;
border: none;
background-color: #49bd85;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
text-decoration: none;
text-align: center;
}
</style>

View File

@@ -0,0 +1,46 @@
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;
};
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
if (params.id === "new") {
return {
space: {
description: "",
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 || [];
delete studySpaceData.created_at;
delete studySpaceData.updated_at;
delete studySpaceData.study_space_images;
return {
space: studySpaceData as StudySpaceData,
images
};
};

View File

@@ -0,0 +1,491 @@
<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 {
availableStudySpaceTags,
wifiTags,
powerOutletTags,
volumeTags,
gmapsLoader
} 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);
const studySpaceData = $state({
...space
});
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);
async function uploadStudySpace() {
if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file.");
if (!studySpaceData.building_location) return alert("Please select a building location.");
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
.upsert(
{
...studySpaceData,
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}`);
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;
});
</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="Room 123, Floor 1"
required
/>
<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="tags">Additional tags:</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) => {
event.preventDefault();
if (event.key === "Enter") {
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">Optional brief description:</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
placeholder="A quiet room with a lovely view of the park."
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, #49bd85);
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;
}
</style>

View 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
};
};

View File

@@ -0,0 +1,119 @@
<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";
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!");
}
</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: #49bd85;
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>

View File

@@ -0,0 +1,3 @@
alter table "public"."study_spaces" add column "tags" text[] not null default ARRAY[]::text[];

View 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;

View 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;

View 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();

View File

@@ -11,8 +11,13 @@ CREATE TABLE study_spaces (
description text,
-- Location within building, e.g., "Room 101"
location text,
building_location 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()
);
@@ -25,6 +30,14 @@ 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
);
-- Triggers
CREATE TRIGGER study_spaces_updated_at
@@ -34,3 +47,7 @@ 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();