49 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
37 changed files with 1475 additions and 432 deletions

View File

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

View File

@@ -1,7 +1,7 @@
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg"> <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)"> <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="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="#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="#189f5e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_117_282"> <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

View File

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

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

@@ -69,6 +69,47 @@ export type Database = {
}, },
] ]
} }
study_space_hours: {
Row: {
closes_at: string
created_at: string | null
day_of_week: number
id: string
open_today_status: boolean | null
opens_at: string
study_space_id: string | null
updated_at: string | null
}
Insert: {
closes_at: string
created_at?: string | null
day_of_week: number
id?: string
open_today_status?: boolean | null
opens_at: string
study_space_id?: string | null
updated_at?: string | null
}
Update: {
closes_at?: string
created_at?: string | null
day_of_week?: number
id?: string
open_today_status?: boolean | null
opens_at?: string
study_space_id?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "study_space_hours_study_space_id_fkey"
columns: ["study_space_id"]
isOneToOne: false
referencedRelation: "study_spaces"
referencedColumns: ["id"]
},
]
}
study_space_images: { study_space_images: {
Row: { Row: {
created_at: string | null created_at: string | null
@@ -104,6 +145,7 @@ export type Database = {
building_location_old: string | null building_location_old: string | null
created_at: string | null created_at: string | null
description: string | null description: string | null
directions: string
id: string id: string
location: string | null location: string | null
power: string power: string
@@ -117,6 +159,7 @@ export type Database = {
building_location_old?: string | null building_location_old?: string | null
created_at?: string | null created_at?: string | null
description?: string | null description?: string | null
directions: string
id?: string id?: string
location?: string | null location?: string | null
power: string power: string
@@ -130,6 +173,7 @@ export type Database = {
building_location_old?: string | null building_location_old?: string | null
created_at?: string | null created_at?: string | null
description?: string | null description?: string | null
directions?: string
id?: string id?: string
location?: string | null location?: string | null
power?: string power?: string
@@ -161,45 +205,40 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
study_space_hours: { favourite_study_spaces: {
Row: { Row: {
id: string user_id: string
study_space_id: string study_space_id: string
day_of_week: number
opens_at: string
closes_at: string
is_24_7: boolean
created_at: string | null created_at: string | null
updated_at: string | null updated_at: string | null
} }
Insert: { Insert: {
id?: string user_id: string
study_space_id: string study_space_id: string
day_of_week: number
opens_at: string
closes_at: string
is_24_7: boolean
created_at?: string | null created_at?: string | null
updated_at?: string | null updated_at?: string | null
} }
Update: { Update: {
id?: string user_id?: string
study_space_id?: string study_space_id?: string
day_of_week?: number
opens_at?: string
closes_at?: string
is_24_7?: boolean
created_at?: string | null created_at?: string | null
updated_at?: string | null updated_at?: string | null
} }
Relationships: [ Relationships: [
{ {
foreignKeyName: "study_space_hours_study_space_id_fkey" 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"] columns: ["study_space_id"]
isOneToOne: false isOneToOne: false
referencedRelation: "study_spaces" referencedRelation: "study_spaces"
referencedColumns: ["id"] referencedColumns: ["id"]
}, }
] ]
} }
} }
@@ -331,3 +370,4 @@ export const Constants = {
Enums: {}, Enums: {},
}, },
} as const } as const

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

@@ -18,7 +18,7 @@ export const availableStudySpaceTags = [
"Air conditioned", "Air conditioned",
"Cold", "Cold",
"PCs", "PCs",
"Cringe" "Rodent-ridden"
]; ];
export const volumeTags = ["Silent", "Some Noise", "Loud"]; export const volumeTags = ["Silent", "Some Noise", "Loud"];
@@ -57,5 +57,56 @@ export const daysOfWeek = [
"Wednesday", "Wednesday",
"Thursday", "Thursday",
"Friday", "Friday",
"Saturday" "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

@@ -5,7 +5,7 @@
import { invalidate } from "$app/navigation"; import { invalidate } from "$app/navigation";
let { data, children } = $props(); let { data, children } = $props();
let { session, supabase } = $derived(data); let { session, supabase, route } = $derived(data);
onMount(() => { onMount(() => {
posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", { posthog.init("phc_hTnel2Q8GKo0TgIBnFWBueJW1ATmCG9tJOtETnQTUdY", {
@@ -17,7 +17,23 @@
invalidate("supabase:auth"); invalidate("supabase:auth");
} }
}); });
return () => data.subscription.unsubscribe(); 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> </script>
@@ -33,6 +49,7 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%; width: 100%;
min-height: 100vh;
} }
:global(html) { :global(html) {
@@ -40,6 +57,10 @@
color: #eaffeb; color: #eaffeb;
} }
:global(body.coloured) {
background: linear-gradient(-77deg, #2e4653, #223a37);
}
:global(*) { :global(*) {
box-sizing: border-box; box-sizing: border-box;
font-family: Inter; 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 { Database } from "$lib/database";
import type { LayoutLoad } from "./$types"; 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 * Declare a dependency so the layout can be invalidated, for example, on
* session refresh. * session refresh.
@@ -40,5 +40,12 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user } data: { user }
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
return { session, supabase, user, adminMode: data.adminMode }; return {
session,
supabase,
user,
adminMode: data.adminMode,
route,
searchParams: url.searchParams.toString()
};
}; };

