60 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
ebf33d47a2 Merge branch 'feat/multiple-images' into 'master'
feat: multi-image uploads

See merge request gk1623/drp-48!5
2025-06-05 16:53:16 +00:00
Ling, Alex
ff414f242d Merge branch 'tags-setup' into 'master'
feat: Added avaliable tags and tag column to table

See merge request gk1623/drp-48!4

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
Co-authored-by: Gleb Koval <gleb@koval.net>
2025-06-05 16:49:36 +00:00
485063f8d2 feat: multi-image uploads 2025-06-05 17:47:08 +01:00
Barf-Vader
0e074e9301 feat: Added avaliable tags and tag column to table
Coauthored-by: gk1623
2025-06-05 17:38:49 +01:00
6e45851892 feat: carousel image display 2025-06-05 17:01:03 +01:00
d4a9d5559e feat: logos
Co-authored-by: Barf-Vader <Barf-Vader@users.noreply.github.com>
2025-06-05 17:00:55 +01:00
a180e49466 feat: disable submit until valid 2025-06-05 14:48:28 +01:00
Caspar Jojo Asaam
a03a80a186 Merge branch 'feat/study-card-visual-improvements' into 'master'
feat: improved on ui so that user can see the spaces they want to view easier

See merge request gk1623/drp-48!3

Co-authored-by: Gleb Koval <gleb@koval.net>
Co-authored-by: Caspar Jojo Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-05 11:03:37 +00:00
11a040a677 fix: don't stretch image in spacecard 2025-06-05 11:54:37 +01:00
f85adf9edc fix: remove navbar overflow on main page 2025-06-05 11:53:10 +01:00
Caspar Jojo Asaam
6e72580a6a refactor: allowed for SpaceCard.svelte to take in study_spaces Table instead of Snippets
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:43:04 +01:00
Caspar Jojo Asaam
1d1bd940bf feat: Added rounded corners and removed blue underlining
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:25:02 +01:00
Caspar Jojo Asaam
f9878d1e48 feat: adjusted the cards such that it's only one column for very small widths and the cards resize for smaller widths
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 11:16:31 +01:00
Caspar Jojo Asaam
4ee33398c1 Merge branch 'feat/space-card-style-change' into 'master'
feat: changed card style and formatting to improve clarity for users

See merge request gk1623/drp-48!2

Co-authored-by: Caspar Jojo Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-05 00:33:54 +00:00
Caspar Jojo Asaam
55d9646b07 feat: changed card style and formatting to improve clarity for users
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-05 01:26:45 +01:00
b49f937dcb chore: rename address to building location 2025-06-04 23:35:02 +01:00
c1de092525 fix: require all study space fields 2025-06-04 21:00:25 +01:00
2ef82a5d41 fix: bind textarea value 2025-06-04 18:46:44 +01:00
b17b9ddb82 Merge branch 'feat/initial-study-space-view' into 'master'
feat: initial uploads and single study space view

See merge request gk1623/drp-48!1
2025-06-04 17:20:17 +00:00
33 changed files with 1755 additions and 167 deletions

View File

@@ -1,2 +1,3 @@
PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 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: variables:
PUBLIC_SUPABASE_URL: $SUPABASE_URL PUBLIC_SUPABASE_URL: $SUPABASE_URL
PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY
PUBLIC_GMAPS_API_KEY: $GMAPS_API_KEY
script: script:
- npm run check - npm run check
@@ -72,6 +73,7 @@ build:
variables: variables:
PUBLIC_SUPABASE_URL: $SUPABASE_URL PUBLIC_SUPABASE_URL: $SUPABASE_URL
PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY PUBLIC_SUPABASE_ANON_KEY: $SUPABASE_ANON_KEY
PUBLIC_GMAPS_API_KEY: $GMAPS_API_KEY
script: script:
- npm run build - npm run build
artifacts: 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 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 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 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? ### What's where?

83
package-lock.json generated
View File

@@ -8,18 +8,22 @@
"name": "drp-48", "name": "drp-48",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@googlemaps/js-api-loader": "^1.16.8",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8" "@supabase/supabase-js": "^2.49.8",
"posthog-js": "^1.250.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@googlemaps/typescript-guards": "^2.0.3",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4", "@testing-library/svelte": "^5.2.4",
"@types/google.maps": "^3.58.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
@@ -818,6 +822,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1724,6 +1741,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2451,6 +2475,17 @@
"node": ">= 0.6" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3030,6 +3065,12 @@
"node": "^12.20 || >= 14.13" "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4090,6 +4131,40 @@
"node": ">=4" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5117,6 +5192,12 @@
"node": ">= 8" "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": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@@ -20,12 +20,14 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@googlemaps/typescript-guards": "^2.0.3",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4", "@testing-library/svelte": "^5.2.4",
"@types/google.maps": "^3.58.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
@@ -42,7 +44,9 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"@googlemaps/js-api-loader": "^1.16.8",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8" "@supabase/supabase-js": "^2.49.8",
"posthog-js": "^1.250.1"
} }
} }

