125 Commits

Author SHA1 Message Date
Ling, Alex
4a3fd0df2a Merge branch 'refactor/favs' into 'master'
feat: admin display

See merge request gk1623/drp-48!34

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-17 12:08:20 +00:00
Barf-Vader
8abcd3a979 feat:admin mode display 2025-06-17 13:03:58 +01:00
Barf-Vader
ade34ac7ca feat: hide favs for admin 2025-06-17 12:33:35 +01:00
Ling, Alex
3edae91e12 Merge branch 'refactor/favs' into 'master'
feat: fixed refactor 🤡

See merge request gk1623/drp-48!33

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-17 00:09:21 +00:00
Barf-Vader
fcd11be506 feat: fixed refactor 🤡 2025-06-17 01:04:18 +01:00
670ecf3526 fix: what 2025-06-17 00:46:51 +01:00
e3e5e9eb69 fix: attempt at fixing image re-arranging 2025-06-17 00:31:21 +01:00
Ling, Alex
d086700d6d Merge branch 'refactor/favs' into 'master'
feat: delete

See merge request gk1623/drp-48!32

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-16 22:30:30 +00:00
Barf-Vader
03bca19527 fix: Types 2025-06-16 23:25:42 +01:00
Barf-Vader
0239f86985 feat: delete 2025-06-16 22:40:54 +01:00
Ling, Alex
e518f63714 Merge branch 'refactor/favs' into 'master'
refactor: everything

See merge request gk1623/drp-48!31

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-16 20:36:42 +00:00
Barf-Vader
9306b42098 refactor: everything 2025-06-16 21:31:34 +01:00
Ling, Alex
a2277a0c8b Merge branch 'realtime' into 'master'
feat: realtime updates for study spaces and reports

See merge request gk1623/drp-48!29

Co-authored-by: Gleb Koval <gleb@koval.net>
2025-06-16 20:29:27 +00:00
Ling, Alex
02c8b25b94 Merge branch 'refactor/favs' into 'master'
refactor: more constrast, clearer favs

See merge request gk1623/drp-48!30

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-16 17:08:28 +00:00
Barf-Vader
7d4e5bf4d1 fix:unbold 2025-06-16 18:04:08 +01:00
Barf-Vader
f48d457f5b refactor: more constrast, clearer favs 2025-06-16 18:00:15 +01:00
ba05fd478f style: remove unused payload 2025-06-16 17:19:10 +01:00
9399a653a3 feat: realtime updates for study spaces and reports 2025-06-16 17:08:11 +01:00
Ling, Alex
7332c0376b Merge branch 'fix-filter-buttons' into 'master'
fix: put filter navigation buttons on the bottom

See merge request gk1623/drp-48!28

Co-authored-by: Gleb Koval <gleb@koval.net>
2025-06-16 15:44:44 +00:00
b8af5c8374 fix: put filter navigation buttons on the bottom 2025-06-16 16:03:46 +01:00
Ling, Alex
bfb361c82a Merge branch 'fix/open-now' into 'master'
fix: open now displays correct times

See merge request gk1623/drp-48!27

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-15 13:54:04 +00:00
Barf-Vader
947bb35f93 fix: open now displays correct times 2025-06-15 14:49:44 +01:00
8af787c61c fix: fixed all-day timing submissions 2025-06-13 15:09:00 +01:00
Temesgen, Tadios
ef157d4015 Merge branch 'fix/directions' into 'master'
fix: Added migrations for addition of direction column to study_spaces

See merge request gk1623/drp-48!26

Co-authored-by: TadiosT <tadios.temesgen@gmail.com>
2025-06-13 13:47:35 +00:00
TadiosT
958e6f61a4 fix: Added migrations for addition of direction column to study_spaces 2025-06-13 14:42:05 +01:00
Ling, Alex
9708713794 Merge branch 'feat/open-now' into 'master'
Feat/open now

See merge request gk1623/drp-48!25

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-13 13:22:42 +00:00
Barf-Vader
764385d660 fix: removed non used imports 2025-06-13 14:18:05 +01:00
Barf-Vader
9bcd1788bf fix: removed console.log 2025-06-13 14:08:31 +01:00
Barf-Vader
37665bcb3a fix: timing 2025-06-13 14:08:31 +01:00
Barf-Vader
268392deed refactor: open timings 2025-06-13 14:07:59 +01:00
e96aeb2cfc Merge branch 'map-sorting' into 'master'
feat: map sorting and separate filter page

See merge request gk1623/drp-48!23
2025-06-13 12:57:01 +00:00
b22943968e fix: remove unused imports 2025-06-13 13:52:33 +01:00
Caspar Jojo Asaam
f9812b3391 Merge branch 'feat/directions' into 'master'
feat: Added feature to allow users to give directions for study spaces. These...

See merge request gk1623/drp-48!24

Co-authored-by: TadiosT <tadios.temesgen@gmail.com>
2025-06-13 12:48:49 +00:00
95c38c6f9f fix: signout without refresh 2025-06-13 13:32:54 +01:00
2ef9a63027 feat: map sorting and separate filter 2025-06-13 13:27:51 +01:00
TadiosT
602bf07d02 fix: fixed style and type check issues 2025-06-13 13:20:46 +01:00
TadiosT
f4517ef467 feat: Added feature to allow users to give directions for study spaces. These directions appear when you click on a study space.
Co-authored-by: Asaam, Caspar <caspar.asaam22@imperial.ac.uk>
2025-06-13 13:06:10 +01:00
Caspar Jojo Asaam
b8f31aef5b Merge branch 'fix/fave-only-on-session' into 'master'
merge: Merged fix/fave-only-on-session to master

See merge request gk1623/drp-48!22

Co-authored-by: Caspar Jojo Asaam <caspar@Caspars-MacBook-Pro-6597.local>
2025-06-13 11:47:24 +00:00
Caspar Jojo Asaam
d767bc4fad fix: Fixed favourites to not show up when a user hasn't been logged in
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-13 12:43:31 +01:00
Caspar Jojo Asaam
7f305287f0 Merge branch 'fix/fave-migration' into 'master'
merge: Merged fix/fave-migration to master

See merge request gk1623/drp-48!21

Co-authored-by: Caspar Jojo Asaam <caspar@Caspars-MacBook-Pro-6597.local>
2025-06-13 11:09:51 +00:00
Caspar Jojo Asaam
2219f7a3b9 fix: Added the migration of the favourite study space table
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-13 12:05:39 +01:00
ce6c391d81 feat: sort by location 2025-06-13 10:56:08 +01:00
Caspar Jojo Asaam
b737c67377 Merge branch 'fix/time-filtering' into 'master'
merge: fix/time-filtering into master

See merge request gk1623/drp-48!20

Co-authored-by: Caspar Jojo Asaam <caspar@dyn3155-98.wlan.ic.ac.uk>
2025-06-13 08:45:11 +00:00
Caspar Jojo Asaam
93b3bf23be fix: Fixed uploading study space error which subsequently fixes persistent favouriting now
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-13 09:41:27 +01:00
Caspar Jojo Asaam
ee190d90db Merge branch 'feat/faves' into 'master'
merge: Merge feat/faves into master

See merge request gk1623/drp-48!19

Co-authored-by: Caspar Jojo Asaam <caspar@dyn3155-98.wlan.ic.ac.uk>
2025-06-13 02:40:19 +00:00
Caspar Jojo Asaam
be04f2d869 feat: Implemented Favouriting for a user
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-13 03:36:16 +01:00
Ling, Alex
ba0ae11abd Merge branch 'refactor/simpler-timings' into 'master'
Refactor/simpler timings

See merge request gk1623/drp-48!18

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-13 02:11:41 +00:00
Barf-Vader
e4a5641eeb fix: pass style check 2025-06-13 03:06:00 +01:00
Barf-Vader
8be06bba8b refactor: simpler timing inputs 2025-06-13 02:58:24 +01:00
07742ad405 fix: fix function for signups 2025-06-12 16:48:41 +01:00
Temesgen, Tadios
9882594551 Merge branch 'r-merge' into 'master'
merge: Merged r-merge into master

See merge request gk1623/drp-48!17

Co-authored-by: Caspar Jojo Asaam <caspar@Caspars-MacBook-Pro-6597.local>
Co-authored-by: Caspar Jojo Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-12 15:33:48 +00:00
Caspar Jojo Asaam
950ab5193a merge: Resolved merge conflicts
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-12 16:21:20 +01:00
0c3d0a00b8 Merge branch 'logins' into 'master'
feat: logins and toggleable admin mode

See merge request gk1623/drp-48!15
2025-06-12 14:46:20 +00:00
788007c1cc feat: complete logins 2025-06-12 15:42:29 +01:00
3bdee89eed feat: initial logins 2025-06-12 15:42:29 +01:00
Caspar Jojo Asaam
30f44b0ac6 feat: Added current opening times to each study space on the main page. In the expanded card, you can view the opening times for the full week. Improved ui
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-12 15:40:20 +01:00
Ling, Alex
b516196d38 Merge branch 'feat/feedback' into 'master'
Feat/feedback

See merge request gk1623/drp-48!13

Co-authored-by: Barf-Vader <47476490+Barf-Vader@users.noreply.github.com>
2025-06-12 14:34:35 +00:00
Barf-Vader
8a6f447202 fix: tag input now works 2025-06-12 15:30:32 +01:00
Barf-Vader
5b7f63f63f feat: Added feedback feature 2025-06-12 15:30:32 +01:00
Caspar Jojo Asaam
afe7b3078d feat: Added current opening times to each study space on the main page. In the expanded card, you can view the opening times for the full week. Improved ui
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-12 14:59:22 +01:00
Caspar Jojo Asaam
7117f85ef7 feat: added migration for a study_space_hours table and allowed for the user to make time inputs when submitting a new space
Co-Authored-By: Tadios Temesgen <tt2022@ic.ac.uk>
2025-06-12 10:10:41 +01:00
Caspar Jojo Asaam
7c0f9b3f52 Merge branch 'fix/filter-by-tags' into 'master'
fix: Search bar on main page no longer overflows. Selected tags no longer...

See merge request gk1623/drp-48!14

Co-authored-by: TadiosT <tadios.temesgen@gmail.com>
2025-06-12 01:03:39 +00:00
TadiosT
8de3d9d48c fix: Fixed type and style errors.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-12 00:48:49 +01:00
TadiosT
2c8d7e00b5 fix: Search bar on main page no longer overflows. Selected tags no longer appear in dropdown. Can no longer filter by contradiction tags.
Co-Authored By: Caspar Asaam <caspar@dyn3159-95.wlan.ic.ac.uk>
2025-06-12 00:41:50 +01:00
ed2721ff4c fix: re-enable typing in tag text box 2025-06-11 23:52:45 +01:00
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
60 changed files with 4292 additions and 588 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_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?