View File

@@ -8,7 +8,25 @@ export const load: PageServerLoad = async ({ depends, locals: { supabase } }) =>
.select("*, study_space_images(*), study_space_hours(*)"); .select("*, study_space_images(*), study_space_hours(*)");
if (err) error(500, "Failed to load study spaces"); 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 { return {
studySpaces studySpaces,
session,
favouriteIds
}; };
}; };

View File

@@ -2,48 +2,56 @@
import SpaceCard from "$lib/components/SpaceCard.svelte"; import SpaceCard from "$lib/components/SpaceCard.svelte";
import defaultImg from "$lib/assets/study_space.png"; import defaultImg from "$lib/assets/study_space.png";
import crossUrl from "$lib/assets/cross.svg"; import crossUrl from "$lib/assets/cross.svg";
import searchUrl from "$lib/assets/search.svg";
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import { allTags, volumeTags, wifiTags, powerOutletTags } from "$lib"; import { collectTimings, timeToMins, haversineDistance } from "$lib";
import Button from "$lib/components/Button.svelte"; 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 { data } = $props();
const { studySpaces, supabase, session, adminMode } = $derived(data); const {
studySpaces,
supabase,
session,
adminMode,
searchParams,
favouriteIds: initialFavourites = []
} = $derived(data);
let selectedTags = $state<string[]>([]); let favouriteIds = $derived<string[]>(initialFavourites);
let tagFilter = $state(""); let showFavourites = $state(false);
let openingFilter = $state("");
let closingFilter = $state("");
let tagFilterElem = $state<HTMLInputElement>();
function categorySelected(category: string[]) { const sortFilter = $derived(urldecodeSortFilter(searchParams));
return category.some((tag) => selectedTags.includes(tag)); const selectedTags = $derived(sortFilter.tags ?? []);
} const openingFilter = $derived(sortFilter.openAt?.from);
const closingFilter = $derived(sortFilter.openAt?.to);
const sortNear = $derived(sortFilter.nearby);
let filteredTags = $derived( // Toggle a space in/out of favourites
allTags async function handleToggleFavourite(id: string) {
.filter((tag) => tag.toLowerCase().includes(tagFilter.toLowerCase())) if (!session?.user) return;
.filter((tag) => !selectedTags.includes(tag)) const already = favouriteIds.includes(id);
.filter((tag) => { if (already) {
if (selectedTags.includes(tag)) return false; await supabase
.from("favourite_study_spaces")
if (categorySelected(volumeTags) && volumeTags.includes(tag)) return false; .delete()
if (categorySelected(wifiTags) && wifiTags.includes(tag)) return false; .match({ user_id: session.user.id, study_space_id: id });
if (categorySelected(powerOutletTags) && powerOutletTags.includes(tag)) favouriteIds = favouriteIds.filter((x) => x !== id);
return false; } else {
await supabase
return true; .from("favourite_study_spaces")
}) .insert([{ user_id: session.user.id, study_space_id: id }]);
); favouriteIds = [...favouriteIds, id];
}
// Convert "HH:MM" or "HH:MM:SS" to minutes since midnight
function toMinutes(timeStr: string): number {
const [h, m] = timeStr.slice(0, 5).split(":").map(Number);
return h * 60 + m;
} }
// Combine tag and time filtering // Combine tag and time filtering
let filteredStudySpaces = $derived( let filteredStudySpaces = $derived(
studySpaces studySpaces
// only include favourites when showFavourites===true
.filter((space) => !showFavourites || favouriteIds?.includes(space.id))
// tag filtering // tag filtering
.filter((space) => { .filter((space) => {
if (selectedTags.length === 0) return true; if (selectedTags.length === 0) return true;
@@ -62,13 +70,13 @@
(h) => h.day_of_week === new Date().getDay() (h) => h.day_of_week === new Date().getDay()
); );
if (!entry) return false; if (!entry) return false;
if (entry.is_24_7) return true; if (entry.open_today_status) return true;
const openMin = toMinutes(entry.opens_at); const openMin = timeToMins(entry.opens_at);
let closeMin = toMinutes(entry.closes_at); let closeMin = timeToMins(entry.closes_at);
// Treat midnight as end of day and handle overnight spans // Treat midnight as end of day and handle overnight spans
if (closeMin === 0) closeMin = 24 * 60; if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60; if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin = toMinutes(openingFilter); const filterMin = timeToMins(openingFilter);
// Include spaces open at the filter time // Include spaces open at the filter time
return filterMin >= openMin && filterMin < closeMin; return filterMin >= openMin && filterMin < closeMin;
}) })
@@ -79,34 +87,111 @@
(h) => h.day_of_week === new Date().getDay() (h) => h.day_of_week === new Date().getDay()
); );
if (!entry) return false; if (!entry) return false;
if (entry.is_24_7) return true; if (entry.open_today_status) return true;
const openMin = toMinutes(entry.opens_at); const openMin = timeToMins(entry.opens_at);
let closeMin = toMinutes(entry.closes_at); let closeMin = timeToMins(entry.closes_at);
if (closeMin === 0) closeMin = 24 * 60; if (closeMin === 0) closeMin = 24 * 60;
if (closeMin <= openMin) closeMin += 24 * 60; if (closeMin <= openMin) closeMin += 24 * 60;
const filterMin = const filterMin =
toMinutes(closingFilter) === 0 ? 24 * 60 : toMinutes(closingFilter); timeToMins(closingFilter) === 0 ? 24 * 60 : timeToMins(closingFilter);
// Include spaces still open at the filter time // Include spaces still open at the filter time
return filterMin > openMin && filterMin <= closeMin; return filterMin > openMin && filterMin <= closeMin;
}) })
); );
let dropdownVisible = $state(false); const sortedByOpenNow = $derived(
filteredStudySpaces.toSorted((a, b) => {
function deleteTag(tagName: string) { const now = new Date();
return () => { const time = now.toTimeString().slice(0, 5);
selectedTags = selectedTags.filter((tag) => tag !== tagName); 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);
function addTag(tagName: string) { for (const timing of timingsPerDay[today]) {
return () => { if (timing.open_today_status === true) {
if (!selectedTags.includes(tagName)) { openUntil[index] = 24 * 60;
selectedTags.push(tagName); 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;
}
}
}
} }
tagFilter = ""; 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> </script>
<Navbar> <Navbar>
@@ -115,109 +200,74 @@
<img src={crossUrl} alt="new" class="new-space" /> <img src={crossUrl} alt="new" class="new-space" />
</a> </a>
{/if} {/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> </Navbar>
<main> <main>
{#if adminMode} {#each sortedStudySpaces as studySpace (studySpace.id)}
<div class="checkReports"> <SpaceCard
<Button href="/space/reports" type="link" style="red">Check Reports</Button> alt="Photo of {studySpace.description}"
</div> href="/space/{studySpace.id}"
{/if} imgSrc={studySpace.study_space_images.length > 0
<div class="time-filter-container">
<label>
Open from:
<input type="time" bind:value={openingFilter} />
</label>
<label>
Open until:
<input type="time" bind:value={closingFilter} />
</label>
</div>
<div class="tag-filter-container">
<form>
<div class="tagDisplay">
{#each selectedTags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button">
{tagName}
<img src={crossUrl} alt="delete" /></button
>
{/each}
<input
type="text"
name="tagInput"
class="tagInput"
bind:value={tagFilter}
bind:this={tagFilterElem}
onfocus={() => {
dropdownVisible = true;
}}
onblur={() => {
dropdownVisible = false;
}}
onkeypress={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const tag = filteredTags[0];
if (tag) addTag(tag)();
}
}}
placeholder="Search by tags..."
/>
{#if dropdownVisible}
<div class="tagDropdown">
{#each filteredTags as avaliableTag (avaliableTag)}
<button
class="avaliableTag"
onclick={addTag(avaliableTag)}
onmousedown={(e) => {
// Keep input focused
e.preventDefault();
e.stopPropagation();
}}
type="button"
>
{avaliableTag}
</button>
{/each}
</div>
{/if}
</div>
</form>
</div>
{#each filteredStudySpaces as studySpace (studySpace.id)}
{@const imgUrl =
studySpace.study_space_images.length > 0
? supabase.storage ? supabase.storage
.from("files_bucket") .from("files_bucket")
.getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl .getPublicUrl(studySpace.study_space_images[0].image_path).data.publicUrl
: defaultImg} : defaultImg}
<SpaceCard
alt="Photo of {studySpace.description}"
href="/space/{studySpace.id}"
imgSrc={imgUrl}
space={studySpace} space={studySpace}
hours={studySpace.study_space_hours} 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} {/each}
</main> </main>
<footer> <footer>
{#if session} {#if session}
<Button onclick={() => supabase.auth.signOut()}>Signout</Button> <Button
onclick={async () => {
await supabase.auth.signOut();
invalidateAll();
}}>Signout</Button
>
{:else} {:else}
<Button href="/auth" type="link">Login / Signup</Button> <Button href="/auth" type="link">Login / Signup</Button>
{/if} {/if}
</footer> </footer>
{#if adminMode}
<div class="adminMode">You are in admin mode</div>
{/if}
<style> <style>
main { main {
display: grid; display: grid;
box-sizing: border-box; box-sizing: border-box;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 0.5rem;
padding: 1rem; padding: 0.5rem;
max-width: 600px; max-width: 32rem;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
@@ -235,125 +285,43 @@
transform: rotate(45deg); transform: rotate(45deg);
} }
.tag-filter-container {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
}
.time-filter-container {
grid-column: 1 / -1;
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 0.5rem;
}
.time-filter-container label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: #eaffeb;
}
.time-filter-container input[type="time"] {
background: none;
border: 2px solid #eaffeb;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #eaffeb;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 32rem;
}
.tagDisplay {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: left;
justify-content: left;
position: relative;
width: 100%;
height: auto;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid #eaffeb;
background: none;
color: #eaffeb;
font-size: 1rem;
}
.tagInput {
flex: 1 1 100%;
min-width: 10rem;
background: none;
color: #eaffeb;
font-size: 1rem;
border: none;
outline: none;
}
::placeholder {
color: #859a90;
opacity: 1;
}
.tag {
display: flex;
align-items: center;
border-radius: 0.25rem;
background-color: #2e4653;
color: #eaffeb;
font-size: 0.9rem;
cursor: pointer;
border-width: 0rem;
}
.tag img {
width: 1rem;
height: 1rem;
margin-left: 0.2rem;
}
.tagDropdown {
width: 100%;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
position: absolute;
background-color: #2e4653;
box-shadow: 1px 1px 0.5rem rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
overflow-y: auto;
max-height: 10rem;
top: 100%;
left: 50%;
transform: translateX(-50%);
}
.avaliableTag {
width: 100%;
text-align: left;
background: none;
border: none;
color: #eaffeb;
font-size: 0.9rem;
margin: 0%;
padding: 0 0.8rem 0.4rem;
}
.avaliableTag:first-child {
padding-top: 0.6rem;
background-color: hsl(201, 26%, 60%);
}
.avaliableTag:last-child {
padding-bottom: 0.6rem;
}
.checkReports { .checkReports {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 1.2rem; 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) { @media (max-width: 20rem) {
@@ -361,4 +329,24 @@
grid-template-columns: 1fr; 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> </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

@@ -7,8 +7,9 @@
import Report from "$lib/components/Report.svelte"; import Report from "$lib/components/Report.svelte";
import Feedback from "$lib/components/Feedback.svelte"; import Feedback from "$lib/components/Feedback.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { gmapsLoader, daysOfWeek, formatTime } from "$lib"; import { gmapsLoader, daysOfWeek, formatTime, collectTimings } from "$lib";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import Favourite from "$lib/components/Favourite.svelte";
const { data } = $props(); const { data } = $props();
const { space, supabase, adminMode } = $derived(data); const { space, supabase, adminMode } = $derived(data);
@@ -51,12 +52,47 @@
}); });
}); });
const hoursByDay = $derived(new Map(space.study_space_hours.map((h) => [h.day_of_week, h]))); let timingsPerDay = collectTimings(space.study_space_hours);
const openingEntries = daysOfWeek.map((day, idx) => ({ let isFavourite = $state(false);
day, onMount(async () => {
entry: hoursByDay.get(idx) 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> </script>
<Navbar> <Navbar>
@@ -78,9 +114,22 @@
/> />
{/if} {/if}
<main> <main>
<Carousel urls={imgUrls} /> <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="nameContainer">
{space.location} <div class="locationContainer">{space.location}</div>
</div> </div>
{#if space.description != null && space.description.length > 0} {#if space.description != null && space.description.length > 0}
<p class="descContainer"> <p class="descContainer">
@@ -91,7 +140,7 @@
<div class="compulsoryContainer"><CompulsoryTags {space} /></div> <div class="compulsoryContainer"><CompulsoryTags {space} /></div>
{#if space.tags.length > 0} {#if space.tags.length > 0}
<div class="tagContainer"> <div class="tagContainer">
{#each space.tags as tag (tag)} {#each space.tags as tag, idx (tag + idx)}
<span class="tag"> <span class="tag">
{tag} {tag}
</span> </span>
@@ -100,27 +149,38 @@
{/if} {/if}
<hr /> <hr />
<div class="subtitle">Opening Times:</div> <div class="subtitle">Opening Times:</div>
{#each openingEntries as { day, entry } (entry)} {#each Array(7).keys() as idx (idx)}
{@const entries = timingsPerDay[idx]}
<div class="opening-entry"> <div class="opening-entry">
<span class="day">{day}:</span> <span class="day">{daysOfWeek[idx]}</span>
<span class="times"> <div class="times">
{#if entry} {#each entries as entry (entry)}
{entry.is_24_7 <span class="time">
? "Open All Day" {entry.open_today_status
: `${formatTime(entry.opens_at)} ${formatTime(entry.closes_at)}`} ? "Open All Day"
: entry.open_today_status === false
? "Closed"
: `${formatTime(entry.opens_at)} ${formatTime(entry.closes_at)}`}
</span>
{:else} {:else}
Closed <span class="time">Not known</span>
{/if} {/each}
</span> </div>
</div> </div>
{/each} {/each}
<hr />
<div class="subtitle">Directions:</div>
<p class="addrContainer">
{space.directions}
</p>
<div class="subtitle">Where it is:</div> <div class="subtitle">Where it is:</div>
<p class="addrContainer"> <p class="addrContainer">
{#if place.name} {#if place.name}
{place.name} <br /> {place.name} <br />
{/if} {/if}
{#each place.formatted_address?.split(",") || [] as line (line)} {#each place.formatted_address?.split(",") || [] as line, idx (line + idx)}
{line.trim()} <br /> {line.trim()} <br />
{/each} {/each}
</p> </p>
@@ -133,12 +193,15 @@
isFeedbackPromptVisible = true; isFeedbackPromptVisible = true;
}} }}
> >
Help categorise this space Update Tags
</button> </button>
<div class="actions"> <div class="actions">
{#if adminMode} {#if adminMode}
<Button href="/space/{space.id}/edit" type="link">Edit</Button> <div class="buttonContainer">
<Button href="/space/{space.id}/edit" type="link">Edit</Button>
<Button type="button" style="red" onclick={deleteSpace}>Delete</Button>
</div>
{:else} {:else}
<Button onclick={() => (isReportVisible = true)} style="red">Report</Button> <Button onclick={() => (isReportVisible = true)} style="red">Report</Button>
{/if} {/if}
@@ -167,21 +230,23 @@
background-color: #2e3c42; background-color: #2e3c42;
width: 70%; width: 70%;
border: none; border: none;
margin: 0 auto; margin: 1rem auto 0;
} }
.nameContainer { .nameContainer {
z-index: 10; display: flex;
display: block; align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%; width: 100%;
padding: 0.6rem; padding: 0.6rem;
margin-top: -0.5rem; margin-top: -0.5rem;
object-position: center; background-color: #189f5e;
background-color: #49bd85;
border-radius: 8px; border-radius: 8px;
font-size: 2.8rem; font-size: 2.8rem;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
z-index: 1;
} }
.descContainer { .descContainer {
@@ -246,7 +311,7 @@
padding: 0.7rem; padding: 0.7rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
background-color: #49bd85; background-color: #189f5e;
color: #ffffff; color: #ffffff;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
@@ -260,22 +325,48 @@
} }
.opening-entry { .opening-entry {
display: grid; display: flex;
grid-template-columns: auto 1fr;
gap: 0.75rem; gap: 0.75rem;
padding: 0.5rem 1.4rem; padding: 0.5rem 1.4rem;
align-items: center; align-items: center;
background-color: #2e4653;
margin: 0.2rem;
border-radius: 0.5rem;
} }
.opening-entry .day { .opening-entry .day {
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
white-space: nowrap; align-items: center;
justify-content: center;
} }
.opening-entry .times { .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; font-family: monospace;
background-color: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
color: #eaffeb; 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> </style>

View File

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

View File

@@ -6,13 +6,16 @@
import crossUrl from "$lib/assets/cross.svg"; import crossUrl from "$lib/assets/cross.svg";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import Images from "$lib/components/inputs/Images.svelte"; import Images from "$lib/components/inputs/Images.svelte";
import OpeningTimesDay from "$lib/components/inputs/OpeningTimesDay.svelte";
import { import {
availableStudySpaceTags, availableStudySpaceTags,
wifiTags, wifiTags,
powerOutletTags, powerOutletTags,
volumeTags, volumeTags,
gmapsLoader, gmapsLoader,
daysOfWeek daysOfWeek,
timeToMins,
collectTimings
} from "$lib"; } from "$lib";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Json } from "$lib/database.js"; import type { Json } from "$lib/database.js";
@@ -21,23 +24,22 @@
const { supabase } = $derived(data); const { supabase } = $derived(data);
const { space, images } = $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({ const studySpaceData = $state({
opening_times: daysOfWeek.map((_, index) => ({ opening_times: [] as OpeningTime[],
day_of_week: index,
opens_at: "",
closes_at: "",
is_24_7: false
})),
...space ...space
}); });
$effect(() => { $effect(() => {
if (!space) return; if (!space) return;
const { opening_times, ...rest } = space; Object.assign(studySpaceData, space);
Object.assign(studySpaceData, rest); studySpaceData.opening_times = space.opening_times ?? [];
if (opening_times) {
studySpaceData.opening_times = opening_times;
}
}); });
let scrollPosition = $state(0); let scrollPosition = $state(0);
@@ -57,12 +59,96 @@
); );
let spaceImgs = $state<FileList>(); let spaceImgs = $state<FileList>();
let uploading = $state(false); 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() { async function uploadStudySpace() {
if (!checkTimings()) return;
if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file."); if (!spaceImgs || spaceImgs.length < 1) return alert("Please select an image file.");
if (!studySpaceData.building_location) return alert("Please select a building location."); if (!studySpaceData.building_location) return alert("Please select a building location.");
const { opening_times, ...spacePayload } = studySpaceData; // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { opening_times: _, ...spacePayload } = studySpaceData;
const { data: studySpaceInsert, error: studySpaceError } = await supabase const { data: studySpaceInsert, error: studySpaceError } = await supabase
.from("study_spaces") .from("study_spaces")
@@ -126,17 +212,24 @@
.eq("study_space_id", studySpaceInsert.id); .eq("study_space_id", studySpaceInsert.id);
if (deleteErr) return alert(`Error clearing old hours: ${deleteErr.message}`); if (deleteErr) return alert(`Error clearing old hours: ${deleteErr.message}`);
const { error: hoursErr } = await supabase.from("study_space_hours").insert( // Nothing is provided
opening_times.map((h) => ({ if (
study_space_id: studySpaceInsert.id, (allDays.closes_at != "" && allDays.opens_at != "") ||
day_of_week: h.day_of_week, studySpaceData.opening_times.length === 7 ||
opens_at: h.opens_at, allDays.open_today_status != null
closes_at: h.closes_at, ) {
is_24_7: h.is_24_7 const { error: hoursErr } = await supabase
})) .from("study_space_hours")
); .insert(genTimings(studySpaceInsert.id));
if (hoursErr) return alert(`Error saving opening times: ${hoursErr.message}`); 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!"); alert("Thank you for your contribution!");
// Redirect to the new study space page // Redirect to the new study space page
await goto(`/space/${studySpaceInsert.id}`, { await goto(`/space/${studySpaceInsert.id}`, {
@@ -196,20 +289,12 @@
}); });
spaceImgs = dt.files; spaceImgs = dt.files;
}); });
// Opening times
// --- Helper functions for opening times --- let allDays = $state({
function toggle247(index: number) { opens_at: "",
const ot = studySpaceData.opening_times[index]; closes_at: "",
if (ot.is_24_7) { open_today_status: null
ot.opens_at = "00:00"; });
ot.closes_at = "00:00";
}
}
function updateTimes(index: number) {
const ot = studySpaceData.opening_times[index];
ot.is_24_7 = ot.opens_at === "00:00" && ot.closes_at === "00:00";
}
</script> </script>
<Navbar> <Navbar>
@@ -265,10 +350,18 @@
<Text <Text
name="location" name="location"
bind:value={studySpaceData.location} bind:value={studySpaceData.location}
placeholder="Room 123, Floor 1" placeholder="Huxeley Labs 225"
maxlength={35}
required 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="compulsoryTags">
<div class="compulsoryContainer"> <div class="compulsoryContainer">
<label for="volume">Sound level:</label> <label for="volume">Sound level:</label>
@@ -302,41 +395,43 @@
</select> </select>
</div> </div>
</div> </div>
<label for="openingTimes">Opening times (Optional):</label>
<label for="opening-times-label">Opening Times:</label> <div class="allDaysTiming">
<div class="opening-times"> {#each studySpaceData.opening_times as opening_time, index (opening_time)}
{#each daysOfWeek as day, index (index)} <OpeningTimesDay
<div class="opening-time-item"> {index}
<label for={"opens-" + index}>{day}</label> bind:openingValue={opening_time.opens_at}
<input bind:closingValue={opening_time.closes_at}
id={"opens-" + index} bind:openTodayStatus={opening_time.open_today_status}
type="time" bind:day={opening_time.day_of_week}
bind:value={studySpaceData.opening_times[index].opens_at} onHide={() => {
required studySpaceData.opening_times.splice(index, 1);
onchange={() => updateTimes(index)} }}
/> />
<span>to</span> <hr />
<input
id={"closes-" + index}
type="time"
bind:value={studySpaceData.opening_times[index].closes_at}
required
onchange={() => updateTimes(index)}
/>
<label for={"is247-" + index}>
<input
id={"is247-" + index}
type="checkbox"
bind:checked={studySpaceData.opening_times[index].is_24_7}
onchange={() => toggle247(index)}
/>
All day
</label>
</div>
{/each} {/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> </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:</label> <label for="tags">Additional tags (Optional):</label>
<div class="tagDisplay"> <div class="tagDisplay">
{#each studySpaceData.tags as tagName (tagName)} {#each studySpaceData.tags as tagName (tagName)}
<button class="tag" onclick={deleteTag(tagName)} type="button"> <button class="tag" onclick={deleteTag(tagName)} type="button">
@@ -385,7 +480,7 @@
{/if} {/if}
</div> </div>
<label for="description">Optional brief description:</label> <label for="description">Brief description (Optional):</label>
<Textarea <Textarea
name="description" name="description"
bind:value={studySpaceData.description} bind:value={studySpaceData.description}
@@ -393,6 +488,14 @@
rows={2} 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> <label for="building-location">Add the building location:</label>
<Text <Text
name="building-location" name="building-location"
@@ -557,7 +660,7 @@
.additionalImages { .additionalImages {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: linear-gradient(-83deg, #3fb095, #49bd85); background: linear-gradient(-83deg, #3fb095, #189f5e);
box-shadow: 0rem 0rem 0.5rem #182125; box-shadow: 0rem 0rem 0.5rem #182125;
color: #eaffeb; color: #eaffeb;
border: none; border: none;
@@ -572,34 +675,28 @@
} }
/* Opening times layout and inputs styling */ /* Opening times layout and inputs styling */
.opening-times { .allDaysTiming {
border-radius: 0.5rem;
background: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.opening-time-item { hr {
display: flex; margin: 0%;
align-items: center; padding: 0;
gap: 0.5rem; width: 100%;
height: 2px;
border: none;
background-color: #eaffeb;
border-radius: 5rem;
} }
.opening-time-item label { .lengthPopup {
margin-top: 0; background-color: #2e4653;
width: 6rem;
}
.opening-time-item input[type="time"] {
padding: 0.5rem;
height: 2.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 2px solid #eaffeb; padding: 0.5rem;
background: none;
color: #eaffeb;
}
.opening-time-item span {
margin: 0 0.5rem;
color: #eaffeb;
} }
</style> </style>

View File

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

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 building_address text;
ALTER TABLE study_spaces ADD COLUMN description text; ALTER TABLE study_spaces ADD COLUMN description text;
ALTER TABLE study_spaces ADD COLUMN location text; ALTER TABLE study_spaces ADD COLUMN location text;
ALTER TABLE study_spaces ADD COLUMN directions text;

View File

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

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

View File

@@ -2,7 +2,7 @@ CREATE TABLE users (
id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE, id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
is_admin boolean NOT NULL DEFAULT false, is_admin boolean NOT NULL DEFAULT false,
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now()
); );
CREATE TRIGGER users_handle_updated_at CREATE TRIGGER users_handle_updated_at
@@ -26,3 +26,16 @@ $$;
CREATE TRIGGER users_handle_new_user CREATE TRIGGER users_handle_new_user
AFTER INSERT ON auth.users AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user(); 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();