View 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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -27,4 +27,8 @@
button:focus { button:focus {
outline: 2px solid #007bff; outline: 2px solid #007bff;
} }
button:disabled {
background: linear-gradient(-18deg, #66697b, #4e4e5e);
cursor: not-allowed;
}
</style> </style>

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

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,18 +21,22 @@
display: flex; display: flex;
position: sticky; position: sticky;
width: 100%; width: 100%;
height: 4rem; height: 3.5rem;
top: 0; top: 0;
left: 0; left: 0;
background: linear-gradient(-77deg, #2e4653, #3a5b56); right: 0;
background: linear-gradient(-77deg, #2e4653, #223a37);
box-shadow: 0rem 0rem 0.5rem #182125; box-shadow: 0rem 0rem 0.5rem #182125;
align-items: center; align-items: center;
overflow: hidden;
z-index: 100;
} }
.logo { .logo {
display: block; display: block;
height: 100%; height: 100%;
} }
.logo img { .logo img {
height: 100%; height: 100%;
} }

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,20 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import CompulsoryTags from "./CompulsoryTags.svelte";
import type { Table } from "$lib";
interface Props { interface Props {
space: Table<"study_spaces">;
alt: string; alt: string;
imgSrc: string; imgSrc: string;
description?: Snippet;
href?: string; href?: string;
} }
const { alt, imgSrc, description, href }: Props = $props(); const { space, alt, imgSrc, href }: Props = $props();
</script> </script>
<a class="card" {href}> <a class="card" {href}>
<img src={imgSrc} {alt} /> <img src={imgSrc} {alt} />
<div class="description"> <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">{tag}</span>
{/each}
</div>
{/if}
</div> </div>
</a> </a>
@@ -22,15 +31,54 @@
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #38353f; background-color: #49bd85;
width: 100%;
max-width: 20rem;
border-radius: 0.5rem;
overflow: hidden;
text-decoration: none;
} }
.description { .description {
padding: 0.5rem; padding: 0.5rem;
color: #edebe9; color: #edebe9;
font-size: 0.875rem;
} }
img { img {
width: 16rem; width: 100%;
height: auto;
aspect-ratio: 1 / 1; 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> </style>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import cameraUrl from "$lib/assets/camera.svg"; import cameraUrl from "$lib/assets/camera.svg";
import Carousel from "../Carousel.svelte";
interface Props { interface Props {
name: string; name: string;
@@ -7,9 +8,17 @@
minHeight?: string; minHeight?: string;
files?: FileList; files?: FileList;
required?: boolean; 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> </script>
<label <label
@@ -18,14 +27,38 @@
class:no-bg={files && files.length > 0} class:no-bg={files && files.length > 0}
> >
{#if 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} {:else}
<div class="message"> <div class="message">
<img src={cameraUrl} class="icon" alt="camera icon" /> <img src={cameraUrl} class="icon" alt="camera icon" />
<span>Click to upload a photo</span> <span>Click to upload a photo</span>
</div> </div>
{/if} {/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> </label>
<style> <style>

View File

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

View File

@@ -11,7 +11,7 @@
let { value = $bindable(), name, ...rest }: Props = $props(); let { value = $bindable(), name, ...rest }: Props = $props();
</script> </script>
<textarea id={name} {name} {...rest}></textarea> <textarea id={name} {name} bind:value {...rest}></textarea>
<style> <style>
textarea { textarea {
@@ -25,6 +25,11 @@
font-size: 1rem; font-size: 1rem;
} }
::placeholder {
color: #859a90;
opacity: 1;
}
textarea:focus { textarea:focus {
border-color: #007bff; border-color: #007bff;
outline: none; outline: none;

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

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

View File

@@ -1,5 +1,45 @@
import { PUBLIC_GMAPS_API_KEY } from "$env/static/public";
import type { Database } from "./database.d.ts"; import type { Database } from "./database.d.ts";
export type Table<T extends keyof Database["public"]["Tables"]> = export type Table<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Row"]; Database["public"]["Tables"][T]["Row"];
export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T]; export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
export const availableStudySpaceTags = [
"Crowded",
"Group study",
"24/7",
"Food allowed",
"No food allowed",
"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"> <script lang="ts">
import posthog from "posthog-js";
import logoUrl from "$lib/assets/logo.svg"; import logoUrl from "$lib/assets/logo.svg";
import { onMount } from "svelte";
const { children } = $props(); const { children } = $props();
onMount(() => {
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
api_host: "https://eu.i.posthog.com",
person_profiles: "always"
});
});
</script> </script>
<svelte:head> <svelte:head>
@@ -15,7 +24,7 @@
:global(body) { :global(body) {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100vw; width: 100%;
} }
:global(html) { :global(html) {

View File

@@ -3,19 +3,113 @@
import defaultImg from "$lib/assets/study_space.png"; import defaultImg from "$lib/assets/study_space.png";
import crossUrl from "$lib/assets/cross.svg"; import crossUrl from "$lib/assets/cross.svg";
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import { allTags } from "$lib";
const { data } = $props(); const { data } = $props();
const { studySpaces, supabase } = $derived(data); 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> </script>
<Navbar> <Navbar>
<a href="/space"> <a href="/space/new/edit">
<img src={crossUrl} alt="new" class="new-space" /> <img src={crossUrl} alt="new" class="new-space" />
</a> </a>
</Navbar> </Navbar>
<main> <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 = {@const imgUrl =
studySpace.study_space_images.length > 0 studySpace.study_space_images.length > 0
? supabase.storage ? supabase.storage
@@ -26,24 +120,136 @@
alt="Photo of {studySpace.description}" alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}" href="/space/{studySpace.id}"
imgSrc={imgUrl} imgSrc={imgUrl}
> space={studySpace}
{#snippet description()} />
<p>{studySpace.description}</p>
{/snippet}
</SpaceCard>
{/each} {/each}
</main> </main>
<style> <style>
main { main {
display: grid; display: grid;
box-sizing: border-box;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
width: min(600px, 100vw); max-width: 600px;
width: 100%;
margin: 0 auto;
} }
.new-space { .new-space {
transform: rotate(45deg); 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;
}
}
</style> </style>

View File

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

View File

@@ -2,17 +2,49 @@
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg"; import crossUrl from "$lib/assets/cross.svg";
import placeholder from "$lib/assets/study_space.png"; 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 { data } = $props();
const { space, supabase } = $derived(data); const { space, supabase } = $derived(data);
const imgUrl = $derived( let adminMode = $state(true);
space.study_space_images.length > 0
? supabase.storage const place = $derived(space.building_location as google.maps.places.PlaceResult);
.from("files_bucket") const imgUrls = $derived(
.getPublicUrl(space.study_space_images[0].image_path).data.publicUrl space.study_space_images.length === 0
: placeholder ? [placeholder]
: space.study_space_images.map(
(img) =>
supabase.storage.from("files_bucket").getPublicUrl(img.image_path).data
.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> </script>
<Navbar> <Navbar>
@@ -21,19 +53,51 @@
</a> </a>
</Navbar> </Navbar>
{#if isReportVisible}<Report {data} studySpaceId={space.id} {hideFunc} />
{/if}
<main> <main>
<img src={imgUrl} alt="the study space" /> <Carousel urls={imgUrls} />
<div class="nameContainer"> <div class="nameContainer">
{space.location} {space.location}
</div> </div>
<p class="descContainer"> {#if space.description != null && space.description.length > 0}
{space.description} <p class="descContainer">
</p> {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 /> <hr />
<div class="whereSubtitle">Where it is:</div> <div class="subtitle">Where it is:</div>
<p class="addrContainer"> <p class="addrContainer">
{space.building_address} {#if place.name}
{place.name} <br />
{/if}
{#each place.formatted_address?.split(",") || [] as line (line)}
{line.trim()} <br />
{/each}
</p> </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> </main>
<style> <style>
@@ -61,6 +125,7 @@
margin: 0 auto; margin: 0 auto;
} }
.nameContainer { .nameContainer {
z-index: 10;
display: block; display: block;
width: 100%; width: 100%;
padding: 0.6rem; padding: 0.6rem;
@@ -83,7 +148,7 @@
font-size: 1.2rem; font-size: 1.2rem;
} }
.whereSubtitle { .subtitle {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
@@ -92,6 +157,67 @@
.addrContainer { .addrContainer {
font-size: 1.2rem; 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> </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 @@
ALTER TABLE study_spaces RENAME COLUMN "building_address" TO "building_location";

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,7 +11,13 @@ CREATE TABLE study_spaces (
description text, description text,
-- Location within building, e.g., "Room 101" -- Location within building, e.g., "Room 101"
location text, 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(), created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now() updated_at timestamp with time zone DEFAULT now()
); );
@@ -24,6 +30,15 @@ CREATE TABLE study_space_images (
PRIMARY KEY (study_space_id, image_path) 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 -- Triggers
CREATE TRIGGER study_spaces_updated_at CREATE TRIGGER study_spaces_updated_at
AFTER UPDATE ON study_spaces AFTER UPDATE ON study_spaces
@@ -32,3 +47,7 @@ FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER study_space_images_updated_at CREATE TRIGGER study_space_images_updated_at
AFTER UPDATE ON study_space_images AFTER UPDATE ON study_space_images
FOR EACH ROW EXECUTE FUNCTION handle_updated_at(); 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();