923
package-lock.json generated

File diff suppressed because it is too large Load Diff

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

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

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

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

3
src/lib/assets/heart.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 411 B

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

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42 42L33.3 33.3M38 22C38 30.8366 30.8366 38 22 38C13.1634 38 6 30.8366 6 22C6 13.1634 13.1634 6 22 6C30.8366 6 38 13.1634 38 22Z" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

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

After

Width:  |  Height:  |  Size: 533 B

View File

@@ -5,26 +5,54 @@
onclick?: (event: MouseEvent) => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
style?: "normal" | "red" | "invisible";
formaction?: string;
children?: Snippet;
}
const { children, ...rest }: Props = $props();
interface LinkProps {
href: string;
type: "link";
style?: "normal" | "red";
children?: Snippet;
}
const { children, type, style = "normal", ...rest }: Props | LinkProps = $props();
</script>
<button {...rest}>
{#if type === "link"}
<a {...rest} class="button {style}">
{@render children?.()}
</a>
{:else}
<button class="button {style}" {type} {...rest}>
{@render children?.()}
</button>
{/if}
<style>
button {
.button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(-83deg, #3fb095, #49bd85);
box-shadow: 0rem 0rem 0.5rem #182125;
color: #eaffeb;
color: #ffffff;
border: none;
cursor: pointer;
text-decoration: none;
text-align: center;
}
button:focus {
.normal {
background: linear-gradient(-83deg, rgb(1, 163, 117), #189f5e);
}
.red {
background-color: #bd4949;
}
.invisible {
background: none;
}
.button:focus {
outline: 2px solid #007bff;
}
.button:disabled {
background: linear-gradient(-18deg, #66697b, #4e4e5e);
cursor: not-allowed;
}
</style>

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/No WiFi": "compulsoryTagRed",
"Moderate WiFi": "compulsoryTagYellow",
"No WiFi": "compulsoryTagRed"
};
</script>
<span class="compulsoryTagGreen">{space.volume}</span>
<span class={tagToColor[space.power]}>{space.power}</span>
<span class={tagToColor[space.wifi]}>{space.wifi}</span>
<style>
.compulsoryTagGreen {
display: flex;
font-weight: bold;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 0.25rem;
background-color: #eaffeb;
color: #2e4653;
cursor: pointer;
padding: 0.2rem 0.2rem;
}
.compulsoryTagYellow {
display: flex;
font-weight: bold;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 0.25rem;
background-color: #ffffd4;
color: #534b2e;
cursor: pointer;
padding: 0.2rem 0.2rem;
}
.compulsoryTagRed {
display: flex;
font-weight: bold;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 0.25rem;
background-color: #ffcece;
color: #532e2e;
cursor: pointer;
padding: 0.2rem 0.2rem;
}
</style>

View File

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

View File

@@ -0,0 +1,344 @@
<script lang="ts">
import crossUrl from "$lib/assets/cross.svg";
import type { Table } from "$lib";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/database.d.ts";
import { availableStudySpaceTags, wifiTags, powerOutletTags, volumeTags } from "$lib";
import { invalidate } from "$app/navigation";
type StudySpaceData = Omit<
Table<"study_spaces">,
"id" | "created_at" | "updated_at" | "building_location_old" | "building_location"
> & {
id?: string;
building_location?: google.maps.places.PlaceResult;
};
interface Props {
studySpaceData: StudySpaceData;
supabase: SupabaseClient<Database>;
hideFunc: () => void;
}
const { studySpaceData, supabase, hideFunc }: Props = $props();
let newStudySpaceData: StudySpaceData = $state({ ...studySpaceData });
let uploading = $state(false);
async function uploadFeedback() {
const { error: feedbackUpload } = await supabase
.from("study_spaces")
.update({
directions: newStudySpaceData.directions,
volume: newStudySpaceData.volume,
wifi: newStudySpaceData.wifi,
power: newStudySpaceData.power,
tags: newStudySpaceData.tags
})
.eq("id", newStudySpaceData.id ?? "");
await supabase.channel("study_space_updates").send({
type: "broadcast",
event: "study_space_updated",
payload: { id: newStudySpaceData.id }
});
invalidate("db:study_spaces");
if (feedbackUpload) return alert(`Error submitting feedback: ${feedbackUpload.message}`);
else alert("Feedback submitted successfully!");
}
// Tag
let tagFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
let filteredTags = $derived(
availableStudySpaceTags
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
.filter((tag) => !newStudySpaceData.tags.includes(tag))
);
let dropdownVisible = $state(false);
function deleteTag(tagName: string) {
return () => {
newStudySpaceData.tags = newStudySpaceData.tags.filter((tag) => tag !== tagName);
};
}
function addTag(tagName: string) {
return () => {
if (!newStudySpaceData.tags.includes(tagName)) {
newStudySpaceData.tags.push(tagName);
}
tagFilter = "";
};
}
</script>
<div class="overlay">
<form
onsubmit={async (event) => {
event.preventDefault();
uploading = true;
await uploadFeedback();
uploading = false;
hideFunc();
}}
class="feedbackContainer"
>
<h1 class="submitHeader">Update Tags</h1>
<div class="compulsoryTags">
<div class="compulsoryContainer">
<label for="volume">Sound level:</label>
<select
bind:value={newStudySpaceData.volume}
name="volume"
class="compulsoryTagSelect"
>
<option value="" disabled selected>How noisy is it?</option>
{#each volumeTags as volumeTag (volumeTag)}
<option value={volumeTag}>{volumeTag}</option>
{/each}
</select>
</div>
<div class="compulsoryContainer">
<label for="powerOutlets">Power outlets:</label>
<select
bind:value={newStudySpaceData.power}
name="poweOutlets"
class="compulsoryTagSelect"
>
<option value="" disabled selected>Power outlets?</option>
{#each powerOutletTags as powerOutletTag (powerOutletTag)}
<option value={powerOutletTag}>{powerOutletTag}</option>
{/each}
</select>
</div>
<div class="compulsoryContainer">
<label for="wifi">Wifi:</label>
<select bind:value={newStudySpaceData.wifi} name="wifi" class="compulsoryTagSelect">
<option value="" disabled selected>How's the wifi?</option>
{#each wifiTags as wifiTag (wifiTag)}
<option value={wifiTag}>{wifiTag}</option>
{/each}
</select>
</div>
</div>
<label for="tags">Additional tags:</label>
<div class="tagDisplay">
{#each newStudySpaceData.tags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
{tagName}
<img src={crossUrl} alt="delete" /></button
>
{/each}
<input
type="text"
name="tagInput"
class="tagInput"
bind:value={tagFilter}
bind:this={tagFilterElem}
onfocus={() => {
dropdownVisible = true;
}}
onblur={() => {
dropdownVisible = false;
}}
onkeypress={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const tag = filteredTags[0];
if (tag) addTag(tag)();
}
}}
placeholder="Add tags..."
/>
{#if dropdownVisible}
<div class="tagDropdown">
{#each filteredTags as avaliableTag (avaliableTag)}
<button
class="avaliableTag"
onclick={addTag(avaliableTag)}
onmousedown={(e) => {
// Keep input focused
e.preventDefault();
e.stopPropagation();
}}
type="button"
>
{avaliableTag}
</button>
{/each}
</div>
{/if}
</div>
<button disabled={uploading} class="submit">Submit</button>
<button type="button" class="exit" aria-label="exit" onclick={hideFunc}
><img src={crossUrl} alt="exit" /></button
>
</form>
</div>
<style>
.overlay {
display: flex;
width: 100%;
height: 100%;
position: fixed;
justify-content: center;
align-items: center;
background-color: rgba(8, 15, 18, 0.9);
z-index: 100;
}
.feedbackContainer {
display: flex;
flex-direction: column;
width: 80%;
gap: 0.5rem;
padding: 1rem;
background-color: #182125;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
color: #eaffeb;
position: absolute;
translate: 0 -3.5rem;
border: 2px solid #eaffeb;
}
.submitHeader {
width: 80%;
}
.submit {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
.exit {
position: absolute;
top: 0.1rem;
right: 0.1rem;
background: none;
border: none;
cursor: pointer;
}
.tagDisplay {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: left;
justify-content: left;
position: relative;
width: 100%;
height: auto;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
.tagInput {
width: 100%;
height: 100%;
background: none;
color: #eaffeb;
font-size: 1rem;
border: none;
outline: none;
}
::placeholder {
color: #859a90;
opacity: 1;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.9rem;
cursor: pointer;
border-width: 0rem;
}
.tag img {
width: 1rem;
height: 1rem;
margin-left: 0.2rem;
}
.tagDropdown {
width: 100%;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
position: absolute;
background-color: #2e4653;
box-shadow: 1px 1px 0.5rem rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
overflow-y: auto;
max-height: 10rem;
top: 100%;
left: 50%;
transform: translateX(-50%);
}
.avaliableTag {
width: 100%;
text-align: left;
background: none;
border: none;
color: #eaffeb;
font-size: 0.9rem;
margin: 0%;
padding: 0 0.8rem 0.4rem;
}
.avaliableTag:first-child {
padding-top: 0.6rem;
background-color: hsl(201, 26%, 60%);
}
.avaliableTag:last-child {
padding-bottom: 0.6rem;
}
.compulsoryTags {
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: 0.5rem;
background-color: none;
width: 100%;
font-size: 1rem;
}
.compulsoryContainer {
display: flex;
flex-direction: column;
align-items: left;
justify-content: top;
border-radius: 0.5rem;
background-color: none;
}
.compulsoryTagSelect {
width: 100%;
height: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 0.9rem;
text-align: left;
}
.compulsoryTagSelect option {
background-color: #2e4653;
color: #eaffeb;
}
</style>

View File

@@ -21,18 +21,22 @@
display: flex;
position: sticky;
width: 100%;
height: 4rem;
height: 3.5rem;
top: 0;
left: 0;
background: linear-gradient(-77deg, #2e4653, #3a5b56);
right: 0;
background: linear-gradient(-77deg, #2e4653, #223a37);
box-shadow: 0rem 0rem 0.5rem #182125;
align-items: center;
overflow: hidden;
z-index: 100;
}
.logo {
display: block;
height: 100%;
}
.logo img {
height: 100%;
}
@@ -41,5 +45,6 @@
display: flex;
flex-direction: row-reverse;
flex: 1;
align-items: center;
}
</style>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Table } from "$lib";
import { formatTime } from "$lib";
interface Props {
hours: Table<"study_space_hours">[];
}
// Destructure hours with a safe default to avoid undefined
const { hours }: Props = $props();
// Determine today's index (0 = Sunday, 6 = Saturday)
const todayIndex = new Date().getDay();
// Find the hours entry matching today
const todayHours = hours.find((h) => h.day_of_week === todayIndex);
// Compute the display string for opening times
let openingDisplay = $state("");
if (todayHours) {
openingDisplay = todayHours.open_today_status
? "Open All Day"
: `${formatTime(todayHours.opens_at)} - ${formatTime(todayHours.closes_at)}`;
} else {
openingDisplay = "Closed";
}
</script>
<div class="opening-times">
<strong>Today's Opening Times:</strong>
{openingDisplay}
</div>
<style>
.opening-times {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #eaffeb;
}
</style>

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { reportTypes } from "$lib";
import crossUrl from "$lib/assets/cross.svg";
import Textarea from "./inputs/Textarea.svelte";
import type { Table } from "$lib";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/database.d.ts";
interface Props {
data: { supabase: SupabaseClient<Database> };
studySpaceId: string;
hideFunc: () => void;
}
const { data, studySpaceId, hideFunc }: Props = $props();
const { supabase } = $derived(data);
let uploading = $state(false);
let reportData = $state<Omit<Table<"reports">, "id" | "created_at" | "updated_at">>({
study_space_id: studySpaceId,
type: "",
content: ""
});
async function uploadReport() {
const { error: reportUploadError } = await supabase
.from("reports")
.insert(reportData)
.select()
.single();
await supabase.channel("report_updates").send({
type: "broadcast",
event: "reports_updated",
payload: { study_space_id: studySpaceId }
});
if (reportUploadError)
return alert(`Error submitting report: ${reportUploadError.message}`);
else alert("Report submitted successfully!");
}
</script>
<div class="overlay">
<form
onsubmit={async (event) => {
event.preventDefault();
uploading = true;
await uploadReport();
uploading = false;
hideFunc();
}}
class="reportContainer"
>
<h1 class="submitHeader">Submit a Report</h1>
<p>What's the reason?</p>
<select name="reportType" class="reportType" required bind:value={reportData.type}>
<option value="" disabled selected>Report type</option>
{#each reportTypes as reportType (reportType)}
<option value={reportType}>{reportType}</option>
{/each}
</select>
<label for="description">Briefly describe the problem:</label>
<Textarea
name="description"
placeholder="Image is inappropriate..."
rows={2}
bind:value={reportData.content}
/>
<button
disabled={!reportData.type || reportData.content?.length === 0 || uploading}
class="submit">Submit</button
>
<button type="button" class="exit" aria-label="exit" onclick={hideFunc}
><img src={crossUrl} alt="exit" /></button
>
</form>
</div>
<style>
.overlay {
display: flex;
width: 100%;
height: 100%;
position: fixed;
justify-content: center;
align-items: center;
background-color: rgba(8, 15, 18, 0.9);
z-index: 100;
}
.reportContainer {
display: flex;
flex-direction: column;
width: 80%;
gap: 0.5rem;
padding: 1rem;
background-color: #182125;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
color: #eaffeb;
position: absolute;
translate: 0 -3.5rem;
border: 2px solid #eaffeb;
}
.submitHeader {
width: 80%;
}
.reportType {
width: 100%;
height: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 0.9rem;
text-align: left;
}
.reportType option {
background-color: #2e4653;
color: #eaffeb;
}
.submit {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
.exit {
position: absolute;
top: 0.1rem;
right: 0.1rem;
background: none;
border: none;
cursor: pointer;
}
</style>

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import cameraUrl from "$lib/assets/camera.svg";
import Carousel from "../Carousel.svelte";
interface Props {
name: string;
@@ -7,9 +8,17 @@
minHeight?: string;
files?: FileList;
required?: boolean;
scrollPosition?: number;
}
let { name, height, minHeight, files = $bindable(), ...rest }: Props = $props();
let {
name,
height,
minHeight,
files = $bindable(),
scrollPosition = $bindable(),
...rest
}: Props = $props();
</script>
<label
@@ -18,14 +27,38 @@
class:no-bg={files && files.length > 0}
>
{#if files && files.length > 0}
<img src={URL.createObjectURL(files[0])} alt="uploaded study space" class="preview" />
<Carousel
urls={files
? Array(files.length)
.keys()
.map((i) => URL.createObjectURL(files![i]))
.toArray()
: []}
ondelete={(idx) => {
if (!files) return;
const dt = new DataTransfer();
for (let i = 0; i < files.length; i++) {
if (i !== idx) dt.items.add(files[i]);
}
files = dt.files;
}}
bind:scrollPosition
/>
{:else}
<div class="message">
<img src={cameraUrl} class="icon" alt="camera icon" />
<span>Click to upload a photo</span>
</div>
{/if}
<input type="file" id={name} {name} accept=".png, .jpg, .jpeg, .svg" {...rest} bind:files />
<input
type="file"
id={name}
{name}
multiple
accept=".png, .jpg, .jpeg, .svg"
{...rest}
bind:files
/>
</label>
<style>
@@ -52,7 +85,7 @@
display: flex;
flex-direction: column;
align-items: center;
color: #49bd85;
color: #189f5e;
}
.preview {
max-height: 100%;

View File

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

View File

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

View File

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

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

@@ -34,6 +34,82 @@ export type Database = {
}
public: {
Tables: {
reports: {
Row: {
content: string | null
created_at: string | null
id: string
study_space_id: string | null
type: string
updated_at: string | null
}
Insert: {
content?: string | null
created_at?: string | null
id?: string
study_space_id?: string | null
type: string
updated_at?: string | null
}
Update: {
content?: string | null
created_at?: string | null
id?: string
study_space_id?: string | null
type?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "reports_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
}
study_space_hours: {
Row: {
closes_at: string
created_at: string | null
day_of_week: number
id: string
open_today_status: boolean | null
opens_at: string
study_space_id: string | null
updated_at: string | null
}
Insert: {
closes_at: string
created_at?: string | null
day_of_week: number
id?: string
open_today_status?: boolean | null
opens_at: string
study_space_id?: string | null
updated_at?: string | null
}
Update: {
closes_at?: string
created_at?: string | null
day_of_week?: number
id?: string
open_today_status?: boolean | null
opens_at?: string
study_space_id?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "study_space_hours_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
}
study_space_images: {
Row: {
created_at: string | null
@@ -65,30 +141,105 @@ export type Database = {
}
study_spaces: {
Row: {
building_address: string | null
building_location: Json | null
building_location_old: string | null
created_at: string | null
description: string | null
directions: string
id: string
location: string | null
power: string
tags: string[]
updated_at: string | null
volume: string
wifi: string
}
Insert: {
building_location?: Json | null
building_location_old?: string | null
created_at?: string | null
description?: string | null
directions: string
id?: string
location?: string | null
power: string
tags?: string[]
updated_at?: string | null
volume: string
wifi: string
}
Update: {
building_location?: Json | null
building_location_old?: string | null
created_at?: string | null
description?: string | null
directions?: string
id?: string
location?: string | null
power?: string
tags?: string[]
updated_at?: string | null
volume?: string
wifi?: string
}
Relationships: []
}
users: {
Row: {
created_at: string
id: string
is_admin: boolean
updated_at: string
}
Insert: {
created_at?: string
id: string
is_admin?: boolean
updated_at?: string
}
Update: {
created_at?: string
id?: string
is_admin?: boolean
updated_at?: string
}
Relationships: []
}
favourite_study_spaces: {
Row: {
user_id: string
study_space_id: string
created_at: string | null
updated_at: string | null
}
Insert: {
building_address?: string | null
user_id: string
study_space_id: string
created_at?: string | null
description?: string | null
id?: string
location?: string | null
updated_at?: string | null
}
Update: {
building_address?: string | null
user_id?: string
study_space_id?: string
created_at?: string | null
description?: string | null
id?: string
location?: string | null
updated_at?: string | null
}
Relationships: []
Relationships: [
{
foreignKeyName: "favourite_study_spaces_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
{
foreignKeyName: "favourite_study_spaces_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
}
]
}
}
Views: {

46
src/lib/filter.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface SortFiler {
tags: string[];
/** Time strings of opening range. */
openAt: {
from: string;
to?: string;
};
nearby: {
lat: number;
lng: number;
};
}
export function urlencodeSortFilter(filter: Partial<SortFiler>): string {
const params = new URLSearchParams();
if (filter.tags) {
filter.tags.forEach((tag) => params.append("tags", tag));
}
if (filter.openAt) {
params.set("open_from", filter.openAt.from);
if (filter.openAt.to) params.set("open_to", filter.openAt.to);
}
if (filter.nearby) {
params.set("nearby", `${filter.nearby.lat},${filter.nearby.lng}`);
}
return params.toString();
}
export function urldecodeSortFilter(query: string): Partial<SortFiler> {
const params = new URLSearchParams(query);
const filter: Partial<SortFiler> = {};
if (params.has("tags")) {
filter.tags = params.getAll("tags");
}
if (params.has("open_from")) {
filter.openAt = {
from: params.get("open_from")!,
to: params.get("open_to") ?? undefined
};
}
if (params.has("nearby")) {
const [lat, lng] = params.get("nearby")!.split(",").map(Number);
filter.nearby = { lat, lng };
}
return filter;
}

View File

@@ -1,5 +1,112 @@
import { PUBLIC_GMAPS_API_KEY } from "$env/static/public";
import type { Database } from "./database.d.ts";
export type Table<T extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][T]["Row"];
export type Enum<T extends keyof Database["public"]["Enums"]> = Database["public"]["Enums"][T];
export const availableStudySpaceTags = [
"Crowded",
"Group study",
"Food allowed",
"No food allowed",
"Well lit",
"Poorly lit",
"Whiteboard",
"Restricted access",
"Hot",
"Air conditioned",
"Cold",
"PCs",
"Rodent-ridden"
];
export const volumeTags = ["Silent", "Some Noise", "Loud"];
export const wifiTags = ["Good WiFi", "Moderate WiFi", "Bad/No WiFi"];
export const powerOutletTags = ["Many Outlets", "Some Outlets", "No Outlets"];
export const allTags = [...availableStudySpaceTags, ...volumeTags, ...wifiTags, ...powerOutletTags];
export const reportTypes = [
"Inappropriate content",
"Duplicate content",
"Incorrect content",
"Other"
];
export async function gmapsLoader() {
const { Loader } = await import("@googlemaps/js-api-loader");
return new Loader({
apiKey: PUBLIC_GMAPS_API_KEY,
version: "weekly",
libraries: ["places"]
});
}
export function formatTime(time: string) {
const [h, m] = time.split(":").map(Number);
const date = new Date();
date.setHours(h, m);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export const daysOfWeek = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"All Days",
"All Other Days"
];
// Convert "HH:MM" or "HH:MM:SS" to minutes since midnight
export function timeToMins(timeStr: string): number {
const [h, m] = timeStr.slice(0, 5).split(":").map(Number);
return h * 60 + m;
}
export function haversineDistance(
lat1Deg: number,
lng1Deg: number,
lat2Deg: number,
lng2Deg: number,
radius: number = 6371e3
): number {
const lat1 = lat1Deg * (Math.PI / 180);
const lat2 = lat2Deg * (Math.PI / 180);
const deltaLat = (lat2Deg - lat1Deg) * (Math.PI / 180);
const deltaLng = (lng2Deg - lng1Deg) * (Math.PI / 180);
const e1 =
Math.pow(Math.sin(deltaLat / 2), 2) +
Math.pow(Math.sin(deltaLng / 2), 2) * Math.cos(lat1) * Math.cos(lat2);
return radius * 2 * Math.asin(Math.sqrt(e1));
}
export function collectTimings(
study_space_hours: Omit<
Table<"study_space_hours">,
"id" | "created_at" | "updated_at" | "study_space_id"
>[]
) {
// Collect all timing entries
const timingsPerDay: Record<
number,
Omit<Table<"study_space_hours">, "id" | "created_at" | "updated_at" | "study_space_id">[]
> = {
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
6: []
};
for (const entry of study_space_hours) {
timingsPerDay[entry.day_of_week].push(entry);
}
return timingsPerDay;
}

View File

@@ -1,9 +1,29 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
export const load: LayoutServerLoad = async ({
locals: { safeGetSession, supabase },
cookies,
depends
}) => {
depends("supabase:auth");
const { session } = await safeGetSession();
let adminMode = false;
if (session) {
const { data: userData, error: userError } = await supabase
.from("users")
.select("*")
.eq("id", session.user.id)
.single();
if (userError) {
console.error("Failed to fetch user data:", userError);
}
if (userData?.is_admin) {
adminMode = true;
}
}
return {
session,
adminMode,
cookies: cookies.getAll()
};
};

View File

@@ -1,7 +1,40 @@
<script lang="ts">
import posthog from "posthog-js";
import logoUrl from "$lib/assets/logo.svg";
import { onMount } from "svelte";
import { invalidate } from "$app/navigation";
const { children } = $props();
let { data, children } = $props();
let { session, supabase, route } = $derived(data);
onMount(() => {
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
api_host: "https://eu.i.posthog.com",
person_profiles: "always"
});
const { data } = supabase.auth.onAuthStateChange((_, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
invalidate("supabase:auth");
}
});
const spacesChannel = supabase
.channel("study_space_updates")
.on("broadcast", { event: "study_space_updated" }, () => {
invalidate("db:study_spaces");
})
.subscribe();
return () => {
data.subscription.unsubscribe();
spacesChannel.unsubscribe();
};
});
$effect(() => {
if (route.id === "/filter") {
document.body.classList.add("coloured");
} else {
document.body.classList.remove("coloured");
}
});
</script>
<svelte:head>
@@ -15,7 +48,8 @@
:global(body) {
margin: 0;
padding: 0;
width: 100vw;
width: 100%;
min-height: 100vh;
}
:global(html) {
@@ -23,6 +57,10 @@
color: #eaffeb;
}
:global(body.coloured) {
background: linear-gradient(-77deg, #2e4653, #223a37);
}
:global(*) {
box-sizing: border-box;
font-family: Inter;

View File

@@ -3,7 +3,7 @@ import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/publi
import type { Database } from "$lib/database";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
export const load: LayoutLoad = async ({ data, url, route, depends, fetch }) => {
/**
* Declare a dependency so the layout can be invalidated, for example, on
* session refresh.
@@ -40,5 +40,12 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user }
} = await supabase.auth.getUser();
return { session, supabase, user };
return {
session,
supabase,
user,
adminMode: data.adminMode,
route,
searchParams: url.searchParams.toString()
};
};

View File

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

View File

@@ -2,48 +2,351 @@
import SpaceCard from "$lib/components/SpaceCard.svelte";
import defaultImg from "$lib/assets/study_space.png";
import crossUrl from "$lib/assets/cross.svg";
import searchUrl from "$lib/assets/search.svg";
import Navbar from "$lib/components/Navbar.svelte";
import { collectTimings, timeToMins, haversineDistance } from "$lib";
import Button from "$lib/components/Button.svelte";
import { urldecodeSortFilter } from "$lib/filter.js";
import { invalidateAll } from "$app/navigation";
import type { Table } from "$lib";
const { data } = $props();
const { studySpaces, supabase } = $derived(data);
const {
studySpaces,
supabase,
session,
adminMode,
searchParams,
favouriteIds: initialFavourites = []
} = $derived(data);
let favouriteIds = $derived<string[]>(initialFavourites);
let showFavourites = $state(false);
const sortFilter = $derived(urldecodeSortFilter(searchParams));
const selectedTags = $derived(sortFilter.tags ?? []);
const openingFilter = $derived(sortFilter.openAt?.from);
const closingFilter = $derived(sortFilter.openAt?.to);
const sortNear = $derived(sortFilter.nearby);
// Toggle a space in/out of favourites
async function handleToggleFavourite(id: string) {
if (!session?.user) return;
const already = favouriteIds.includes(id);
if (already) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: id });
favouriteIds = favouriteIds.filter((x) => x !== id);
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: id }]);
favouriteIds = [...favouriteIds, id];
}
}
// Combine tag and time filtering
let filteredStudySpaces = $derived(
studySpaces
// only include favourites when showFavourites===true
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
// tag filtering
.filter((space) => {
if (selectedTags.length === 0) return true;
const allTags = [
...(space.tags || []),
space.volume,
space.wifi,
space.power
].filter(Boolean);
return selectedTags.every((tag) => allTags.includes(tag));
})
// opening time filter
.filter((space) => {
if (!openingFilter) return true;
const entry = space.study_space_hours?.find(
(h) => h.day_of_week === new Date().getDay()
);
if (!entry) return false;
if (entry.open_today_status) return true;
const openMin = timeToMins(entry.opens_at);
let closeMin = timeToMins(entry.closes_at);
// Treat midnight as end of day and handle overnight spans
if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin = timeToMins(openingFilter);
// Include spaces open at the filter time
return filterMin >= openMin && filterMin < closeMin;
})
// closing time filter
.filter((space) => {
if (!closingFilter) return true;
const entry = space.study_space_hours?.find(
(h) => h.day_of_week === new Date().getDay()
);
if (!entry) return false;
if (entry.open_today_status) return true;
const openMin = timeToMins(entry.opens_at);
let closeMin = timeToMins(entry.closes_at);
if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin =
timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
// Include spaces still open at the filter time
return filterMin > openMin && filterMin <= closeMin;
})
);
const sortedByOpenNow = $derived(
filteredStudySpaces.toSorted((a, b) => {
const now = new Date();
const time = now.toTimeString().slice(0, 5);
const today = now.getDay();
let openUntil = [0, 0] as number[];
for (const [index, day] of [a, b].entries()) {
const timingsPerDay = collectTimings(day.study_space_hours);
for (const timing of timingsPerDay[today]) {
if (timing.open_today_status === true) {
openUntil[index] = 24 * 60;
break;
} else if (timing.open_today_status === false) {
break;
} else {
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
if (opensFor) {
openUntil[index] = opensFor;
break;
}
}
}
}
return openUntil[1] - openUntil[0];
})
);
const sortedStudySpaces = $derived(
sortNear
? filteredStudySpaces.toSorted((a, b) => {
if (!sortNear) return 0;
type DBLatLng = { lat: number; lng: number } | undefined;
const aLoc = a.building_location as unknown as google.maps.places.PlaceResult;
const bLoc = b.building_location as unknown as google.maps.places.PlaceResult;
const aLatLng = aLoc.geometry?.location as DBLatLng;
const bLatLng = bLoc.geometry?.location as DBLatLng;
const aDistance = haversineDistance(
sortNear.lat,
sortNear.lng,
aLatLng?.lat || sortNear.lat,
aLatLng?.lng || sortNear.lng
);
const bDistance = haversineDistance(
sortNear.lat,
sortNear.lng,
bLatLng?.lat || sortNear.lat,
bLatLng?.lng || sortNear.lng
);
return aDistance - bDistance;
})
: sortedByOpenNow
);
// Open now
function isOpenNow(all_study_space_hours: Table<"study_space_hours">[]) {
const now = new Date();
const time = now.toTimeString().slice(0, 5);
const day = now.getDay();
const timingsPerDay = collectTimings(all_study_space_hours);
for (const timing of timingsPerDay[day]) {
if (timing.open_today_status === true) {
return { isOpen: true, message: `Open all day` };
} else if (timing.open_today_status === false) {
return { isOpen: false, message: `Closed today` };
} else {
const opensFor = timeUntilClosing(timing.opens_at, timing.closes_at, time);
if (opensFor) {
return {
isOpen: true,
message: `Open now for: ${minsToReadableHours(opensFor)}`
};
}
}
}
return { isOpen: false, message: "Closed right now" };
}
function timeUntilClosing(openingTime: string, closingTime: string, currentTime: string) {
const currTimeInMins = timeToMins(currentTime);
const OpeningTimeInMins = timeToMins(openingTime);
const closingTimeInMins = timeToMins(closingTime);
if (currTimeInMins >= OpeningTimeInMins && currTimeInMins < closingTimeInMins) {
return closingTimeInMins - currTimeInMins;
}
}
function minsToReadableHours(mins: number) {
return `${Math.floor(mins / 60)} hrs, ${mins % 60} mins`;
}
$inspect(sortedStudySpaces);
</script>
<Navbar>
<a href="/space">
{#if session}
<a href="/space/new/edit">
<img src={crossUrl} alt="new" class="new-space" />
</a>
{/if}
{#if adminMode}
<span class="checkReports">
<Button href="/space/reports" type="link" style="red">Check Reports</Button>
</span>
{/if}
{#if session}
<button class="fav-button" onclick={() => (showFavourites = !showFavourites)} type="button">
{showFavourites ? "All spaces" : "My favourites"}
</button>
{/if}
<div class="filterWrapper">
<Button type="link" href="/filter?{searchParams}">
<span class="search">
<img src={searchUrl} alt="search" />
Search
</span>
</Button>
</div>
</Navbar>
<main>
{#each studySpaces as studySpace (studySpace.id)}
{@const imgUrl =
studySpace.study_space_images.length > 0
{#each sortedStudySpaces as studySpace (studySpace.id)}
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={studySpace.study_space_images.length > 0
? supabase.storage
.from("files_bucket")
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
: defaultImg}
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={imgUrl}
>
{#snippet description()}
<p>{studySpace.description}</p>
{/snippet}
</SpaceCard>
space={studySpace}
isFavourite={favouriteIds.includes(studySpace.id)}
onToggleFavourite={session ? () => handleToggleFavourite(studySpace.id) : undefined}
isAvailable={studySpace.study_space_hours.length === 0
? undefined
: isOpenNow(studySpace.study_space_hours).isOpen}
footer={studySpace.study_space_hours.length === 0
? undefined
: isOpenNow(studySpace.study_space_hours).message}
/>
{/each}
</main>
<footer>
{#if session}
<Button
onclick={async () => {
await supabase.auth.signOut();
invalidateAll();
}}>Signout</Button
>
{:else}
<Button href="/auth" type="link">Login / Signup</Button>
{/if}
</footer>
{#if adminMode}
<div class="adminMode">You are in admin mode</div>
{/if}
<style>
main {
display: grid;
box-sizing: border-box;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: 0.5rem;
padding: 0.5rem;
max-width: 32rem;
width: 100%;
margin: 0 auto;
}
footer {
display: flex;
flex-direction: column;
max-width: 600px;
width: 100%;
padding: 1rem;
width: min(600px, 100vw);
margin: 0 auto;
}
.new-space {
transform: rotate(45deg);
}
.checkReports {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
font-size: 1rem;
margin-left: 0.5rem;
}
.fav-button {
background: none;
border: none;
color: #eaffeb;
font-size: 1rem;
cursor: pointer;
}
.fav-button:hover {
text-decoration: underline;
}
.filterWrapper {
display: flex;
justify-content: center;
align-items: center;
margin-right: 0.5rem;
}
.search {
display: flex;
align-items: center;
gap: 0.3rem;
color: #eaffeb;
font-size: 1rem;
}
.search img {
width: 1.2rem;
height: 1.2rem;
}
@media (max-width: 20rem) {
main {
grid-template-columns: 1fr;
}
}
.adminMode {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
position: sticky;
left: 0;
gap: 0.5rem;
padding: 0.75rem;
font-size: 1rem;
background-color: #182125;
bottom: 0;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
color: #eaffeb;
border: 2px solid #eaffeb;
z-index: 1000;
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
const { children } = $props();
</script>
<Navbar>
<a href="/">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
{@render children?.()}

View File

@@ -0,0 +1,30 @@
import { redirect, error } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions: Actions = {
signup: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const { error: authError } = await supabase.auth.signUp({ email, password });
if (authError) {
error(400, "Failed to sign up: " + authError.message);
} else {
redirect(303, "/");
}
},
login: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const { error: authError } = await supabase.auth.signInWithPassword({ email, password });
if (authError) {
error(400, "Failed to log in: " + authError.message);
} else {
redirect(303, "/");
}
}
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import Button from "$lib/components/Button.svelte";
import Text from "$lib/components/inputs/Text.svelte";
</script>
<form method="POST" action="?/login">
<label for="email">Email</label>
<Text type="email" name="email" placeholder="your@email.com" />
<label for="password">Password</label>
<Text type="password" name="password" placeholder="*********" />
<div class="actions">
<Button type="submit">Login</Button>
<Button formaction="?/signup">Signup</Button>
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 600px;
margin: 1rem auto;
}
label {
margin-top: 0.5rem;
}
.actions {
display: grid;
margin-top: 0.5rem;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import { allTags, volumeTags, wifiTags, powerOutletTags, gmapsLoader } from "$lib";
import crossUrl from "$lib/assets/cross.svg";
import Button from "$lib/components/Button.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import { urldecodeSortFilter, urlencodeSortFilter, type SortFiler } from "$lib/filter.js";
import { onMount } from "svelte";
const { data } = $props();
const { searchParams } = $derived(data);
const sortFilter = $derived(urldecodeSortFilter(searchParams));
// svelte-ignore state_referenced_locally
const openAt = $state(sortFilter.openAt ?? ({} as Partial<SortFiler["openAt"]>));
// svelte-ignore state_referenced_locally
let selectedTags = $state(sortFilter.tags ?? ([] as SortFiler["tags"]));
// svelte-ignore state_referenced_locally
let sortNear = $state(sortFilter.nearby ?? undefined);
let tagFilter = $state("");
const newSearchParams = $derived(
urlencodeSortFilter({
openAt: openAt?.from ? (openAt as { from: string; to?: string }) : undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
nearby: sortNear
})
);
let filteredTags = $derived(
allTags
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
.filter((tag) => !selectedTags.includes(tag))
.filter((tag) => {
if (selectedTags.includes(tag)) return false;
if (categorySelected(volumeTags) && volumeTags.includes(tag)) return false;
if (categorySelected(wifiTags) && wifiTags.includes(tag)) return false;
if (categorySelected(powerOutletTags) && powerOutletTags.includes(tag))
return false;
return true;
})
);
let dropdownVisible = $state(false);
function deleteTag(tagName: string) {
return () => {
selectedTags = selectedTags.filter((tag) => tag !== tagName);
};
}
function addTag(tagName: string) {
return () => {
if (!selectedTags.includes(tagName)) {
selectedTags.push(tagName);
}
tagFilter = "";
};
}
let sortMapElem = $state<HTMLDivElement>();
let marker = $state<google.maps.marker.AdvancedMarkerElement>();
onMount(async () => {
if (!sortMapElem) return console.error("sortMapElem is not defined");
const loader = await gmapsLoader();
const { Map } = await loader.importLibrary("maps");
const { AdvancedMarkerElement } = await loader.importLibrary("marker");
const map = new Map(sortMapElem, {
center: { lat: 51.5087393, lng: -0.1667442 },
zoom: 10,
mapId: "9f4993cd3fb1504d495821a5"
});
marker = new AdvancedMarkerElement({
map,
title: "Find near here"
});
map.addListener("click", (e: google.maps.MapMouseEvent) => {
console.log("Clicked map at", e.latLng);
sortNear = e.latLng
? {
lat: e.latLng.lat(),
lng: e.latLng.lng()
}
: sortNear;
});
});
$effect(() => {
if (marker) {
marker.position = sortNear;
}
});
function categorySelected(category: string[]) {
return category.some((tag) => selectedTags.includes(tag));
}
</script>
<Navbar>
<a href="/?{searchParams}">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
<main>
<h1>Search options</h1>
<div class="time-filter-container">
<label>
Open from
<input type="time" bind:value={openAt.from} />
</label>
<label>
until
<input type="time" bind:value={openAt.to} />
</label>
<span class="setToNow">
<Button
onclick={() => {
const now = new Date();
openAt.from = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
openAt.to = undefined;
console.log(openAt);
}}
>
Set to now
</Button>
</span>
</div>
<div class="tag-filter-container">
<div class="tagDisplay">
{#each selectedTags as tagName, idx (tagName + idx)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
{tagName}
<img src={crossUrl} alt="delete" /></button
>
{/each}
<input
type="text"
name="tagInput"
class="tagInput"
bind:value={tagFilter}
onfocus={() => {
dropdownVisible = true;
}}
onblur={() => {
dropdownVisible = false;
}}
onkeypress={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const tag = filteredTags[0];
if (tag) addTag(tag)();
}
}}
placeholder="Search by tags..."
/>
{#if dropdownVisible}
<div class="tagDropdown">
{#each filteredTags as avaliableTag, idx (avaliableTag + idx)}
<button
class="avaliableTag"
onclick={addTag(avaliableTag)}
onmousedown={(e) => {
// Keep input focused
e.preventDefault();
e.stopPropagation();
}}
type="button"
>
{avaliableTag}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="location-filter-container">
<h3 class="location-filter-title">Click to search nearby</h3>
<Button
onclick={() => {
navigator.geolocation.getCurrentPosition((position) => {
if (marker)
sortNear = marker.position = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
});
}}
>
Use current location
</Button>
</div>
<div class="sortMap" bind:this={sortMapElem}></div>
</main>
<div class="controls">
<div class="controls-inner">
<Button type="link" href="/?{newSearchParams}">Back to study spaces</Button>
<Button
style="red"
onclick={() => {
openAt.from = undefined;
openAt.to = undefined;
selectedTags = [];
sortNear = undefined;
tagFilter = "";
}}
>
Clear
</Button>
</div>
</div>
<style>
main {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 32rem;
margin: auto;
}
.controls {
position: sticky;
background: inherit;
background-attachment: local;
padding: 0.5rem 1rem;
bottom: 0;
width: 100%;
}
.controls-inner {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 32rem;
gap: 1rem;
margin: auto;
}
.tag-filter-container {
display: flex;
margin-bottom: 0.5rem;
}
.time-filter-container {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
}
.location-filter-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-filter-container label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: #eaffeb;
}
.time-filter-container input[type="time"] {
background: none;
border: 2px solid #000000;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #000000;
filter: brightness(0) saturate(100%) invert(98%) sepia(8%) saturate(555%) hue-rotate(54deg)
brightness(100%) contrast(102%);
}
.setToNow {
display: flex;
justify-content: end;
align-items: center;
flex: 1;
}
.tagDisplay {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: left;
justify-content: left;
position: relative;
width: 100%;
height: auto;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
.tagInput {
flex: 1 1 100%;
min-width: 10rem;
background: none;
color: #eaffeb;
font-size: 1rem;
border: none;
outline: none;
}
::placeholder {
color: #859a90;
opacity: 1;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.9rem;
cursor: pointer;
border-width: 0rem;
}
.tag img {
width: 1rem;
height: 1rem;
margin-left: 0.2rem;
}
.tagDropdown {
width: 100%;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
position: absolute;
background-color: #2e4653;
box-shadow: 1px 1px 0.5rem rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
overflow-y: auto;
max-height: 10rem;
top: 100%;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}
.avaliableTag {
width: 100%;
text-align: left;
background: none;
border: none;
color: #eaffeb;
font-size: 0.9rem;
margin: 0%;
padding: 0 0.8rem 0.4rem;
}
.avaliableTag:first-child {
padding-top: 0.6rem;
background-color: hsl(201, 26%, 60%);
}
.avaliableTag:last-child {
padding-bottom: 0.6rem;
}
.location-filter-title {
flex: 1;
}
.sortMap {
aspect-ratio: 1 / 1;
width: 100%;
}
</style>

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

@@ -1,10 +1,11 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
export const load: PageServerLoad = async ({ depends, params, locals: { supabase } }) => {
depends("db:study_spaces");
const { data: space, error: err } = await supabase
.from("study_spaces")
.select("*, study_space_images(*)")
.select("*, study_space_images(*), study_space_hours(*)")
.eq("id", params.id)
.single();
if (err) error(500, "Failed to load study space");

View File

@@ -2,17 +2,97 @@
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import placeholder from "$lib/assets/study_space.png";
import Carousel from "$lib/components/Carousel.svelte";
import CompulsoryTags from "$lib/components/CompulsoryTags.svelte";
import Report from "$lib/components/Report.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import { onMount } from "svelte";
import { gmapsLoader, daysOfWeek, formatTime, collectTimings } from "$lib";
import Button from "$lib/components/Button.svelte";
import Favourite from "$lib/components/Favourite.svelte";
const { data } = $props();
const { space, supabase } = $derived(data);
const { space, supabase, adminMode } = $derived(data);
const imgUrl = $derived(
space.study_space_images.length > 0
? supabase.storage
.from("files_bucket")
.getPublicUrl(space.study_space_images[0].image_path).data.publicUrl
: placeholder
const place = $derived(space.building_location as google.maps.places.PlaceResult);
const imgUrls = $derived(
space.study_space_images.length === 0
? [placeholder]
: space.study_space_images.map(
(img) =>
supabase.storage.from("files_bucket").getPublicUrl(img.image_path).data
.publicUrl
)
);
let isReportVisible = $state(false);
function hideReport() {
isReportVisible = false;
}
let isFeedbackPromptVisible = $state(false);
function hideFeedbackPrompt() {
isFeedbackPromptVisible = false;
}
let mapElem = $state<HTMLDivElement>();
onMount(async () => {
if (!mapElem) return console.error("Map element not found");
const loader = await gmapsLoader();
const { Map } = await loader.importLibrary("maps");
const { AdvancedMarkerElement } = await loader.importLibrary("marker");
const map = new Map(mapElem, {
center: place.geometry?.location,
zoom: 15,
mapId: "9f4993cd3fb1504d495821a5"
});
new AdvancedMarkerElement({
position: place.geometry?.location,
map
});
});
let timingsPerDay = collectTimings(space.study_space_hours);
let isFavourite = $state(false);
onMount(async () => {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
const { data: fav } = await supabase
.from("favourite_study_spaces")
.select("study_space_id")
.match({ user_id: session.user.id, study_space_id: space.id })
.single();
isFavourite = !!fav;
});
// Toggle a space in/out of favourites
async function handleToggleFavourite() {
const {
data: { session }
} = await supabase.auth.getSession();
if (!session?.user) return;
if (isFavourite) {
await supabase
.from("favourite_study_spaces")
.delete()
.match({ user_id: session.user.id, study_space_id: space.id });
isFavourite = false;
} else {
await supabase
.from("favourite_study_spaces")
.insert([{ user_id: session.user.id, study_space_id: space.id }]);
isFavourite = true;
}
}
async function deleteSpace() {
if (!confirm("Are you sure you want to delete this study space?")) return;
await supabase.from("study_spaces").delete().eq("id", space.id);
window.location.href = "/";
}
</script>
<Navbar>
@@ -21,19 +101,111 @@
</a>
</Navbar>
{#if isReportVisible}<Report {data} studySpaceId={space.id} hideFunc={hideReport} />
{/if}
{#if isFeedbackPromptVisible}
<Feedback
studySpaceData={{
...space,
building_location: place
}}
{supabase}
hideFunc={hideFeedbackPrompt}
/>
{/if}
<main>
<img src={imgUrl} alt="the study space" />
<div class="nameContainer">
{space.location}
<div class="imgContainer">
{#await supabase.auth.getSession() then resp}
{#if resp.data.session}
<div class="title-fav">
<Favourite
{isFavourite}
onToggleFavourite={handleToggleFavourite}
imgSize={27}
/>
</div>
{/if}
{/await}
<Carousel urls={imgUrls} />
</div>
<div class="nameContainer">
<div class="locationContainer">{space.location}</div>
</div>
{#if space.description != null && space.description.length > 0}
<p class="descContainer">
{space.description}
</p>
<hr />
<div class="whereSubtitle">Where it is:</div>
{/if}
<div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0}
<div class="tagContainer">
{#each space.tags as tag, idx (tag + idx)}
<span class="tag">
{tag}
</span>
{/each}
</div>
{/if}
<hr />
<div class="subtitle">Opening Times:</div>
{#each Array(7).keys() as idx (idx)}
{@const entries = timingsPerDay[idx]}
<div class="opening-entry">
<span class="day">{daysOfWeek[idx]}</span>
<div class="times">
{#each entries as entry (entry)}
<span class="time">
{entry.open_today_status
? "Open All Day"
: entry.open_today_status === false
? "Closed"
: `${formatTime(entry.opens_at)} ${formatTime(entry.closes_at)}`}
</span>
{:else}
<span class="time">Not known</span>
{/each}
</div>
</div>
{/each}
<hr />
<div class="subtitle">Directions:</div>
<p class="addrContainer">
{space.building_address}
{space.directions}
</p>
<div class="subtitle">Where it is:</div>
<p class="addrContainer">
{#if place.name}
{place.name} <br />
{/if}
{#each place.formatted_address?.split(",") || [] as line, idx (line + idx)}
{line.trim()} <br />
{/each}
</p>
<div class="addrMap" bind:this={mapElem}></div>
<button
type="button"
class="feedbackButton"
onclick={() => {
isFeedbackPromptVisible = true;
}}
>
Update Tags
</button>
<div class="actions">
{#if adminMode}
<div class="buttonContainer">
<Button href="/space/{space.id}/edit" type="link">Edit</Button>
<Button type="button" style="red" onclick={deleteSpace}>Delete</Button>
</div>
{:else}
<Button onclick={() => (isReportVisible = true)} style="red">Report</Button>
{/if}
</div>
</main>
<style>
@@ -58,19 +230,23 @@
background-color: #2e3c42;
width: 70%;
border: none;
margin: 0 auto;
margin: 1rem auto 0;
}
.nameContainer {
display: block;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.6rem;
margin-top: -0.5rem;
object-position: center;
background-color: #49bd85;
background-color: #189f5e;
border-radius: 8px;
font-size: 2.8rem;
font-weight: bold;
color: #ffffff;
z-index: 1;
}
.descContainer {
@@ -83,7 +259,7 @@
font-size: 1.2rem;
}
.whereSubtitle {
.subtitle {
font-size: 1.2rem;
font-weight: bold;
color: #ffffff;
@@ -92,6 +268,105 @@
.addrContainer {
font-size: 1.2rem;
padding: 0rem 1.4rem;
padding: 0rem 1.4rem 1rem;
}
.tagContainer {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 1.4rem;
border-radius: 0.5rem;
background: none;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.6rem;
}
.compulsoryContainer {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 1.4rem;
font-size: 1.3rem;
padding-bottom: 0;
}
.addrMap {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
}
.feedbackButton {
width: 100%;
padding: 0.7rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
text-align: center;
}
.actions {
display: flex;
flex-direction: column;
padding-top: 1rem;
}
.opening-entry {
display: flex;
gap: 0.75rem;
padding: 0.5rem 1.4rem;
align-items: center;
background-color: #2e4653;
margin: 0.2rem;
border-radius: 0.5rem;
}
.opening-entry .day {
font-weight: bold;
color: #ffffff;
align-items: center;
justify-content: center;
}
.opening-entry .times {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.25rem 1.5rem;
flex: 1;
align-items: end;
}
.opening-entry .time {
font-family: monospace;
color: #eaffeb;
}
.imgContainer {
position: relative;
}
.title-fav {
position: absolute;
top: 0;
right: 0;
background: #189f5e;
border-radius: 0 0 0 0.5rem;
z-index: 1;
width: 3.75rem;
height: 3.75rem;
}
.buttonContainer {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,60 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import type { Table } from "$lib";
type StudySpaceData = Omit<
Table<"study_spaces">,
"id" | "created_at" | "updated_at" | "building_location_old" | "building_location"
> & {
id?: string;
building_location?: google.maps.places.PlaceResult;
opening_times?: {
day_of_week: number;
opens_at: string;
closes_at: string;
open_today_status: boolean | null;
}[];
};
export const load: PageServerLoad = async ({ params, locals: { supabase } }) => {
if (params.id === "new") {
return {
space: {
description: "",
directions: "",
building_location: undefined,
location: "",
tags: [],
volume: "",
power: "",
wifi: ""
} as StudySpaceData
};
}
const { data: space, error: err } = await supabase
.from("study_spaces")
.select("*, study_space_images(*)")
.eq("id", params.id)
.single();
if (err) error(500, "Failed to load study space");
const studySpaceData = space as StudySpaceData & Partial<typeof space>;
const images = studySpaceData.study_space_images || [];
const { data: hours, error: hoursErr } = await supabase
.from("study_space_hours")
.select("day_of_week, opens_at, closes_at, open_today_status")
.eq("study_space_id", params.id)
.order("day_of_week", { ascending: true });
if (hoursErr) error(500, "Failed to load opening times");
studySpaceData.opening_times = hours;
delete studySpaceData.created_at;
delete studySpaceData.updated_at;
delete studySpaceData.study_space_images;
return {
space: studySpaceData as StudySpaceData,
images
};
};

View File

@@ -0,0 +1,702 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Text from "$lib/components/inputs/Text.svelte";
import Textarea from "$lib/components/inputs/Textarea.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import Button from "$lib/components/Button.svelte";
import Images from "$lib/components/inputs/Images.svelte";
import OpeningTimesDay from "$lib/components/inputs/OpeningTimesDay.svelte";
import {
availableStudySpaceTags,
wifiTags,
powerOutletTags,
volumeTags,
gmapsLoader,
daysOfWeek,
timeToMins,
collectTimings
} from "$lib";
import { onMount } from "svelte";
import type { Json } from "$lib/database.js";
const { data } = $props();
const { supabase } = $derived(data);
const { space, images } = $derived(data);
interface OpeningTime {
day_of_week: number;
opens_at: string;
closes_at: string;
open_today_status: boolean | null;
}
const studySpaceData = $state({
opening_times: [] as OpeningTime[],
...space
});
$effect(() => {
if (!space) return;
Object.assign(studySpaceData, space);
studySpaceData.opening_times = space.opening_times ?? [];
});
let scrollPosition = $state(0);
const existingImages = $derived(
Promise.all(
images?.map(async ({ image_path }) => {
const { data, error } = await supabase.storage
.from("files_bucket")
.download(image_path);
if (error) {
console.error(`Error downloading image ${image_path}:`, error);
return null;
}
return { data, name: image_path.split("/").pop() || "image", type: data.type };
}) || []
)
);
let spaceImgs = $state<FileList>();
let uploading = $state(false);
function checkTimings() {
let cannotExist = [] as number[];
let hasAllDays = Object.values(collectTimings(studySpaceData.opening_times)).every(
(item) => !(Array.isArray(item) && item.length === 0)
);
if (
(allDays.closes_at === "" || allDays.opens_at === "") &&
allDays.open_today_status === null &&
studySpaceData.opening_times.length > 0 &&
!hasAllDays
) {
alert(`No opening time provided for all other days.`);
return false;
}
const opensAtMinsAll = timeToMins(allDays.opens_at);
const closesAtMinsAll = timeToMins(allDays.closes_at);
if (opensAtMinsAll >= closesAtMinsAll) {
alert(`Opening time for all days is after closing time.`);
return false;
}
for (const entry of studySpaceData.opening_times) {
if (cannotExist.includes(entry.day_of_week)) {
alert(
"You marked a day as either closed or open all day, and then provided another timing."
);
return false;
}
if (entry.open_today_status != null) {
cannotExist.push(entry.day_of_week);
}
const opensAtMins = timeToMins(entry.opens_at);
const closesAtMins = timeToMins(entry.closes_at);
if (opensAtMins >= closesAtMins) {
alert(`Opening time for ${daysOfWeek[entry.day_of_week]} is after closing time.`);
return false;
}
}
return true;
}
function genTimings(studySpaceId: string) {
const fullDayOfWeek = [0, 1, 2, 3, 4, 5, 6];
// all day only
if (
studySpaceData.opening_times.length === 0 &&
((allDays.closes_at != "" && allDays.opens_at != "") ||
allDays.open_today_status != null)
) {
return fullDayOfWeek.map((day) => ({
study_space_id: studySpaceId,
day_of_week: day,
opens_at: allDays.open_today_status === null ? allDays.opens_at : "00:00",
closes_at: allDays.open_today_status === null ? allDays.closes_at : "00:01",
open_today_status: allDays.open_today_status
}));
}
// some days specified
const nonDefinedDays = fullDayOfWeek.filter(
(day) => !new Set(studySpaceData.opening_times.map((h) => h.day_of_week)).has(day)
);
return studySpaceData.opening_times
.map((h) => ({
study_space_id: studySpaceId,
day_of_week: h.day_of_week,
opens_at: h.open_today_status === null ? h.opens_at : "00:00",
closes_at: h.open_today_status === null ? h.closes_at : "00:01",
open_today_status: h.open_today_status
}))
.concat(
nonDefinedDays.map((day) => ({
study_space_id: studySpaceId,
day_of_week: day,
opens_at: allDays.open_today_status === null ? allDays.opens_at : "00:00",
closes_at: allDays.open_today_status === null ? allDays.closes_at : "00:01",
open_today_status: allDays.open_today_status
}))
);
}
async function uploadStudySpace() {
if (!checkTimings()) return;
if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file.");
if (!studySpaceData.building_location) return alert("Please select a building location.");
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { opening_times: _, ...spacePayload } = studySpaceData;
const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces")
.upsert(
{
...spacePayload,
building_location: studySpaceData.building_location as Json
},
{
onConflict: "id"
}
)
.select()
.single();
if (studySpaceError)
return alert(`Error uploading study space: ${studySpaceError.message}`);
const imgUploads = await Promise.all(
Array(spaceImgs.length)
.keys()
.map(async (i) => {
const imageFile = spaceImgs![i];
const resp = await supabase.storage
.from("files_bucket")
.upload(
`public/${studySpaceInsert.id}-${crypto.randomUUID()}-${imageFile.name}`,
imageFile,
{
contentType: imageFile.type
}
);
return resp;
})
);
const imageError = imgUploads.find(({ error }) => error)?.error;
if (imageError) return alert(`Error uploading image: ${imageError.message}`);
if (space.id) {
const { error: imageOverwriteError } = await supabase
.from("study_space_images")
.delete()
.eq("study_space_id", space.id);
if (imageOverwriteError)
return alert(`Error overwriting existing images: ${imageOverwriteError.message}`);
}
const { error: imageInsertError } = await supabase
.from("study_space_images")
.insert(
imgUploads.map(({ data }) => ({
study_space_id: studySpaceInsert.id,
image_path: data!.path
}))
)
.select();
if (imageInsertError) return alert(`Error creating image: ${imageInsertError.message}`);
const { error: deleteErr } = await supabase
.from("study_space_hours")
.delete()
.eq("study_space_id", studySpaceInsert.id);
if (deleteErr) return alert(`Error clearing old hours: ${deleteErr.message}`);
// Nothing is provided
if (
(allDays.closes_at != "" && allDays.opens_at != "") ||
studySpaceData.opening_times.length === 7 ||
allDays.open_today_status != null
) {
const { error: hoursErr } = await supabase
.from("study_space_hours")
.insert(genTimings(studySpaceInsert.id));
if (hoursErr) return alert(`Error saving opening times: ${hoursErr.message}`);
}
await supabase.channel("study_space_updates").send({
type: "broadcast",
event: "study_space_updated",
payload: {
study_space_id: studySpaceInsert.id
}
});
alert("Thank you for your contribution!");
// Redirect to the new study space page
await goto(`/space/${studySpaceInsert.id}`, {
invalidate: ["db:study_spaces"]
});
}
// Tag
let tagFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
let filteredTags = $derived(
availableStudySpaceTags
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase()))
.filter((tag) => !studySpaceData.tags.includes(tag))
);
let dropdownVisible = $state(false);
function deleteTag(tagName: string) {
return () => {
studySpaceData.tags = studySpaceData.tags.filter((tag) => tag !== tagName);
};
}
function addTag(tagName: string) {
return () => {
if (!studySpaceData.tags.includes(tagName)) {
studySpaceData.tags.push(tagName);
}
tagFilter = "";
};
}
let addressInput = $state<HTMLInputElement>();
onMount(async () => {
const loader = await gmapsLoader();
const places = await loader.importLibrary("places");
if (!addressInput) return console.error("Address input element not found");
addressInput.value = studySpaceData.building_location?.formatted_address || "";
if (studySpaceData.building_location?.name) {
addressInput.value = `${studySpaceData.building_location.name}, ${addressInput.value}`;
}
const placeAutocomplete = new places.Autocomplete(addressInput, {
componentRestrictions: { country: "gb" }
});
placeAutocomplete.addListener("place_changed", () => {
studySpaceData.building_location = placeAutocomplete.getPlace();
});
});
onMount(async () => {
const images = await existingImages;
const dt = new DataTransfer();
images.forEach((response) => {
if (response) {
const file = new File([response.data], response.name, { type: response.type });
dt.items.add(file);
}
});
spaceImgs = dt.files;
});
// Opening times
let allDays = $state({
opens_at: "",
closes_at: "",
open_today_status: null
});
</script>
<Navbar>
<a href="/">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
<form
onsubmit={async (event) => {
event.preventDefault();
uploading = true;
await uploadStudySpace();
uploading = false;
}}
>
<Images
name="study-space-image"
minHeight="16rem"
bind:files={spaceImgs}
bind:scrollPosition
required
/>
{#if spaceImgs?.length || 0 > 0}
<label class="additionalImages" for="additionalImages">
Add more images
<input
type="file"
name="additionalImages"
id="additionalImages"
multiple
accept=".png, .jpg, .jpeg, .svg"
onchange={function () {
const dt = new DataTransfer();
if (spaceImgs) {
for (let i = 0; i < spaceImgs.length; i++) {
dt.items.add(spaceImgs[i]);
}
}
if (this.files) {
for (let i = 0; i < this.files.length; i++) {
dt.items.add(this.files[i]);
}
}
spaceImgs = dt.files;
scrollPosition = dt.files.length - 1;
}}
/>
</label>
{/if}
<label for="location">Enter the name:</label>
<Text
name="location"
bind:value={studySpaceData.location}
placeholder="Huxeley Labs 225"
maxlength={35}
required
/>
{#if (studySpaceData.location ?? "").length > 25}
<p class="lengthPopup">
Try to keep the name succinct—for example, building + room name. Put any further
information like floor number in the description.
</p>
{/if}
<div class="compulsoryTags">
<div class="compulsoryContainer">
<label for="volume">Sound level:</label>
<select bind:value={studySpaceData.volume} name="volume" class="compulsoryTagSelect">
<option value="" disabled selected>How noisy is it?</option>
{#each volumeTags as volumeTag (volumeTag)}
<option value={volumeTag}>{volumeTag}</option>
{/each}
</select>
</div>
<div class="compulsoryContainer">
<label for="powerOutlets">Power outlets:</label>
<select
bind:value={studySpaceData.power}
name="poweOutlets"
class="compulsoryTagSelect"
>
<option value="" disabled selected>Power outlets?</option>
{#each powerOutletTags as powerOutletTag (powerOutletTag)}
<option value={powerOutletTag}>{powerOutletTag}</option>
{/each}
</select>
</div>
<div class="compulsoryContainer">
<label for="wifi">Wifi:</label>
<select bind:value={studySpaceData.wifi} name="wifi" class="compulsoryTagSelect">
<option value="" disabled selected>How's the wifi?</option>
{#each wifiTags as wifiTag (wifiTag)}
<option value={wifiTag}>{wifiTag}</option>
{/each}
</select>
</div>
</div>
<label for="openingTimes">Opening times (Optional):</label>
<div class="allDaysTiming">
{#each studySpaceData.opening_times as opening_time, index (opening_time)}
<OpeningTimesDay
{index}
bind:openingValue={opening_time.opens_at}
bind:closingValue={opening_time.closes_at}
bind:openTodayStatus={opening_time.open_today_status}
bind:day={opening_time.day_of_week}
onHide={() => {
studySpaceData.opening_times.splice(index, 1);
}}
/>
<hr />
{/each}
<OpeningTimesDay
index={-1}
bind:openingValue={allDays.opens_at}
bind:closingValue={allDays.closes_at}
bind:openTodayStatus={allDays.open_today_status}
day={studySpaceData.opening_times.length === 0 ? 7 : 8}
/>
</div>
<Button
style="normal"
type="button"
onclick={() => {
studySpaceData.opening_times.push({
day_of_week: 0,
opens_at: "09:00",
closes_at: "17:00",
open_today_status: null
});
}}>Add new day</Button
>
<label for="tags">Additional tags (Optional):</label>
<div class="tagDisplay">
{#each studySpaceData.tags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
{tagName}
<img src={crossUrl} alt="delete" /></button
>
{/each}
<input
type="text"
name="tagInput"
class="tagInput"
bind:value={tagFilter}
bind:this={tagFilterElem}
onfocus={() => {
dropdownVisible = true;
}}
onblur={() => {
dropdownVisible = false;
}}
onkeypress={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const tag = filteredTags[0];
if (tag) addTag(tag)();
}
}}
placeholder="Add tags..."
/>
{#if dropdownVisible}
<div class="tagDropdown">
{#each filteredTags as avaliableTag (avaliableTag)}
<button
class="avaliableTag"
onclick={addTag(avaliableTag)}
onmousedown={(e) => {
// Keep input focused
e.preventDefault();
e.stopPropagation();
}}
type="button"
>
{avaliableTag}
</button>
{/each}
</div>
{/if}
</div>
<label for="description">Brief description (Optional):</label>
<Textarea
name="description"
bind:value={studySpaceData.description}
placeholder="A quiet room with a lovely view of the park."
rows={2}
/>
<label for="directions">Give directions:</label>
<Textarea
name="directions"
bind:value={studySpaceData.directions}
placeholder="Turn left once you enter Huxley and walk straight."
rows={2}
/>
<label for="building-location">Add the building location:</label>
<Text
name="building-location"
bind:inputElem={addressInput}
placeholder="Huxley Building, Imperial South Kensington Campus"
required
/>
<div class="submit">
<Button
type="submit"
disabled={(spaceImgs?.length || 0) === 0 ||
!studySpaceData.location ||
!studySpaceData.wifi ||
!studySpaceData.volume ||
!studySpaceData.power ||
!studySpaceData.building_location ||
uploading}
>
Share this study space!
</Button>
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.5rem;
max-width: 32rem;
margin: 0 auto;
}
label {
color: #ffffff;
margin-top: 0.5rem;
}
.submit {
position: sticky;
display: flex;
flex-direction: column;
margin-top: 0.5rem;
bottom: 0;
margin-left: -0.5rem;
width: calc(100% + 1rem);
}
.tagDisplay {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: left;
justify-content: left;
position: relative;
width: 100%;
height: auto;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
.tagInput {
width: 100%;
height: 100%;
background: none;
color: #eaffeb;
font-size: 1rem;
border: none;
outline: none;
}
::placeholder {
color: #859a90;
opacity: 1;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.9rem;
cursor: pointer;
border-width: 0rem;
}
.tag img {
width: 1rem;
height: 1rem;
margin-left: 0.2rem;
}
.tagDropdown {
width: 100%;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
position: absolute;
background-color: #2e4653;
box-shadow: 1px 1px 0.5rem rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
overflow-y: auto;
max-height: 10rem;
top: 100%;
left: 50%;
transform: translateX(-50%);
}
.avaliableTag {
width: 100%;
text-align: left;
background: none;
border: none;
color: #eaffeb;
font-size: 0.9rem;
margin: 0%;
padding: 0 0.8rem 0.4rem;
}
.avaliableTag:first-child {
padding-top: 0.6rem;
background-color: hsl(201, 26%, 60%);
}
.avaliableTag:last-child {
padding-bottom: 0.6rem;
}
.compulsoryTags {
display: grid;
gap: 0.4rem;
border-radius: 0.5rem;
background-color: none;
width: 100%;
font-size: 1rem;
grid-template-columns: repeat(3, 1fr);
}
.compulsoryContainer {
display: flex;
flex-direction: column;
align-items: left;
justify-content: top;
border-radius: 0.5rem;
background-color: none;
}
.compulsoryTagSelect {
width: 100%;
height: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 0.9rem;
text-align: left;
}
.compulsoryTagSelect option {
background-color: #2e4653;
color: #eaffeb;
}
.additionalImages {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(-83deg, #3fb095, #189f5e);
box-shadow: 0rem 0rem 0.5rem #182125;
color: #eaffeb;
border: none;
cursor: pointer;
text-align: center;
}
.additionalImages:focus {
outline: 2px solid #007bff;
}
.additionalImages input {
display: none;
}
/* Opening times layout and inputs styling */
.allDaysTiming {
border-radius: 0.5rem;
background: none;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.5rem;
}
hr {
margin: 0%;
padding: 0;
width: 100%;
height: 2px;
border: none;
background-color: #eaffeb;
border-radius: 5rem;
}
.lengthPopup {
background-color: #2e4653;
border-radius: 0.5rem;
padding: 0.5rem;
}
</style>

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,130 @@
<script lang="ts">
import Navbar from "$lib/components/Navbar.svelte";
import crossUrl from "$lib/assets/cross.svg";
import type { Table } from "$lib";
const { data } = $props();
const { reports, supabase } = $derived(data);
import { invalidate } from "$app/navigation";
import { onMount } from "svelte";
let deleting = $state(false);
async function deleteReport(report: Table<"reports">) {
const { error: reportDeleteError } = await supabase
.from("reports")
.delete()
.eq("id", report.id);
if (reportDeleteError)
return alert(`Error submitting report: ${reportDeleteError.message}`);
else alert("Report deleted successfully!");
}
onMount(() => {
const reportsChannel = supabase
.channel("report_updates")
.on("broadcast", { event: "reports_updated" }, () => {
invalidate("db:reports");
})
.subscribe();
return () => reportsChannel.unsubscribe();
});
</script>
<Navbar>
<a href="/">
<img src={crossUrl} alt="close" />
</a>
</Navbar>
<main>
{#each reports as report (report.id)}
<div class="reportContainer">
<button
type="button"
class="deleteReport"
aria-label="delete"
disabled={deleting}
onclick={async () => {
deleting = true;
await deleteReport(report);
await invalidate("db:reports");
deleting = false;
}}><img src={crossUrl} alt="delete" /></button
>
<h1 class="submitHeader">
{report.study_spaces?.location ?? "Study space doesn't exist"}
</h1>
<span class="tag">
{report.type}
</span>
<p class="content">{report.content}</p>
<a href="/space/{report.study_space_id}" class="viewPage">View Space</a>
</div>
{/each}
</main>
<style>
main {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
flex-direction: column;
padding: 5rem 0 1rem 0;
max-width: 100%;
margin: 0 auto;
height: 200vh;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.6rem;
width: fit-content;
}
.reportContainer {
display: flex;
flex-direction: column;
width: 90%;
gap: 0.5rem;
padding: 1rem;
background-color: #182125;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
color: #eaffeb;
position: relative;
translate: 0 -3.5rem;
border: 2px solid #eaffeb;
}
.viewPage {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background-color: #189f5e;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
text-align: center;
text-decoration: none;
}
.deleteReport {
position: absolute;
top: 0.1rem;
right: 0.1rem;
background: none;
border: none;
cursor: pointer;
}
</style>

View File

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

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

@@ -0,0 +1,14 @@
CREATE TABLE study_space_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
study_space_id UUID REFERENCES study_spaces(id) ON DELETE CASCADE,
day_of_week INT CHECK (day_of_week BETWEEN 0 AND 6), -- 0 = Sunday, 6 = Saturday
opens_at TIME NOT NULL,
closes_at TIME NOT NULL,
is_24_7 BOOLEAN DEFAULT FALSE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TRIGGER study_space_hours_updated_at
AFTER UPDATE ON study_space_hours
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();

View File

@@ -0,0 +1,29 @@
CREATE TABLE users (
id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
is_admin boolean NOT NULL DEFAULT false,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE TRIGGER users_handle_updated_at
AFTER UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
-- Auto-create users when auth.users are created
CREATE FUNCTION handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.users (id, contact_email)
VALUES (NEW.id, NEW.email);
RETURN NEW;
END;
$$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,9 +9,17 @@ CREATE POLICY "Whack"
CREATE TABLE study_spaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
description text,
directions text,
-- Location within building, e.g., "Room 101"
location text,
building_address text,
-- Not bothered to write a proper data migration
building_location_old text,
building_location jsonb,
tags text[] NOT NULL DEFAULT array[]::text[],
volume text NOT NULL,
wifi text NOT NULL,
power text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
@@ -24,6 +32,26 @@ CREATE TABLE study_space_images (
PRIMARY KEY (study_space_id, image_path)
);
CREATE TABLE reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
type text NOT NULL,
content text
);
CREATE TABLE study_space_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
study_space_id UUID REFERENCES study_spaces(id) ON DELETE CASCADE,
day_of_week INT CHECK (day_of_week BETWEEN 0 AND 6) NOT NULL, -- 0 = Sunday, 6 = Saturday
opens_at TIME NOT NULL,
closes_at TIME NOT NULL,
open_today_status BOOLEAN,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
-- Triggers
CREATE TRIGGER study_spaces_updated_at
AFTER UPDATE ON study_spaces
@@ -32,3 +60,11 @@ FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER study_space_images_updated_at
AFTER UPDATE ON study_space_images
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER reports_updated_at
AFTER UPDATE ON reports
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
CREATE TRIGGER study_space_hours_updated_at
AFTER UPDATE ON study_space_hours
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();

View File

@@ -0,0 +1,41 @@
CREATE TABLE users (
id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
is_admin boolean NOT NULL DEFAULT false,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE TRIGGER users_handle_updated_at
AFTER UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();
-- Auto-create users when auth.users are created
CREATE FUNCTION handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.users (id)
VALUES (NEW.id);
RETURN NEW;
END;
$$;
CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Table to store users' favourite study spaces
CREATE TABLE favourite_study_spaces (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
study_space_id uuid REFERENCES study_spaces(id) ON DELETE CASCADE,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, study_space_id)
);
CREATE TRIGGER favourite_study_spaces_updated_at
AFTER UPDATE ON favourite_study_spaces
FOR EACH ROW EXECUTE FUNCTION handle_updated_at();