Compare commits

...

190 Commits

Author SHA1 Message Date
a6d910f56a sort feedback desc
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-29 00:19:24 +01:00
fde50d21a6 fuck that shit, revert
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m3s
2024-12-28 03:46:17 +01:00
8ea1750f1a fix scroll position resetting on admin panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m2s
2024-12-28 02:26:43 +01:00
5935b0d561 stick header and filter bar to top
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-28 01:29:05 +01:00
6e7c2eafca fix reported user not updating when changed
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-12-28 00:51:36 +01:00
3c8dc30e43 increase string size for some database fields
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-28 00:44:12 +01:00
8d8b1c52c0 make feedback title optional
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-12-27 19:00:22 +01:00
1596fb605e update report admin endpoint
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2024-12-24 01:18:36 +01:00
7357ad9e88 actually fix strike date not set if status changed but strike reason not
All checks were successful
delpoy / build-and-deploy (push) Successful in 39s
2024-12-20 21:07:36 +01:00
3dd56bc471 fix strike date not set if status changed but strike reason not
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2024-12-20 20:56:38 +01:00
8e60f83b6f fix crashing webhook sending 2024-12-20 20:46:48 +01:00
0280e2a277 fix report details not showing after report is submitted
All checks were successful
delpoy / build-and-deploy (push) Successful in 36s
2024-12-20 19:24:59 +01:00
60f031aa7b update input disabled style
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m5s
2024-12-20 19:10:52 +01:00
e7bba22784 update report submitted window (#42)
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-06 15:00:05 +01:00
a0cc11860f update search component
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-12-05 23:26:30 +01:00
ffc4dcf8f5 do not fail when webhook endpoint doesn't exist 2024-12-05 22:36:03 +01:00
ccdf9b9bed set height and width to 100% on index image
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-03 16:15:40 +01:00
48d3565008 replace png with webp where applicable
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-12-03 15:58:33 +01:00
1d8e99be8a lazy load background image
All checks were successful
delpoy / build-and-deploy (push) Successful in 34s
2024-12-03 14:43:37 +01:00
eb0d0f8db3 fix team page cards overflowing on small width screens
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-03 14:33:41 +01:00
8cb1e8bec5 add admin tools page
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2024-12-03 14:04:15 +01:00
9e282cf61b update texts
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-03 12:49:16 +01:00
4dc8d9b646 increase admin panel transition speed
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2024-12-03 03:19:54 +01:00
672379c27b update admin layout
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-03 03:12:17 +01:00
332089228e add confirmation popups to admin interface
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-02 21:27:13 +01:00
e30446598c use global modal for popup messages
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-02 21:02:23 +01:00
a1965c62e2 fix registration working when uuid cannot be fetched and user already exists
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-12-02 18:17:23 +01:00
4627d3de30 add team website link
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-02 17:29:33 +01:00
63cdf5c33d fix user search only working if table columns got sorted at least once
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-02 16:03:14 +01:00
7872744ab0 fix magnifying glass search not working
All checks were successful
delpoy / build-and-deploy (push) Successful in 36s
2024-12-02 00:35:43 +01:00
95968148a6 update to svelte 5
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-02 00:28:43 +01:00
abffa440a1 skip java uuid request/validation if api sends rate limit response
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-01 15:47:07 +01:00
5e07f4d545 rename REPORT_SECRET env variable to API_SECRET
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-01 14:48:43 +01:00
06751f0e27 fix report api uuid param
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2024-12-01 14:46:23 +01:00
b5e0dfad8c fix menu tooltip being always shown on top
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-12-01 13:30:16 +01:00
b823e198ab always show menu tooltips when on mobile
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-12-01 13:19:14 +01:00
5ff950bcc0 do not overflow report & feedback over footer
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-12-01 00:48:01 +01:00
f9de94db08 expand menu horizontal if screen height is small
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-11-30 16:48:48 +01:00
7ec1d8ab1d request full content when viewing feedback
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-11-30 16:32:39 +01:00
1ea07f7666 fix sql substr 2024-11-30 16:18:39 +01:00
1e7915837a fix content not showing in admin feedback panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-11-30 16:02:45 +01:00
55798fd294 add public feedback/contact option
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m5s
2024-11-30 03:00:46 +01:00
ceaf006dd5 fix rules typo
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-11-29 14:02:13 +01:00
aacd618d4f trigger pagination if table body is delayed populated
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2024-11-29 12:54:54 +01:00
c89cbdd389 fix pagination failing if data is loaded too slow into dom
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-11-29 12:37:13 +01:00
97f10da146 relocate new user/report button
All checks were successful
delpoy / build-and-deploy (push) Successful in 50s
2024-11-29 02:46:22 +01:00
dc3a404a5b add feedback endpoint (#28) and some other stuff
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m11s
2024-11-29 01:52:19 +01:00
dc86dceb2f redirect to root on unknown report hash 2024-11-29 00:00:50 +01:00
58d39921cb fix pagination missing last entry
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-11-28 20:15:51 +01:00
f74aa38bef fix pagination not showing on small screens
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-11-28 12:47:45 +01:00
0066736527 probably wasn't overfetching at all
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m24s
2024-11-28 02:42:07 +01:00
750d1b43d7 maybe fix overfetching now
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m23s
2024-11-28 02:31:20 +01:00
f461f6db77 maybe fix overfetching
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m35s
2024-11-28 02:23:15 +01:00
df91278db0 increase pagination prefetch elements
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m27s
2024-11-28 02:13:35 +01:00
bd33727aa6 update admin pagination
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m16s
2024-11-28 01:44:35 +01:00
676bfc23d8 remove unnecessary report api fields
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-11-27 22:30:03 +01:00
0be3c31b51 include reporter and reported in report api get
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-11-27 22:28:11 +01:00
b15b74c9b2 update after registration message
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2024-11-27 21:23:26 +01:00
7bb0bd07ac fix invalid faq paypal link
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2024-11-27 21:18:45 +01:00
a489b8cdd3 add api endpoint to get all reported related to a user (#27)
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-11-27 21:13:49 +01:00
6635591788 remove paypal link settings and show link when registered successfully
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m1s
2024-11-27 20:34:07 +01:00
45f8550604 remove menu scroll functionality
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-11-24 14:29:15 +01:00
865a8eee24 highlight current active page in navbar (#40) 2024-11-24 14:28:42 +01:00
23b4e6249f update faq
All checks were successful
delpoy / build-and-deploy (push) Successful in 53s
2024-11-02 19:22:17 +01:00
bacc39dea0 add keywords meta tag
All checks were successful
delpoy / build-and-deploy (push) Successful in 58s
2024-11-02 19:12:27 +01:00
e41c65e1fc add og meta tags 2024-11-02 19:09:50 +01:00
d1b7fa58b4 add faq discord link
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-10-30 15:45:05 +01:00
a671989f02 show register disabled text on top on small devices 2024-10-30 15:33:57 +01:00
204e2e915d add team page to navbar
All checks were successful
delpoy / build-and-deploy (push) Successful in 46s
2024-10-30 12:42:07 +01:00
17644b7952 rearrange faq sections 2024-10-30 12:42:00 +01:00
a9b7577ec8 fix faq
All checks were successful
delpoy / build-and-deploy (push) Successful in 46s
2024-10-30 03:05:15 +01:00
903cae13e2 make team responsive
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m16s
2024-10-30 02:36:22 +01:00
065395590a make faq responsive 2024-10-30 02:31:40 +01:00
0389ee4c3b use player heads as team member picture 2024-10-30 02:29:07 +01:00
937464d681 add correct header to team and faq pages
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m26s
2024-10-30 02:17:20 +01:00
f930deaba8 add faq
Some checks failed
delpoy / build-and-deploy (push) Has been cancelled
2024-10-30 02:15:59 +01:00
6c7442e33c add team page 2024-10-30 01:35:01 +01:00
3f3f691c52 update most mined block 2024-10-20 18:51:44 +02:00
7e08cd06fc update branding (#24)
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m0s
2024-10-20 18:02:05 +02:00
0bb02b4687 disable register rule timeout in dev mode 2024-10-19 20:53:40 +02:00
c6040f06dd update settings label text 2024-10-19 20:42:06 +02:00
b59354c2f9 fix login not working for non-env users
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2024-10-19 20:35:58 +02:00
c2c1660064 use zod schemes for validation
All checks were successful
delpoy / build-and-deploy (push) Successful in 53s
2024-10-19 18:07:11 +02:00
ac38540424 update statistics (#23)
Some checks are pending
delpoy / build-and-deploy (push) Waiting to run
2024-10-19 17:00:39 +02:00
11db3a16ab change register button text if registration is closed (#31) 2024-10-19 16:17:10 +02:00
89152bfaa9 add option to change title if registration is closed (#25)
All checks were successful
delpoy / build-and-deploy (push) Successful in 13m44s
2024-10-19 15:47:01 +02:00
0d5e68689c target es2018 2024-10-19 15:46:13 +02:00
414247a891 set minimum of 2 chars for first- and lastname (#29)
All checks were successful
delpoy / build-and-deploy (push) Successful in 3m13s
2024-10-19 15:28:30 +02:00
aa91eaf82a use zod for register verification 2024-10-19 15:27:29 +02:00
18135a0816 format
All checks were successful
delpoy / build-and-deploy (push) Successful in 5m13s
2024-10-19 13:53:57 +02:00
dbe9810b90 fix menu underflow (#30) 2024-10-19 13:53:41 +02:00
4375796679 update dependencies 2024-10-19 13:45:35 +02:00
231b75c47e make bullet points on rule page collapsable (#26) 2024-09-10 16:11:20 +02:00
4206797862 replace static roboto font with fontsource and add nunito font 2024-09-10 16:05:13 +02:00
f6f613b008 update dependencies 2024-09-10 15:56:44 +02:00
977905c390 order reports descending
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2024-01-11 00:01:00 +01:00
6d9f3c41aa add report pagination
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2024-01-10 23:51:52 +01:00
f74f1fe19e hide countdown if in the past
All checks were successful
delpoy / build-and-deploy (push) Successful in 44s
2024-01-02 23:10:26 +01:00
3310a82c29 fix report status not updating when no strike reason is set
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2023-12-29 00:46:38 +01:00
e991da4db3 allow any input in report
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2023-12-27 19:31:57 +01:00
27c525d5bd use hard location assignment after successful login
All checks were successful
delpoy / build-and-deploy (push) Successful in 42s
2023-12-26 20:00:16 +01:00
b2d18b81a8 add missing page titles 2023-12-26 19:58:24 +01:00
c98905e285 change moderator to admin in text
All checks were successful
delpoy / build-and-deploy (push) Successful in 42s
2023-12-26 19:34:40 +01:00
9f5fe25653 add ability to set reported user after report creation (#17)
All checks were successful
delpoy / build-and-deploy (push) Successful in 53s
2023-12-25 16:01:27 +01:00
3762872e01 javascript moment
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2023-12-24 01:17:06 +01:00
29d9765a81 fix webhook only working when reported user is changed
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2023-12-24 00:58:38 +01:00
8910a98489 return 404 on user api if uuid does not exist
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m31s
2023-12-23 23:04:57 +01:00
fe6fadee39 fix user info
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m34s
2023-12-23 21:24:53 +01:00
366913c5b3 fix wrong striked_at date when querying
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m32s
2023-12-23 14:30:12 +01:00
8ccff82fd3 fix uncaught null return on user info database query
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m28s
2023-12-23 14:15:29 +01:00
b92e494ecb add option to add user manually (#22)
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m25s
2023-12-23 00:41:49 +01:00
7878fef301 log on failed login attempt 2023-12-22 23:28:29 +01:00
ca16ce0603 add webhook endpoint on report change (#21)
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m16s
2023-12-21 15:49:39 +01:00
2a9869ca7d change strike layout and add outlawed option
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m21s
2023-12-21 15:02:29 +01:00
8a5eed787a remove created_at and update_at database columns on strike reason and punishment tables
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m1s
2023-12-10 19:31:04 +01:00
9352083884 return if user is banned in api response
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m7s
2023-12-10 19:27:11 +01:00
58bc475aec add strike system (#18)
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m25s
2023-12-10 19:18:33 +01:00
7599f233a8 use uuid instead of playername to query player
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m0s
2023-12-05 12:17:00 +01:00
6519a4071a copy report api to separate api route
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2023-12-04 21:07:23 +01:00
1561681171 add api route to get user by name (#2) 2023-12-04 21:07:20 +01:00
561e6683dd fix user bedrock filter value
All checks were successful
delpoy / build-and-deploy (push) Successful in 44s
2023-12-04 18:54:29 +01:00
9ebea2a1e8 increase pagination size
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2023-12-04 18:50:57 +01:00
d089ba36fe remove old sentence from long rules
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2023-12-02 01:45:22 +01:00
372e91121d consider pagination page when showing user index 2023-12-02 01:43:36 +01:00
74c842272d fix height on admin page users and reports
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2023-12-01 14:20:45 +01:00
9af8a50706 fix register rule close button position 2023-12-01 14:13:38 +01:00
2538285632 fix admin login and root page height 2023-12-01 14:10:38 +01:00
7400b41670 fix footer on mobile devices
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2023-12-01 12:00:57 +01:00
fc6fc097e9 lint
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2023-11-30 23:25:04 +01:00
b932d88990 add information and verbose back button when registered successfully (#7) 2023-11-30 23:20:50 +01:00
a024dfb626 add platform notice when selection edition on register page (#14)
All checks were successful
delpoy / build-and-deploy (push) Successful in 44s
2023-11-30 21:57:46 +01:00
dd19ff8c15 add footer (#13)
All checks were successful
delpoy / build-and-deploy (push) Successful in 50s
2023-11-30 21:56:08 +01:00
f6f9fafc64 change register error messages (#8)
All checks were successful
delpoy / build-and-deploy (push) Successful in 46s
2023-11-30 21:31:18 +01:00
d5ad9a7890 fix unwanted phone number field clearing
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2023-11-30 21:16:38 +01:00
5a31406a1c show modal on rules popup when clicking on button in the first 30 seconds
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2023-11-30 21:01:20 +01:00
9725cac44b fix mobile navigation (#12)
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2023-11-30 20:33:28 +01:00
26aaf8c677 fix min birthday date and error message (#9)
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2023-11-30 20:27:56 +01:00
971ca4bc75 allow only number in phone number input (#10)
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2023-11-30 20:20:24 +01:00
4e16487d3d add min age notice (#9)
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2023-11-30 19:41:44 +01:00
09da379812 fix log notice (#6)
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2023-11-30 19:19:20 +01:00
235dfe3094 add admin settings
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2023-11-30 19:15:00 +01:00
44454f445f show exact start time when hovering over countdown
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2023-11-30 15:02:17 +01:00
cf90924672 add skin on registration complete page
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2023-11-30 14:49:35 +01:00
63605e23b1 deactivate bedrock uuid resolver
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2023-11-30 12:54:25 +01:00
1f150bae06 do not throw 500 on mojang server error 2023-11-30 00:40:14 +01:00
b75620c892 always use white text on countdown 2023-11-30 00:31:19 +01:00
f53dc28597 fix spelling mistake
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2023-11-30 00:25:52 +01:00
974757511d add total playtime stat 2023-11-30 00:22:48 +01:00
3f6913ef5f remove unnecessary logic 2023-11-30 00:15:06 +01:00
5aa429d7eb fix rules redstone title 2023-11-30 00:04:40 +01:00
5fd7f715e7 show rules modal when clicking on rules link on register page
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2023-11-29 11:18:11 +01:00
18d45b1a81 fix root page aligning and text color 2023-11-29 11:14:35 +01:00
c6a9eaa27a add check for existing uuid or exiting firstname, lastname + birthday (#4)
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2023-11-29 02:00:13 +01:00
0ec9751f41 make user uuid unique 2023-11-29 01:30:31 +01:00
9cd78231c3 add rules read timeout of 30 seconds
All checks were successful
delpoy / build-and-deploy (push) Successful in 42s
2023-11-29 01:25:50 +01:00
47867738f8 update rules 2023-11-29 00:23:21 +01:00
a872613f1e add more stats 2023-11-29 00:15:25 +01:00
38906df545 update 2022 stats 2023-11-28 22:16:43 +01:00
05ddd05a5b update dependencies 2023-11-28 22:15:12 +01:00
dc21366f7a rework index page
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2023-11-28 21:52:09 +01:00
245d980b9a add report read & write permissions
All checks were successful
delpoy / build-and-deploy (push) Successful in 36s
2023-11-19 15:26:51 +01:00
b862b7c24d fix POJO required error
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2023-11-19 15:18:46 +01:00
5442d0b745 fix database report status enum type
All checks were successful
delpoy / build-and-deploy (push) Successful in 44s
2023-11-19 14:38:00 +01:00
9af519d72f set uuid column datatype to uuid instead of uuidv4
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2023-11-13 11:53:17 +01:00
4a4135c31e rename cracked to noauth and remove the possibility to self register as such 2023-11-13 11:52:45 +01:00
241d6c031e add register background clip path 2023-11-07 22:22:12 +01:00
c7a17d4481 fix register page env variable loading
All checks were successful
delpoy / build-and-deploy (push) Successful in 42s
2023-11-05 18:35:55 +01:00
73506fd81d allow only valid reported uuids or null
Some checks failed
delpoy / build-and-deploy (push) Failing after 28s
2023-11-05 18:19:28 +01:00
9dc8c59271 add register background image
Some checks failed
delpoy / build-and-deploy (push) Failing after 30s
2023-11-03 23:12:01 +01:00
475ccc8c99 remove settings
All checks were successful
delpoy / build-and-deploy (push) Successful in 39s
2023-11-03 20:41:08 +01:00
9562cdeb95 add simple dashboard on root admin route
All checks were successful
delpoy / build-and-deploy (push) Successful in 2m9s
2023-11-03 19:59:08 +01:00
981e1c3f9b replace sidebar with navbar on admin page
All checks were successful
delpoy / build-and-deploy (push) Successful in 32s
2023-11-03 19:43:51 +01:00
81d97380ca make reported user nullable
All checks were successful
delpoy / build-and-deploy (push) Successful in 44s
2023-11-03 18:10:02 +01:00
72eeb59230 fix report endpoint response url
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2023-10-24 22:52:46 +02:00
4aaf63c63f fix disabled register send button on initial rule acceptance (#1)
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2023-10-06 13:23:15 +02:00
1f8cf66e90 add report selection via url hash
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2023-10-05 13:07:30 +02:00
444631f649 fix error when sorting users after edition
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2023-10-04 14:32:31 +02:00
85597585da make error toast global in admin routes 2023-10-04 14:28:35 +02:00
8a80b0a9e0 add custom invalid message on admin report create popup user input and fix search component validity check
All checks were successful
delpoy / build-and-deploy (push) Successful in 37s
2023-09-30 17:41:29 +02:00
74e56d0ec8 add report link copy dropdown 2023-09-30 17:29:14 +02:00
6eb44cc33b fix pagination on user search 2023-09-30 16:15:22 +02:00
56aa3c2673 add option to filter reporter or reported by clicking on the magnifying glass next to their names on admin report page
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2023-09-30 15:39:44 +02:00
9be57c1004 add generic components for table sorting 2023-09-30 15:15:48 +02:00
9ababd4847 remove table resizing and fix admin panel user scroll behavior
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2023-09-30 02:13:09 +02:00
b1f546ee94 add user search in admin panel and remove user skeleton while loading data
All checks were successful
delpoy / build-and-deploy (push) Successful in 31s
2023-09-30 01:42:59 +02:00
b7177708a7 add optional env variable to protect the public report creation endpoint with a secret
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2023-09-30 01:10:50 +02:00
3713c7eaba add option to create a report via the admin panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2023-09-30 01:01:26 +02:00
61ea07d371 make report body actually editable on user report page 2023-09-29 19:40:45 +02:00
722026c938 add report admin panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 53s
2023-09-29 02:10:57 +02:00
37c230575d add report page 2023-09-28 01:12:06 +02:00
140 changed files with 9098 additions and 3208 deletions

View File

@ -3,3 +3,10 @@ ADMIN_USER=admin
ADMIN_PASSWORD=admin
PUBLIC_START_DATE=2023-12-26T00:00:00+0200
PUBLIC_BASE_PATH=
API_SECRET=
PUBLIC_SERVER_IP=example.com
PUBLIC_TS_LINK=ts3server://example.com
PUBLIC_DISCORD_LINK=https://example.com
PUBLIC_PAYPAL_LINK=https://example.com

View File

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,30 +0,0 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

View File

@ -4,6 +4,5 @@
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -29,12 +29,14 @@ $ node -r dotenv/config build/index.js
Configurations can be done with env variables
| Name | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HOST` | Host the server should listen on |
| `PORT` | Port the server should listen on |
| `DATABASE_URI` | URI to the database as a connection string. Supported databases are [sqlite](https://www.sqlite.org/index.html) and [mariadb](https://mariadb.org/) |
| `ADMIN_USER` | Name for the root admin user. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `ADMIN_PASSWORD` | Password for the root admin user defined via `ADMIN_USER`. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `PUBLIC_BASE_PATH` | If running the website on a sub-path, set this variable to the path so that assets etc. can find the correct location |
| `PUBLIC_START_DATE` | The start date when the event starts |
| Name | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HOST` | Host the server should listen on |
| `PORT` | Port the server should listen on |
| `DATABASE_URI` | URI to the database as a connection string. Supported databases are [sqlite](https://www.sqlite.org/index.html) and [mariadb](https://mariadb.org/) |
| `ADMIN_USER` | Name for the root admin user. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `ADMIN_PASSWORD` | Password for the root admin user defined via `ADMIN_USER`. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `REPORT_SECRET` | Secret which may be required (as `?secret=<secret>` query parameter) to create reports on the public endpoint. Isn't required to be in the request if this variable is empty |
| `REPORTED_WEBHOOK` | URL to send POST request to when a report got finished |
| `PUBLIC_BASE_PATH` | If running the website on a sub-path, set this variable to the path so that assets etc. can find the correct location |
| `PUBLIC_START_DATE` | The start date when the event starts |

38
eslint.config.mjs Normal file
View File

@ -0,0 +1,38 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off'
}
}
);

5857
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,45 +9,51 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"@types/bcrypt": "^5.0.0",
"@types/node": "^20.5.6",
"@types/validator": "^13.11.1",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"daisyui": "^3.6.3",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.27",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"sass": "^1.66.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"svelte-heros-v2": "^0.9.3",
"svelte-local-storage-store": "^0.6.0",
"@fontsource/nunito": "^5.1.0",
"@fontsource/roboto": "^5.1.0",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.10.1",
"@types/validator": "^13.12.2",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.14",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.13.0",
"postcss": "^8.4.49",
"prettier": "^3.4.1",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"publint": "^0.2.12",
"sass": "^1.81.0",
"skinview3d": "^3.1.0",
"svelte": "^5.3.0",
"svelte-check": "^4.1.0",
"svelte-heros-v2": "^2.0.1",
"svelte-multicssclass": "^2.1.1",
"svelte-preprocess": "^5.0.4",
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vitest": "^0.34.1"
"svelte-preprocess": "^6.0.3",
"tailwindcss": "^3.4.15",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "^6.0.1",
"vitest": "^2.1.6",
"zod": "^3.23.8"
},
"type": "module",
"dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1",
"mariadb": "^3.2.0",
"sequelize": "^6.32.1",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.6"
"dotenv": "^16.4.5",
"mariadb": "^3.3.2",
"sequelize": "^6.37.4",
"sequelize-typescript": "^2.1.6",
"sqlite3": "^5.1.7"
}
}

View File

@ -1,3 +1,6 @@
@import '@fontsource/nunito';
@import '@fontsource/roboto';
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -8,30 +11,6 @@
src: url('/fonts/MinecraftRegular.otf') format('opentype');
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
font-weight: normal;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Medium.ttf') format('truetype');
font-weight: 500;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Bold.ttf') format('truetype');
font-weight: bold;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Black.ttf') format('truetype');
font-weight: 900;
}
html {
@apply font-roboto scroll-smooth;
}

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@ -1,10 +1,16 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { env } from '$env/dynamic/public';
// start date in milliseconds. if undefined, start will be Date.now
export let start: number | undefined = undefined;
// end date in milliseconds
export let end: number;
let { start, end }: { start?: number; end: number } = $props();
let title = `Spielstart ist am ${new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} Uhr`;
function getUntil(): [number, number, number, number] {
let diff = (end - (start || Date.now())) / 1000;
@ -17,7 +23,7 @@
];
}
let [days, hours, minutes, seconds] = getUntil();
let [days, hours, minutes, seconds] = $state(getUntil());
let intervalId = setInterval(() => {
[days, hours, minutes, seconds] = getUntil();
if (start) start += 1000;
@ -26,28 +32,31 @@
onDestroy(() => clearInterval(intervalId));
</script>
<div class="grid grid-flow-col gap-5 text-center auto-cols-max">
<div class="flex flex-col">
<div
class:hidden={days + hours + minutes + seconds < 0}
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
>
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{days};" />
<span class="m-auto" style="--value:{days};"></span>
</span>
Tage
</div>
<div class="flex flex-col">
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{hours};" />
<span class="m-auto" style="--value:{hours};"></span>
</span>
Stunden
</div>
<div class="flex flex-col">
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{minutes};" />
<span class="m-auto" style="--value:{minutes};"></span>
</span>
Minuten
</div>
<div class="flex flex-col">
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{seconds};" />
<span class="m-auto" style="--value:{seconds};"></span>
</span>
Sekunden
</div>

View File

@ -0,0 +1,9 @@
<script lang="ts">
let { size = '24', fill = 'currentColor' } = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"
><path
d="M256 0c17.7 0 32 14.3 32 32V42.4c93.7 13.9 167.7 88 181.6 181.6H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H469.6c-13.9 93.7-88 167.7-181.6 181.6V480c0 17.7-14.3 32-32 32s-32-14.3-32-32V469.6C130.3 455.7 56.3 381.7 42.4 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H42.4C56.3 130.3 130.3 56.3 224 42.4V32c0-17.7 14.3-32 32-32zM107.4 288c12.5 58.3 58.4 104.1 116.6 116.6V384c0-17.7 14.3-32 32-32s32 14.3 32 32v20.6c58.3-12.5 104.1-58.4 116.6-116.6H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h20.6C392.1 165.7 346.3 119.9 288 107.4V128c0 17.7-14.3 32-32 32s-32-14.3-32-32V107.4C165.7 119.9 119.9 165.7 107.4 224H128c17.7 0 32 14.3 32 32s-14.3 32-32 32H107.4zM256 224a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
/></svg
>

View File

@ -0,0 +1,9 @@
<script lang="ts">
let { size = '24', fill = 'currentColor' } = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"
><path
d="M416 398.9c58.5-41.1 96-104.1 96-174.9C512 100.3 397.4 0 256 0S0 100.3 0 224c0 70.7 37.5 133.8 96 174.9c0 .4 0 .7 0 1.1v64c0 26.5 21.5 48 48 48h48V464c0-8.8 7.2-16 16-16s16 7.2 16 16v48h64V464c0-8.8 7.2-16 16-16s16 7.2 16 16v48h48c26.5 0 48-21.5 48-48V400c0-.4 0-.7 0-1.1zM96 256a64 64 0 1 1 128 0A64 64 0 1 1 96 256zm256-64a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"
/></svg
>

View File

@ -1,12 +1,17 @@
<script lang="ts">
// eslint-disable-next-line no-undef
type T = $$Generic;
export let id: string | null = null;
export let name: string | null = null;
export let disabled = false;
export let available: string[] | { [key: string]: T } = {};
export let value: T[] = [];
let {
id,
name,
disabled = false,
available = {},
value = $bindable([])
}: {
id?: string;
name?: string;
disabled?: boolean;
available?: string[] | { [key: string]: any };
value: any[];
} = $props();
</script>
<div class="flex items-center gap-4">
@ -15,7 +20,7 @@
{name}
class="select select-bordered select-xs"
disabled={disabled || available.length === 0}
on:change={(e) => {
onchange={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value.push(Object.values(available)[Object.keys(available).indexOf(e.target.value)]);
@ -42,7 +47,7 @@
<button
{disabled}
class:pointer-events-none={disabled}
on:click={() => {
onclick={() => {
value.splice(i, 1);
value = value;
}}></button

View File

@ -1,25 +1,46 @@
<script lang="ts">
import { IconSolid } from 'svelte-heros-v2';
import { createEventDispatcher } from 'svelte';
import { Eye, EyeSlash } from 'svelte-heros-v2';
import type { Snippet } from 'svelte';
export let id: string | null = null;
export let name: string | null = null;
export let type: string;
export let value: string | null = null;
export let placeholder: string | null = null;
export let required = false;
export let disabled = false;
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
export let inputElement: HTMLInputElement | undefined = undefined;
const dispatch = createEventDispatcher();
function input(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
dispatch('input', e);
}
function click(e: Event) {
dispatch('click', e);
}
let {
label,
notice,
id,
name,
type = 'text',
value = $bindable(),
placeholder,
pattern,
required = false,
disabled = false,
readonly = false,
checked = $bindable(false),
size = 'md',
pickyWidth = true,
containerClass = '',
inputElement = $bindable(),
oninput,
onclick
}: {
label?: Snippet;
notice?: Snippet;
id?: string;
name?: string;
type?: string;
value?: string;
placeholder?: string;
pattern?: RegExp;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
checked?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
pickyWidth?: boolean;
containerClass?: string;
inputElement?: HTMLInputElement;
oninput?: (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => void;
onclick?: (e: Event) => void;
} = $props();
let initialType = type;
@ -32,7 +53,7 @@
</script>
<!-- the cursor-not-allowed class must be set here because a disabled button does not respect the 'cursor' css property -->
<div class={type === 'submit' && disabled ? 'cursor-not-allowed' : ''}>
<div class={containerClass} class:cursor-not-allowed={type === 'submit' && disabled}>
{#if type === 'submit'}
<input
class="btn"
@ -45,22 +66,25 @@
{disabled}
bind:value
bind:this={inputElement}
on:input={input}
on:click={click}
{oninput}
{onclick}
/>
{:else}
<div>
{#if $$slots.label}
{#if label}
<label class="label" for={id}>
<span class="label-text">
<slot name="label" />
{@render label()}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</label>
{/if}
<div class="relative flex items-center" class:sm:max-w-[16rem]={type !== 'checkbox'}>
<div
class="relative flex items-center"
class:sm:max-w-[16rem]={type !== 'checkbox' && pickyWidth}
>
<input
class:checkbox={type === 'checkbox'}
class:checkbox-xs={type === 'checkbox' && size === 'xs'}
@ -74,50 +98,56 @@
class:input-lg={type !== 'checkbox' && size === 'lg'}
class:input-bordered={type !== 'checkbox'}
class:pr-11={initialType === 'password'}
class:!border-none,!text-inherit={disabled}
{id}
{name}
{type}
{value}
{checked}
{placeholder}
{required}
{disabled}
{readonly}
bind:this={inputElement}
autocomplete="off"
on:input={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value = e.target?.value;
input(e);
onchange={() => {
if (type === 'checkbox') {
checked = !checked;
}
}}
on:click={click}
oninput={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
value = e.currentTarget.value;
if (pattern && !pattern.test(value)) return;
oninput?.(e);
}}
onpaste={(e) => {
if (pattern && e.clipboardData && !pattern.test(e.clipboardData.getData('text'))) {
e.preventDefault();
}
}}
{onclick}
/>
{#if initialType === 'password'}
<button
class="absolute right-3"
type="button"
on:click={() => {
onclick={() => {
type = type === 'password' ? 'text' : 'password';
}}
>
{#if type === 'password'}
<IconSolid
name="eye-slash-solid"
width={passwordEyeSize[size]}
height={passwordEyeSize[size]}
/>
<EyeSlash variation="solid" size={passwordEyeSize[size]} />
{:else}
<IconSolid
name="eye-solid"
width={passwordEyeSize[size]}
height={passwordEyeSize[size]}
/>
<Eye variation="solid" size={passwordEyeSize[size]} />
{/if}
</button>
{/if}
</div>
{#if $$slots.notice}
{#if notice}
<label class="label" for={id}>
<span class="label-text-alt"><slot name="notice" /></span>
<span class="label-text-alt">
{@render notice()}
</span>
</label>
{/if}
</div>

View File

@ -0,0 +1,105 @@
<script lang="ts">
let {
id,
value = $bindable(),
suggestionRequired = false,
emptyAllowed = false,
searchSuggestionFunc = () => Promise.resolve([]),
invalidMessage,
size = 'md',
label,
required = false,
onsubmit
}: {
id?: string;
value: string;
suggestionRequired?: boolean;
emptyAllowed?: boolean;
searchSuggestionFunc?: (input: string) => Promise<{ name: string; value: string }[]>;
invalidMessage?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
label?: string;
required?: boolean;
onsubmit?: (event: Event & { input: string; value: string }) => void;
} = $props();
let elemValue = $state(value);
let searchSuggestions: { name: string; value: string }[] = $state([]);
</script>
<div class="relative">
<div>
{#if label}
<label class="label" for={id}>
<span class="label-text">
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</label>
{/if}
<input
type="search"
autocomplete="off"
class="input input-bordered w-full"
class:input-xs={size === 'xs'}
class:input-sm={size === 'sm'}
class:input-md={size === 'md'}
class:input-lg={size === 'lg'}
{id}
{required}
bind:value={elemValue}
oninput={async (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
searchSuggestions = await searchSuggestionFunc(elemValue);
const searchSuggestion = searchSuggestions.find((v) => v.name === elemValue);
if (searchSuggestion !== undefined) {
elemValue = searchSuggestion.name;
value = searchSuggestion.value;
searchSuggestions = [];
(e.currentTarget || e.target).setCustomValidity('');
onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
} else if (elemValue === '' && emptyAllowed) {
onsubmit?.(Object.assign(e, { input: '', value: '' }));
} else {
value = '';
}
}}
oninvalid={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
if (invalidMessage) e.currentTarget.setCustomValidity(invalidMessage);
}}
onfocus={() => searchSuggestionFunc(elemValue).then((v) => (searchSuggestions = v))}
pattern={suggestionRequired
? `${value ? elemValue : 'a^' + (emptyAllowed ? '|$^' : '')}`
: null}
/>
</div>
{#if elemValue && searchSuggestions.length !== 0}
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
{#each searchSuggestions as searchSuggestion}
<li class="w-full text-left">
<button
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title="{searchSuggestion.name} ({searchSuggestion.value})"
onclick={(e) => {
elemValue = searchSuggestion.name;
value = searchSuggestion.value;
searchSuggestions = [];
onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
}}>{searchSuggestion.name}</button
>
</li>
{/each}
</ul>
{/if}
</div>
<!-- close the search suggestions box when clicking outside -->
{#if elemValue && searchSuggestions.length !== 0}
<button
aria-label=" "
class="absolute top-0 left-0 z-10 w-full h-full cursor-default"
onclick={() => (searchSuggestions = [])}
></button>
{/if}

View File

@ -1,12 +1,31 @@
<script lang="ts">
export let id: string;
export let name: string | null = null;
export let value: string | null = null;
export let label: string | null = null;
export let notice: string | null = null;
export let required = false;
export let disabled = false;
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
import type { Snippet } from 'svelte';
let {
children,
id,
name,
value = $bindable(),
label,
notice,
required = false,
disabled = false,
size = 'md',
pickyWidth = true,
onChange
}: {
children: Snippet;
id?: string;
name?: string;
value?: any;
label?: string;
notice?: string;
required?: boolean;
disabled?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
pickyWidth?: boolean;
onChange?: ({ value }: { value: any }) => void;
} = $props();
</script>
<div>
@ -21,18 +40,21 @@
</label>
{/if}
<select
class="select select-bordered w-[100%] sm:max-w-[16rem]"
class="select select-bordered w-[100%]"
class:sm:max-w-[16rem]={pickyWidth}
class:select-xs={size === 'xs'}
class:select-sm={size === 'sm'}
class:select-md={size === 'md'}
class:select-lg={size === 'lg'}
class:!border-none,!text-inherit={disabled}
{id}
{name}
{required}
{disabled}
bind:value
onchange={() => onChange && onChange({ value: value })}
>
<slot />
{@render children()}
</select>
{#if notice}
<label class="label" for={id}>

View File

@ -0,0 +1,58 @@
<script lang="ts">
let {
id,
name,
value = $bindable(),
label,
notice,
required,
disabled,
readonly,
size = 'md',
rows = 2
}: {
id?: string;
name?: string;
value?: string;
label?: string;
notice?: string;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
rows?: number;
} = $props();
</script>
<div>
{#if label}
<label class="label" for={id}>
<span class="label-text">
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</label>
{/if}
<textarea
class="textarea textarea-bordered w-full"
class:textarea-xs={size === 'xs'}
class:textarea-sm={size === 'sm'}
class:textarea-md={size === 'md'}
class:textarea-lg={size === 'lg'}
class:!border-none,!text-inherit={disabled}
{id}
{name}
{required}
{disabled}
{readonly}
{rows}
bind:value
></textarea>
{#if notice}
<label class="label" for={id}>
<span class="label-text-alt">{notice}</span>
</label>
{/if}
</div>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import { onMount, type Snippet, tick } from 'svelte';
let { children, onUpdate }: { children: Snippet; onUpdate: () => Promise<any> } = $props();
let bodyElem: HTMLTableSectionElement;
let intersectionElem: HTMLElement;
async function getIntersectionElement(): Promise<HTMLElement> {
if (!bodyElem.lastElementChild) {
await new Promise<void>((resolve) => {
new MutationObserver((_, observer) => {
if (!bodyElem.lastElementChild) return;
observer.disconnect();
resolve();
});
});
}
return bodyElem.rows.item(bodyElem.rows.length - 15)! || bodyElem.lastElementChild!;
}
onMount(async () => {
await onUpdate();
await tick();
if (!bodyElem) return;
const intersectionObserver = new IntersectionObserver(
async (entries, observer) => {
if (entries.filter((e) => e.isIntersecting).length === 0 || !entries) return;
observer.unobserve(intersectionElem);
const rows = bodyElem.rows.length;
await onUpdate();
await tick();
if (rows === bodyElem.rows.length) return;
observer.observe((intersectionElem = await getIntersectionElement()));
},
{ threshold: 0.25 }
);
new MutationObserver(async (entries) => {
if (!entries) {
return;
} else if (
entries.findIndex((e) => e.addedNodes.length > 0 || e.removedNodes.length > 0) == -1
) {
return;
}
if (intersectionElem) intersectionObserver.unobserve(intersectionElem);
intersectionObserver.observe((intersectionElem = await getIntersectionElement()));
}).observe(bodyElem, { childList: true });
intersectionObserver.observe((intersectionElem = await getIntersectionElement()));
});
</script>
<tbody bind:this={bodyElem}>
{@render children()}
</tbody>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { getContext, onDestroy, type Snippet } from 'svelte';
import type { Writable } from 'svelte/store';
import { ChevronDown, ChevronUp } from 'svelte-heros-v2';
let { children, onSort }: { children: Snippet; onSort: ({ asc }: { asc: boolean }) => void } =
$props();
let id = crypto.randomUUID();
let asc = $state(false);
let { ascHeader } = getContext('sortableTr') as { ascHeader: Writable<null | string> };
ascHeader.subscribe((v) => {
if (v !== id) asc = false;
});
onDestroy(() => {
if ($ascHeader === id) $ascHeader = null;
});
</script>
<th>
<button
class="flex flex-center"
onclick={() => {
onSort({ asc: (asc = !asc) });
$ascHeader = id;
}}
>
<span class="mr-1">
{@render children()}
</span>
{#if $ascHeader === id && asc}
<ChevronUp variation="solid" />
{:else}
<ChevronDown variation="solid" />
{/if}
</button>
</th>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { writable } from 'svelte/store';
let { children, ...restProps }: { children: Snippet; [x: string]: unknown } = $props();
setContext('sortableTr', {
ascHeader: writable(null)
});
</script>
<tr {...restProps}>
{@render children()}
</tr>

View File

@ -1,17 +1,12 @@
<script lang="ts">
import { IconOutline } from 'svelte-heros-v2';
import { ExclamationCircle } from 'svelte-heros-v2';
import { fly } from 'svelte/transition';
import { onDestroy } from 'svelte';
export let timeout = 2000;
export let show = false;
let { children, timeout = 2000, show = false } = $props();
export function reset() {
progressValue = 1;
}
let progressValue = 100;
let intervalClear: ReturnType<typeof setInterval> | undefined;
let progressValue = $state(100);
let intervalClear: ReturnType<typeof setInterval> | undefined = $state();
function startTimout() {
intervalClear = setInterval(() => {
@ -23,10 +18,11 @@
}, timeout / 100);
}
$: if (show) {
$effect(() => {
if (!show) return;
progressValue = 0;
startTimout();
}
});
onDestroy(() => clearInterval(intervalClear));
</script>
@ -36,23 +32,23 @@
in:fly={{ x: 0, duration: 200 }}
out:fly={{ x: 400, duration: 400 }}
class="toast"
on:mouseenter={() => {
onmouseenter={() => {
clearInterval(intervalClear);
progressValue = 1;
}}
on:mouseleave={startTimout}
onmouseleave={startTimout}
role="alert"
>
<div class="alert alert-error border-none relative text-gray-900 overflow-hidden">
<div class="flex gap-2 z-10">
<IconOutline name="exclamation-circle-outline" />
<slot />
<ExclamationCircle />
{@render children()}
</div>
<progress
class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]"
value={progressValue}
max="100"
/>
></progress>
</div>
</div>
{/if}

View File

@ -3,56 +3,3 @@ export async function buttonTriggeredRequest<T>(e: MouseEvent, promise: Promise<
await promise;
(e.target as HTMLButtonElement).disabled = false;
}
export function resizeTableColumn(event: MouseEvent, dragOffset: number) {
const element = event.target as HTMLTableCellElement;
const rect = element.getBoundingClientRect();
const posX = event.clientX - rect.left;
const offset = rect.width - event.clientX;
if (posX <= dragOffset || posX >= rect.width - dragOffset) {
// do not resize if resize request is on the table left or right
if (
(posX <= dragOffset && !element.previousElementSibling) ||
(posX >= rect.width - dragOffset && !element.nextElementSibling)
) {
return;
}
const table = element.parentElement!.parentElement!.parentElement as HTMLTableElement;
let resizeRow: HTMLTableRowElement;
if (table.tBodies[0].rows[0].hasAttribute('resize-row')) {
resizeRow = table.tBodies[0].rows[0];
} else {
resizeRow = table.tBodies[0].insertRow(0);
resizeRow.setAttribute('resize-row', '');
resizeRow.style.height = '0';
resizeRow.style.border = '0';
resizeRow.style.overflow = 'hidden';
for (let i = 0; i < table.rows[0].cells.length; i++) {
const cell = resizeRow.insertCell();
cell.style.padding = '0';
}
// insert an additional to keep the zebra in place pattern which might be applied
const zebraGhostRow = table.tBodies[0].insertRow(1);
zebraGhostRow.hidden = true;
}
const resizeElement =
resizeRow.cells[element.cellIndex - ((posX <= dragOffset) as unknown as number)];
// eslint-disable-next-line svelte/no-inner-declarations,no-inner-declarations
function resize(e: MouseEvent) {
document.body.style.cursor = 'col-resize';
resizeElement.style.width = `${offset + e.clientX}px`;
}
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', resize);
document.body.style.cursor = 'initial';
});
}
}

18
src/lib/context.ts Normal file
View File

@ -0,0 +1,18 @@
import { getContext } from 'svelte';
export type PopupModalContextArgs = {
title: string;
text?: string;
actions?: { text: string; action?: (modal: Event) => void }[];
onClose?: () => void;
};
export function getPopupModalShowFn(): ({
title,
text,
actions,
onClose
}: PopupModalContextArgs) => void {
const { set }: { set: ({ title, text, actions, onClose }: PopupModalContextArgs) => void } =
getContext('globalPopupModal');
return set;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,8 +1,10 @@
export class Permissions {
static readonly AdminRead = 2;
static readonly AdminWrite = 4;
static readonly UserRead = 8;
static readonly UserWrite = 16;
static readonly Admin = 2 << 0;
static readonly Users = 2 << 1;
static readonly Reports = 2 << 2;
static readonly Feedback = 2 << 3;
static readonly Settings = 2 << 4;
static readonly Tools = 2 << 5;
readonly value: number;
@ -26,24 +28,33 @@ export class Permissions {
static allPermissions(): number[] {
return [
Permissions.AdminRead,
Permissions.AdminWrite,
Permissions.UserRead,
Permissions.UserWrite
Permissions.Admin,
Permissions.Users,
Permissions.Reports,
Permissions.Feedback,
Permissions.Settings,
Permissions.Tools
];
}
adminRead(): boolean {
return (this.value & Permissions.AdminRead) != 0;
admin(): boolean {
return (this.value & Permissions.Admin) != 0;
}
adminWrite(): boolean {
return (this.value & Permissions.AdminWrite) != 0;
users(): boolean {
return (this.value & Permissions.Users) != 0;
}
userRead(): boolean {
return (this.value & Permissions.UserRead) != 0;
reports(): boolean {
return (this.value & Permissions.Reports) != 0;
}
userWrite(): boolean {
return (this.value & Permissions.UserWrite) != 0;
feedback(): boolean {
return (this.value & Permissions.Reports) != 0;
}
settings(): boolean {
return (this.value & Permissions.Reports) != 0;
}
tools(): boolean {
return (this.value & Permissions.Tools) != 0;
}
asArray(): number[] {

View File

@ -1,8 +1,112 @@
export const rules = {
export const rulesShort = {
header: `
Das Lesen der Regeln ist für alle Teilnehmer verpflichtend. Die Regeln sollen für einen reibungslosen und
strukturierte Ablauf des Projekts sorgen, weshalb das Lesen der Regeln ein essenzieller Bestandteil für das Gelingen
von CraftAttack 6 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
von CraftAttack 7 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
gehen sie nicht zu weit ins Detail und deuten teils nur umfangreiche Themengebiete an. Entscheidungen werden, wenn
von Spielern angeregt, dann durch die Administratoren getroffen, die sich an den Regeln orientieren.
`,
sections: [
{
title: 'Respektvoller Umgang',
content: `
Oberste Priorität hat der respektvolle und tolerante Umgang der Spieler untereinander. Der Spielspaß, der
offene Umgang miteinander und die Interaktion aller steht im Vordergrund, weshalb Drohungen, Belästigungen
oder sonstige gegenüber anderen Spielern respektlose Aktivitäten strengstens verboten sind und auch hart
geahndet werden.`
},
{
title: 'Einschränkungen von Minecraft-Namen, Skins, Chat-Nachrichten, Links, etc.',
content: `
Selbstverständlich sind sämtliche Inhalte (Minecraft-Namen, Skins, Chat-Nachrichten, Links, etc.) mit
sexistischen, diskriminierenden, rassistischen, pornographischen oder illegalen Inhalten nicht erlaubt.
Außerdem ist es nicht gestattet, den Chat mit Nachrichten jeglicher Art vollzuspammen. Des Weiteren sollte
der MC-Name des Spielers, der bei der Anmeldung angegeben wird, bis zum Ende des Projekts nicht geändert
werden. Das Nutzen bzw. Anmelden von Zweitaccounts ist nicht gestattet.
`
},
{
title: 'Clientmodifikationen',
content: `
Jegliche Clientmodifications, die deutliche Vorteile gegenüber anderen Spielern erbringen, sind nicht
gestattet.
`
},
{
title: 'Redstone-Bauten und überdimensionierte Villager-Baukomplexe',
content: `
Das Erbauen und Betreiben lag-erzeugender Maschinen, Farmen (Zero-Tick-Farmen etc.) oder andere Bauten, die
den Spielfluss stören könnten, ist verboten.
`
},
{
title: 'Verkauf von Items',
content: `
Das Verkaufen von Items ist allgemein jedem Spieler überall gestattet. Jedoch bietet es sich an und ist
wünschenswert, die Shops aller Spieler in einem Shoppingdistrict beim Spawn gemeinsam anzusiedeln, um die
Interaktion zu fördern. Ein angemessener Abstand der privaten Strukturen vom Shoppingdisrict ist
einzuhalten.
`
},
{
title: 'Abstecken von Gebieten und Grundstücken',
content: `
Das Abstecken bestimmter Gebiete ist grundsätzlich erlaubt, jedoch sind unangemessen große Grundstücke
untersagt. Das maximale Maß ist im Einzelfall zu entscheiden. Die Grenzen bereits abgesteckter Grundstücke
sind unveränderlich.
`
},
{
title: 'Verhalten gegenüber anderen Spielern',
content: `
Das Töten, und Beklauen von Spielern ist verboten. Ebenso ist es nicht erlaubt, andere Bauten zu zerstören
(Griefing). Ein gewisser Toleranzspielraum besteht, der im Einzelfall zu bewerten ist.
`
},
{
title: 'Rolle der Administratoren',
content: `
Allgemein liegt es in der Hand der Administratoren einzelne Situation zu bewerten, Strafen zu verhängen und
Entscheidungen zu treffen. Den Entscheidungen und Anweisungen der Administratoren ist stets Folge zu
leisten. Allgemein gilt immer der Grundsatz, dass ein Eingriff der Administratoren nur dann erfolgt, wenn
dies die Spieler auch fordern. Solange beide Parteien zufrieden sind und sich niemand beschwert, passiert
natürlich auch nichts.
`
},
{
title: 'Kontakt zum Administratoren-Team',
content: `
Jedem Teilnehmer ist es möglich sich an den Support/das Administratoren-Team zu wenden. Zu den
Administratoren gehören die Spieler, die auf dem Server mit einem Admin-Tag versehen sind. Zwei von diesen
sind außerdem Administrator der WhatsApp-Gruppe. Eine Kontaktaufnahme ist direkt auf dem Server im Chat
oder auf dem Teamspeak: „mhsl.eu“ möglich. Außerdem können sie über WhatsApp angeschrieben werden, wenn
sich z.B. gerade kein Administrator auf dem Server befindet oder bei anderen Rückfragen. Bei
Unzufriedenheit, Meldung eines Regelverstoßen, Anregungen oder Fragen steht das Administratoren-Team allen
Spielern jederzeit zu Verfügung.
`
},
{
title: 'Konfliktlösung und mögliche Konsequenzen',
content: `
Konflikte sollen grundlegend zuerst auf einer Ebene zwischen den Spielern geschlichtet werden, bevor ein
Administrator kontaktiert wird. Jeder Regelverstoß zieht unterschiedliche Folgen nach sich, die von
Ermahnungen, über Tagesbänne bis zum permanenten Bann führen können. Diese möglichen Konsequenzen sind von
allen Teilnehmern zu akzeptieren.
`
}
],
footer: `
Alle aufgeführten Regeln und die damit in Verbindung stehende Angaben erfolgen ohne Gewähr auf Vollständigkeit,
Richtigkeit und Aktualität. Das Durchsetzen der Regeln liegt im Ermessen der Administratoren, die vorher in
Absprache mit dem Geschädigten eine der Situation angemessene Maßnahmen getroffen haben.
`
};
export const rulesLong = {
header: `
Das Lesen der Regeln ist für alle Teilnehmer verpflichtend. Die Regeln sollen für einen reibungslosen und
strukturierte Ablauf des Projekts sorgen, weshalb das Lesen der Regeln ein essenzieller Bestandteil für das Gelingen
von CraftAttack 7 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
gehen sie nicht zu weit ins Detail und deuten teils nur umfangreiche Themengebiete an. Entscheidungen werden, wenn
von Spielern angeregt, dann durch die Administratoren getroffen, die sich an den Regeln orientieren.
`,
@ -39,7 +143,7 @@ export const rules = {
`
},
{
title: 'Redstone bauten und überdimensionierte Villager-Baukomplexe',
title: 'Redstone-Bauten und überdimensionierte Villager-Baukomplexe',
content: `
Das Erbauen und Betreiben lag-erzeugender Maschinen, Farmen (Zero-Tick-Farmen etc.) oder andere Bauten, die
den Spielfluss stören könnten, ist verboten. Im Zweifelsfall ist eine Anfrage bei den Administratoren
@ -96,11 +200,10 @@ export const rules = {
diese als Administrator fungieren. Im normalen Spielbetrieb sind sie normale Mitspieler ohne
spielentscheidende Sonderrechte. So ist es nicht ihre Aufgabe überall nach dem Rechten zu sehen, sondern
Ansprechpartner zu sein, um dann nach der Vorlegung eines Problems durch einen Geschädigten die
Administratorenrolle einzunehmen und dementsprechend zu handeln. In dem Feld ist einzutragen, wobei die
Regeln trotzdem bis zum Ende gelesen werden müssen. Allgemein gilt immer der Grundsatz, dass ein Eingriff
der Administratoren nur dann erfolgt, wenn dies die Spieler auch fordern. Solange beide Parteien zufrieden
sind, passiert natürlich auch nichts. Wenn also beispielsweise zwei Spieler ein bewusstes pvp-Duell
starten, zieht das logischer Weise keine Konsequenzen nach sich.
Administratorenrolle einzunehmen und dementsprechend zu handeln. Allgemein gilt immer der Grundsatz, dass
ein Eingriff der Administratoren nur dann erfolgt, wenn dies die Spieler auch fordern. Solange beide
Parteien zufrieden sind, passiert natürlich auch nichts. Wenn also beispielsweise zwei Spieler ein
bewusstes pvp-Duell starten, zieht das logischer Weise keine Konsequenzen nach sich.
`
},
{

View File

@ -5,15 +5,17 @@ import * as bcrypt from 'bcrypt';
import {
BeforeCreate,
BeforeUpdate,
BelongsTo,
Column,
ForeignKey,
Index,
Model,
Sequelize,
Table,
Unique
Table
} from 'sequelize-typescript';
import { Permissions } from '$lib/permissions';
@Table({ modelName: 'user' })
@Table({ modelName: 'user', underscored: true })
export class User extends Model {
@Column({ type: DataTypes.STRING, allowNull: false })
declare firstname: string;
@ -22,18 +24,113 @@ export class User extends Model {
@Column({ type: DataTypes.DATE, allowNull: false })
declare birthday: Date;
@Column({ type: DataTypes.STRING })
declare telephone: string;
declare telephone: string | null;
@Column({ type: DataTypes.STRING, allowNull: false })
declare username: string;
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'cracked'), allowNull: false })
declare playertype: 'java' | 'bedrock' | 'cracked';
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'noauth'), allowNull: false })
declare playertype: 'java' | 'bedrock' | 'noauth';
@Column({ type: DataTypes.STRING })
declare password: string;
@Column({ type: DataTypes.UUIDV4 })
declare uuid: string;
declare password: string | null;
@Column({ type: DataTypes.UUID, unique: true })
@Index
declare uuid: string | null;
}
@Table({ modelName: 'admin' })
@Table({ modelName: 'report', underscored: true })
export class Report extends Model {
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
@Index
declare url_hash: string;
@Column({ type: DataTypes.STRING, allowNull: false })
declare subject: string;
@Column({ type: DataTypes.TEXT })
declare body: string | null;
@Column({ type: DataTypes.BOOLEAN, allowNull: false })
declare draft: boolean;
@Column({ type: DataTypes.ENUM('none', 'review', 'reviewed'), allowNull: false })
declare status: 'none' | 'review' | 'reviewed';
@Column({ type: DataTypes.STRING })
declare notice: string | null;
@Column({ type: DataTypes.TEXT })
declare statement: string | null;
@Column({ type: DataTypes.DATE })
declare striked_at: Date | null;
@Column({ type: DataTypes.INTEGER, allowNull: false })
@ForeignKey(() => User)
declare reporter_id: number;
@Column({ type: DataTypes.INTEGER })
@ForeignKey(() => User)
declare reported_id: number | null;
@Column({ type: DataTypes.INTEGER })
@ForeignKey(() => Admin)
declare auditor_id: number | null;
@Column({ type: DataTypes.INTEGER })
@ForeignKey(() => StrikeReason)
declare strike_reason_id: number | null;
@BelongsTo(() => User, {
onDelete: 'CASCADE',
foreignKey: 'reporter_id'
})
declare reporter: User | null;
@BelongsTo(() => User, {
onDelete: 'CASCADE',
foreignKey: 'reported_id'
})
declare reported: User | null;
@BelongsTo(() => Admin, {
onDelete: 'CASCADE',
foreignKey: 'auditor_id'
})
declare auditor: Admin | null;
@BelongsTo(() => StrikeReason, {
onDelete: 'CASCADE',
foreignKey: 'strike_reason_id'
})
declare strike_reason: StrikeReason | null;
}
@Table({ modelName: 'strike_reason', underscored: true, createdAt: false, updatedAt: false })
export class StrikeReason extends Model {
@Column({ type: DataTypes.INTEGER, allowNull: false })
declare weight: number;
@Column({ type: DataTypes.STRING, allowNull: false })
declare name: string;
}
@Table({ modelName: 'strike_punishment', underscored: true, createdAt: false, updatedAt: false })
export class StrikePunishment extends Model {
@Column({ type: DataTypes.INTEGER, allowNull: false })
declare weight: number;
@Column({ type: DataTypes.ENUM('ban', 'outlawed'), allowNull: false })
declare type: 'ban' | 'outlawed';
@Column({ type: DataTypes.INTEGER, allowNull: false })
declare punishment_in_seconds: number;
}
@Table({ modelName: 'feedback', underscored: true })
export class Feedback extends Model {
@Column({ type: DataTypes.STRING, allowNull: false })
declare event: string;
@Column({ type: DataTypes.STRING })
declare title: string | null;
@Column({ type: DataTypes.TEXT })
declare content: string | null;
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
@Index
declare url_hash: string;
@Column({ type: DataTypes.INTEGER })
@ForeignKey(() => User)
declare user_id: number | null;
@BelongsTo(() => User, {
onDelete: 'CASCADE',
foreignKey: 'user_id'
})
declare user: User | null;
}
@Table({ modelName: 'admin', underscored: true })
export class Admin extends Model {
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
declare username: string;
@ -65,8 +162,23 @@ export class Admin extends Model {
}
}
@Table({ modelName: 'settings', underscored: true })
export class Settings extends Model {
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
declare key: string;
@Column({
type: DataTypes.STRING,
allowNull: false,
get(this: Settings): any {
const value = this.getDataValue('value');
return value != null ? JSON.parse(value) : null;
}
})
declare value: string;
}
export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, {
// only log sql queries in dev mode
logging: dev ? console.log : false,
models: [User, Admin]
models: [User, Report, StrikeReason, StrikePunishment, Feedback, Admin, Settings]
});

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { getBedrockUuid, getCrackedUuid, getJavaUuid } from '$lib/server/minecraft';
import { getBedrockUuid, getJavaUuid, getNoAuthUuid } from '$lib/server/minecraft';
describe('java username', () => {
test('is valid', async () => {
@ -19,9 +19,9 @@ describe('bedrock username', () => {
});
});
describe('cracked username', () => {
describe('noauth username', () => {
// every username can be converted to an uuid so every user id automatically valid
test('is valid', () => {
expect(getCrackedUuid('bytedream')).toBe('88de3863-bf47-30f9-a7f4-ab6134feb49a');
expect(getNoAuthUuid('bytedream')).toBe('88de3863-bf47-30f9-a7f4-ab6134feb49a');
});
});

View File

@ -1,17 +1,27 @@
import { createHash } from 'node:crypto';
export class UserNotFoundError extends Error {
readonly username: string;
constructor(username: string) {
super(`Ein Spieler mit dem Namen '${username}' konnte nicht gefunden werden`);
super(`couldn't find a player with the username '${username}'`);
this.username = username;
}
}
export class RateLimitError extends Error {}
export class ApiError extends Error {}
export async function getJavaUuid(username: string): Promise<string> {
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
if (!response.ok) {
if (response.status == 429) {
throw new RateLimitError();
}
throw response.status < 500
? new UserNotFoundError(username)
: new Error(`mojang server error (${response.status}): ${await response.text()}`);
: new ApiError(`mojang server error (${response.status}): ${await response.text()}`);
}
const json = await response.json();
const id: string = json['id'];
@ -24,12 +34,13 @@ export async function getBedrockUuid(username: string): Promise<string> {
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
const initialPageContent = await initialPageResponse.text();
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
if (token === undefined) throw new Error("couldn't grab token from xuid converter website");
if (token === undefined) throw new ApiError("couldn't grab token from xuid converter website");
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
if (cookies === undefined)
throw new Error("couldn't get response cookies from xuid converter website");
else if (cookies.length < 11) throw new Error('xuid converter website sent unexpected cookies');
throw new ApiError("couldn't get response cookies from xuid converter website");
else if (cookies.length < 11)
throw new ApiError('xuid converter website sent unexpected cookies');
const requestBody = new URLSearchParams();
requestBody.set('_token', token);
@ -55,7 +66,7 @@ export async function getBedrockUuid(username: string): Promise<string> {
'Sec-Fetch-User': '?1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3',
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
@ -68,7 +79,7 @@ export async function getBedrockUuid(username: string): Promise<string> {
}
// https://gist.github.com/yushijinhun/69f68397c5bb5bee76e80d192295f6e0
export function getCrackedUuid(username: string): string {
export function getNoAuthUuid(username: string): string {
const data = createHash('md5').update(`OfflinePlayer:${username}`).digest('binary').split('');
data[6] = String.fromCharCode((data[6].charCodeAt(0) & 0x0f) | 0x30);
data[8] = String.fromCharCode((data[8].charCodeAt(0) & 0x3f) | 0x80);

15
src/lib/server/webhook.ts Normal file
View File

@ -0,0 +1,15 @@
export async function webhookUserReported(endpoint: string, uuid: string) {
try {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
user: uuid
})
});
} catch (e) {
throw (e as { message: string }).message;
}
}

View File

@ -1,7 +1,17 @@
import { persisted } from 'svelte-local-storage-store';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export const playAudio: Writable<boolean> = persisted('playAudio', false);
export const errorMessage: Writable<string | null> = (() => {
const store: Writable<string | null> = writable(null);
return {
subscribe: store.subscribe,
set: (value) => {
if (value != null) store.set(null);
store.set(value);
},
update: store.update
};
})();
export const reportCount: Writable<number> = writable(0);
export const feedbackCount: Writable<number> = writable(0);
export const adminCount: Writable<number> = writable(0);

18
src/lib/utils.ts Normal file
View File

@ -0,0 +1,18 @@
import { env } from '$env/dynamic/public';
export async function usernameSuggestions(
username: string
): Promise<{ name: string; value: string }[]> {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'POST',
body: JSON.stringify({
limit: 6,
search: username,
slim: true
})
});
const json: { username: string; uuid: string }[] = await response.json();
return json.map((v) => {
return { name: v.username, value: v.uuid };
});
}

View File

@ -2,138 +2,139 @@
import '../app.css';
import { env } from '$env/dynamic/public';
import { goto } from '$app/navigation';
import Settings from './Settings.svelte';
import { playAudio } from '$lib/stores';
import { page } from '$app/stores';
import Input from '$lib/components/Input/Input.svelte';
import { setContext, tick } from 'svelte';
import type { PopupModalContextArgs } from '$lib/context';
let navPaths = [
let { children } = $props();
let navPaths = $state([
{
name: 'Startseite',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-home.png`,
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-home.webp`,
href: `${env.PUBLIC_BASE_PATH}/`,
active: false
},
{
name: 'Registrieren',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-register.png`,
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-register.webp`,
href: `${env.PUBLIC_BASE_PATH}/register`,
active: false
},
{
name: 'Regeln',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-rules.png`,
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-rules.webp`,
href: `${env.PUBLIC_BASE_PATH}/rules`,
active: false
},
{
name: 'FAQ',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-faq.webp`,
href: `${env.PUBLIC_BASE_PATH}/faq`,
active: false
},
{
name: 'Feedback & Kontakt',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-feedback.webp`,
href: `${env.PUBLIC_BASE_PATH}/feedback`,
active: false
},
{
name: 'Team',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-team.webp`,
href: `${env.PUBLIC_BASE_PATH}/team`,
active: false
}
];
]);
let showMenuPermanent = $state(false);
let onAdminPage = $state(false);
let isTouch = $state(false);
let windowHeight = $state(0);
let showMenuPermanent = false;
let menuButtonScrollIndex: number | null = null;
function onMenuButtonScroll(e: WheelEvent) {
if (menuButtonScrollIndex == null) {
if (e.deltaY < 0) {
menuButtonScrollIndex = navPaths.length - 1;
} else if (e.deltaY > 0) {
menuButtonScrollIndex = 0;
} else {
menuButtonScrollIndex = navPaths.length - 1;
}
} else {
navPaths[menuButtonScrollIndex].active = false;
if (e.deltaY > 0) {
menuButtonScrollIndex++;
} else if (e.deltaY < 0) {
menuButtonScrollIndex--;
}
if (menuButtonScrollIndex > navPaths.length - 1) {
menuButtonScrollIndex = 0;
} else if (menuButtonScrollIndex < 0) {
menuButtonScrollIndex = navPaths.length - 1;
}
$effect(() => {
onAdminPage =
$page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
$page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
});
$effect(() => {
for (let i = 0; i < navPaths.length; i++) {
navPaths[i].active = navPaths[i].href === $page.url.pathname;
}
});
navPaths[menuButtonScrollIndex].active = true;
}
let popupModalState: PopupModalContextArgs | null = $state(null);
// eslint-disable-next-line no-undef
let popupModalNullTimeout: number | NodeJS.Timeout | null = null;
setContext('globalPopupModal', {
set: async ({ title, text, actions, onClose }: PopupModalContextArgs) => {
if (popupModalNullTimeout) clearTimeout(popupModalNullTimeout);
popupModalState = { title, text, actions, onClose };
await tick();
popupModalElem.showModal();
}
});
let onAdminPage = false;
$: onAdminPage =
$page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
$page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
let isTouch = false;
let nav: HTMLDivElement;
let settings: HTMLDialogElement;
let navElem: HTMLDivElement;
let popupModalElem: HTMLDialogElement;
</script>
<svelte:window bind:innerHeight={windowHeight} />
<svelte:body
on:keyup={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
if (settings.open) {
settings.close();
} else {
settings.show();
}
}
}}
on:touchend={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (isTouch && !nav.contains(e.target)) showMenuPermanent = false;
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
}}
/>
<svelte:head>
{#if !onAdminPage}
<meta property="og:url" content={$page.url.toString()} />
<meta property="og:type" content="website" />
<meta property="og:image" content="{env.PUBLIC_BASE_PATH}/img/logo-512.webp" />
{/if}
</svelte:head>
<main>
<div class="h-screen w-full">
<slot />
<div class="min-h-[calc(100vh-3.5rem)] h-full w-full" class:min-h-screen={onAdminPage}>
{@render children()}
</div>
</main>
<nav>
<div
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center"
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50 main-menu"
class:hidden={onAdminPage}
bind:this={nav}
bind:this={navElem}
>
<button
class={isTouch
? 'btn btn-square relative w-16 h-16'
: 'btn btn-square group/menu-button relative w-16 h-16'}
on:click={() => {
onclick={() => {
if (!isTouch) {
let activePath = navPaths.find((path) => path.active);
if (activePath !== undefined) {
goto(activePath.href);
} else if ($playAudio) {
new Audio(
`${env.PUBLIC_BASE_PATH}/aud/chest-${showMenuPermanent ? 'close' : 'open'}.mp3`
).play();
}
showMenuPermanent = !showMenuPermanent;
}
}}
on:touchend={() => {
ontouchend={() => {
isTouch = true;
showMenuPermanent = !showMenuPermanent;
}}
on:mouseleave={() => {
if (menuButtonScrollIndex !== null) {
navPaths[menuButtonScrollIndex].active = false;
}
menuButtonScrollIndex = null;
}}
on:wheel|preventDefault={onMenuButtonScroll}
>
<img
class="absolute w-full h-full p-1 pixelated"
src="{env.PUBLIC_BASE_PATH}/img/menu-button.png"
src="{env.PUBLIC_BASE_PATH}/img/menu-button.webp"
alt="menu"
/>
<img
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated"
class:opacity-100={isTouch && showMenuPermanent}
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.png"
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.webp"
alt="menu hover"
/>
</button>
@ -141,28 +142,33 @@
class:hidden={!showMenuPermanent}
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
>
<ul class="flex flex-col bg-base-200 rounded">
<ul class="bg-base-200 rounded">
{#each navPaths as navPath, i}
<li
class="flex justify-center tooltip tooltip-left sm:tooltip-right"
class="flex justify-center tooltip"
class:tooltip-left={windowHeight > 450}
class:sm:tooltip-right={windowHeight > 450}
class:tooltip-top={windowHeight <= 450}
class:tooltip-open={isTouch || windowHeight <= 450}
data-tip={navPath.name}
>
<a
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
href={navPath.href}
onclick={() => goto(navPath.href)}
>
<div
style="background-image: url('{env.PUBLIC_BASE_PATH}/img/menu-inventory-bar.png'); background-position: -{i *
style="background-image: url('{env.PUBLIC_BASE_PATH}/img/menu-inventory-bar.webp'); background-position: -{i *
3.5}rem 0;"
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated"
/>
></div>
<div class="absolute flex justify-center items-center w-full h-full">
<img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
</div>
<img
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10"
class:opacity-0={!navPath.active}
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.png"
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.webp"
alt="menu hover"
/>
</a>
@ -172,13 +178,81 @@
</div>
</div>
</nav>
{#if !onAdminPage && $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/register`}
<footer>
<div
class="flex justify-around items-center h-14 w-full bg-base-300"
class:hidden={onAdminPage}
>
<div class="hidden sm:block">
<p>© {new Date().getFullYear()} mhsl.eu</p>
</div>
<div class="flex gap-4">
<a class="link" href="https://mhsl.eu/id.html" target="_blank">Impressum</a>
<a class="link" href="https://mhsl.eu/datenschutz.html" target="_blank">Datenschutz</a>
</div>
</div>
</footer>
{/if}
<dialog class="modal" bind:this={settings}>
<form method="dialog" class="modal-box" style="overflow: unset">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<Settings />
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
<dialog class="modal" bind:this={popupModalElem}>
{#if popupModalState}
<form
method="dialog"
class="modal-box z-50"
onsubmit={() => {
popupModalNullTimeout = setTimeout(() => {
popupModalState = null;
popupModalNullTimeout = null;
}, 200);
popupModalState?.onClose?.();
}}
>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 class="font-bold text-2xl">{popupModalState.title}</h3>
{#if popupModalState.text}
<p class="py-4 whitespace-pre-line">{popupModalState.text}</p>
{/if}
{#if popupModalState.actions}
<div class="flex flex-row space-x-1 mt-4">
{#each popupModalState.actions as action}
<Input type="submit" value={action.text} onclick={(e) => action.action?.(e)} />
{/each}
</div>
{/if}
</form>
<form
method="dialog"
class="modal-backdrop bg-[rgba(0,0,0,.2)]"
onsubmit={() => {
popupModalNullTimeout = setTimeout(() => {
popupModalState = null;
popupModalNullTimeout = null;
}, 200);
popupModalState?.onClose?.();
}}
>
<button>close</button>
</form>
{/if}
</dialog>
<style lang="scss">
@media (max-height: 450px) {
.main-menu {
flex-direction: row;
}
.main-menu > div {
padding: 0.25rem 0 0 0.5rem;
}
.main-menu li {
display: inline-block;
&::before {
transform-origin: 0;
transform: rotate(-90deg);
margin-bottom: -0.5rem;
}
}
}
</style>

View File

@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { Settings } from '$lib/server/database';
export const load: PageServerLoad = async () => {
return {
register_enabled:
(await Settings.findOne({ where: { key: 'register.enabled' } }))?.value ?? true
};
};

View File

@ -1,49 +1,151 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import Countdown from '$lib/components/Countdown/Countdown.svelte';
import { IconOutline } from 'svelte-heros-v2';
import { Clock, User, WrenchScrewdriver } from 'svelte-heros-v2';
import Crosshairs from '$lib/components/CustomIcons/Crosshairs.svelte';
import Skull from '$lib/components/CustomIcons/Skull.svelte';
let { data } = $props();
let information = [
{
title: 'Das Projekt',
description:
'CraftAttack ist ein Vanilla-Minecraft-Projekt, bei dem zahlreiche Spieler im friedlichen Miteinander spielen. Von gemeinsamen Bauvorhaben bis hin zum kollektiven Kampf gegen den Enderdrachen können die vielfältigen Aspekte von Minecraft erkundet werden.'
},
{
title: 'Events',
description:
'Abwechslungsreiche Events und verschiedene Minispiele sorgen dafür, dass es nie langweilig wird und garantieren somit jede Menge Spielspaß.'
},
{
title: 'Voraussetzungen',
description:
'Jeder ist willkommen und kann mitspielen. Dazu benötigst Du nur einen Minecraft-Account und schon bist Du Teil unser Community :)'
}
];
</script>
<svelte:head>
<title>Craftattack</title>
<meta property="og:title" content="Craftattack" />
<meta property="keywords" content="minecraft, craftattack, mhsl, minecraft craftattack" />
</svelte:head>
<div class="absolute top-0 left-0 h-screen w-full overflow-hidden">
<!-- svelte-ignore a11y-media-has-caption -->
<video
class="h-full w-full blur-sm object-cover"
autoplay
loop
src="{env.PUBLIC_BASE_PATH}/vid/background.mp4"
/>
<div class="absolute top-0 left-0 w-full h-full bg-black opacity-70" />
</div>
<div class="absolute top-0 w-full">
<div class="relative h-screen">
<div class="flex flex-col items-center w-full pt-36">
<img
class="w-11/12 sm:w-3/4 pointer-events-none"
src="{env.PUBLIC_BASE_PATH}/img/craftattack.webp"
alt="Craftattack 6"
width="1905"
height="188"
/>
<div class="mt-4 sm:mt-10 lg:mt-16">
<Countdown end={Date.parse(env.PUBLIC_START_DATE)} />
<div class="flex flex-col min-h-screen relative">
<div class="flex items-center xl:w-1/2 px-6 sm:px-10 min-h-screen h-full">
<div class="flex flex-col items-center xl:items-start w-full xl:h-3/4 my-10">
<img src="{env.PUBLIC_BASE_PATH}/img/craftattack.webp" alt="Craftattack 7" />
<div class="flex flex-col gap-5 lg:gap-14 w-full mt-2 lg:mt-5 lg:w-10/12 h-full">
<div>
<div class="divider"></div>
<div class="flex flex-col md:flex-row xl:flex-col gap-5">
{#each information as info}
<div>
<h4 class="text-black dark:text-white mb-1">{info.title}</h4>
<p>{info.description}</p>
</div>
{/each}
</div>
<div class="divider"></div>
</div>
<div class="flex justify-center">
<a
class="btn btn-outline btn-accent hover:bg-white"
href="{env.PUBLIC_BASE_PATH}/register"
>{data.register_enabled ? 'Jetzt registrieren' : 'Infos zur Anmeldung'}</a
>
</div>
</div>
</div>
<div class="absolute bottom-4 sm:bottom-6 lg:bottom-10 w-full flex justify-center">
<button
on:click={() => {
window.scrollTo(0, window.innerHeight);
}}
>
<div class="border-2 border-white rounded-full w-10 h-16 flex justify-center items-center">
<div class="animate-[pleaseINeedAttention_1.5s_ease-in-out_infinite]">
<IconOutline name="chevron-double-down-outline" width="34" height="34" />
</div>
<div
class="hidden xl:block absolute top-0 left-0 h-full w-full"
style="clip-path: polygon(60% 0, 100% 0, 100% 100%, 40% 100%);"
>
<img
src="{env.PUBLIC_BASE_PATH}/img/bg.webp"
alt=""
loading="lazy"
width="100%"
height="100%"
/>
</div>
<div class="hidden xl:flex justify-center absolute bottom-12 right-0 w-[60%]">
<Countdown end={Date.parse(env.PUBLIC_START_DATE)} />
</div>
</div>
<div class="flex justify-center py-20 bg-base-200">
<div class="card bg-base-100 shadow-lg w-11/12 xl:w-5/12 p-10">
<div>
<h2 class="text-3xl text-black dark:text-white mb-8">Über uns</h2>
<p>
Wir sind ein kleines <a class="link" href={`${env.PUBLIC_BASE_PATH}/team`}>Team</a> von Minecraft-Enthusiasten,
das bereits im 7. Jahr in Folge Minecraft CraftAttack organisiert. Jahr für Jahr arbeiten wir
daran, das Spielerlebnis zu verbessern und steigeren die Teilnehmerzahl.
</p>
<p>
Unser Ziel bei diesem ab dem <span class="italic"
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', {
day: '2-digit',
month: 'numeric',
year: 'numeric'
})}</span
>
stattfindenden Projekts ist es, sicherzustellen, dass alle Spieler eine großartige Erfahrung
haben und alles reibungslos abläuft. Wir freuen uns immer über Anregungen und stehen Dir jederzeit
zur Verfügung.
</p>
</div>
</div>
</div>
<div class="flex flex-col xl:flex-row justify-center items-center py-20 bg-base-100">
<div>
<h3 class="text-center text-2xl mb-6">2023/2024 in Zahlen</h3>
<div class="flex flex-col lg:flex-row gap-4">
<div class="stats stats-vertical xl:stats-horizontal shadow">
<div class="stat">
<div class="stat-figure">
<WrenchScrewdriver />
</div>
<div class="stat-title">Abgebaute Blöcke</div>
<div class="stat-value">35M</div>
<div class="stat-desc"><span class="underline">9.6M</span> davon Stein</div>
</div>
</button>
<div class="stat">
<div class="stat-figure">
<User />
</div>
<div class="stat-title">Teilnehmer</div>
<div class="stat-value">148</div>
</div>
</div>
<div class="stats stats-vertical xl:stats-horizontal shadow h-min xl:h-[initial]">
<div class="stat">
<div class="stat-figure">
<Clock />
</div>
<div class="stat-title">Gesamtspielzeit</div>
<div class="stat-value">246 Tage</div>
</div>
</div>
<div class="stats stats-vertical xl:stats-horizontal shadow">
<div class="stat">
<div class="stat-figure">
<Crosshairs />
</div>
<div class="stat-title">Getötete Mobs</div>
<div class="stat-value">1.8M</div>
</div>
<div class="stat">
<div class="stat-figure">
<Skull />
</div>
<div class="stat-title">Spieler Tode</div>
<div class="stat-value">3054</div>
<div class="stat-desc"><span class="underline">552</span> davon durch andere Spieler</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,42 +0,0 @@
<script lang="ts">
import { playAudio } from '$lib/stores';
import { get } from 'svelte/store';
import { IconOutline } from 'svelte-heros-v2';
let settings = [
{
name: 'Sound abspielen',
description:
'Manche Elemente können Sounds abspielen. Aktiviere diese Option und finde raus welche',
store: playAudio
}
];
</script>
<div>
<h2 class="text-2xl">Einstellungen</h2>
<div>
<div class="divider" />
<div class="grid grid-cols-2">
{#each settings as setting}
<div class="flex">
<p>{setting.name}</p>
<span class="tooltip ml-[0.2rem] -mt-[1px]" data-tip={setting.description}>
<IconOutline name="question-mark-circle-outline" width="16" height="16" />
</span>
</div>
<input
type="checkbox"
class="toggle justify-self-end mr-6"
checked={get(setting.store)}
on:change={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
setting.store.set(e.target.checked);
}}
/>
{/each}
</div>
<div class="divider" />
</div>
</div>

View File

@ -1,8 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { Admin, User } from '$lib/server/database';
import { Admin, Feedback, Report, User } from '$lib/server/database';
import { getSession } from '$lib/server/session';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { Op } from 'sequelize';
export const load: LayoutServerLoad = async ({ route, cookies }) => {
const session = getSession(cookies);
@ -11,7 +12,18 @@ export const load: LayoutServerLoad = async ({ route, cookies }) => {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin/login`);
return {
userCount: session?.permissions.userRead() ? await User.count() : null,
adminCount: session?.permissions.adminRead() ? await Admin.count() : null
userCount: session?.permissions.users() ? await User.count() : null,
reportCount: session?.permissions.reports()
? await Report.count({ where: { draft: false, status: ['none', 'review'] } })
: null,
feedbackCount: session?.permissions.feedback()
? await Feedback.count({ where: { content: { [Op.not]: null } } })
: null,
adminCount: session?.permissions.admin() ? await Admin.count() : null,
settingsRead: session?.permissions.settings(),
toolsRead: session?.permissions.tools(),
self: session
? JSON.parse(JSON.stringify(await Admin.findOne({ where: { id: session.userId } })))
: null
};
};

View File

@ -1,11 +1,24 @@
<script lang="ts">
import { page } from '$app/stores';
import { fly } from 'svelte/transition';
import { env } from '$env/dynamic/public';
import { IconOutline } from 'svelte-heros-v2';
import {
ArrowLeftOnRectangle,
AdjustmentsHorizontal,
Flag,
UserGroup,
Users,
BookOpen,
WrenchScrewdriver
} from 'svelte-heros-v2';
import { buttonTriggeredRequest } from '$lib/components/utils';
import { goto } from '$app/navigation';
import type { LayoutData } from './$types';
import { adminCount } from '$lib/stores';
import { adminCount, errorMessage, reportCount, feedbackCount } from '$lib/stores';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
let { children, data } = $props();
let transitionPrefix = $state(0);
async function logout() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/logout`, {
@ -18,46 +31,127 @@
}
}
export let data: LayoutData;
if (data.reportCount) $reportCount = data.reportCount;
if (data.feedbackCount) $feedbackCount = data.feedbackCount;
if (data.adminCount) $adminCount = data.adminCount;
let tabs = [
{
path: `${env.PUBLIC_BASE_PATH}/admin/users`,
icon: UserGroup,
name: 'Registrierte Nutzer',
badge: data.userCount,
enabled: data.userCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/reports`,
icon: Flag,
name: 'Reports',
badge: $reportCount,
enabled: data.reportCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/feedback`,
icon: BookOpen,
name: 'Feedback',
badge: $feedbackCount,
enabled: data.feedbackCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
icon: Users,
name: 'Website Admins',
badge: $adminCount,
enabled: data.adminCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/settings`,
icon: AdjustmentsHorizontal,
name: 'Website Einstellungen',
badge: null,
enabled: data.settingsRead
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/tools`,
icon: WrenchScrewdriver,
name: 'Tools',
badge: null,
enabled: data.toolsRead
}
];
let pageTitleSuffix = $derived(
tabs.find((t) => $page.url.pathname === t.path)?.name ??
($page.url.pathname === `${env.PUBLIC_BASE_PATH}/admin/login` ? 'Login' : null)
);
</script>
<svelte:head>
<title>Craftattack Admin{pageTitleSuffix ? ` - ${pageTitleSuffix}` : ''}</title>
</svelte:head>
{#if $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`}
<div class="flex h-screen">
<div class="h-full">
<ul class="menu p-4 w-max h-full bg-base-200 text-base-content">
{#if data.userCount != null}
<li>
<a href="{env.PUBLIC_BASE_PATH}/admin/users">
<IconOutline name="user-group-outline" />
<span class="ml-1">Registrierte Nutzer</span>
<div class="badge">{data.userCount}</div>
</a>
</li>
<div class="relative bg-base-200 w-full flex justify-center">
<div role="tablist" class="tabs tabs-lifted">
{#each tabs as tab, i}
{#if tab.enabled}
{@const Icon = tab.icon}
<a
role="tab"
class="tab h-full"
class:tab-active={$page.url.pathname === tab.path}
href={tab.path}
onclick={() => {
let currIdx = tabs.findIndex((t) => $page.url.pathname === t.path);
if (currIdx !== -1) {
transitionPrefix = currIdx < i ? 1 : -1;
}
}}
>
<span class="my-2 flex items-center space-x-1">
<Icon />
<span>{tab.name}</span>
{#if tab.badge != null}
<div class="badge">{tab.badge}</div>
{/if}
</span>
</a>
{/if}
{#if data.adminCount != null}
<li>
<a href="{env.PUBLIC_BASE_PATH}/admin/admin">
<IconOutline name="users-outline" />
<span class="ml-1">Website Admins</span>
<div class="badge">{$adminCount}</div>
</a>
</li>
{/if}
<li class="mt-auto">
<button on:click={(e) => buttonTriggeredRequest(e, logout())}>
<IconOutline name="arrow-left-on-rectangle-outline" />
<span class="ml-1">Ausloggen</span>
{/each}
</div>
<div class="absolute top-0 right-0 flex items-center h-full">
<ul class="menu menu-vertical">
<li>
<button onclick={(e) => buttonTriggeredRequest(e, logout())}>
<ArrowLeftOnRectangle />
<span>Ausloggen</span>
</button>
</li>
</ul>
</div>
<div class="h-full w-full overflow-scroll">
<slot />
</div>
</div>
<div class="grid">
{#key $page.url.pathname}
<div
class="col-[1] row-[1] h-full w-full overflow-y-scroll overflow-x-hidden"
in:fly={{ x: transitionPrefix * window.innerWidth, duration: !transitionPrefix ? 0 : 100 }}
out:fly={{
x: transitionPrefix * -window.innerWidth,
duration: !transitionPrefix ? 0 : 100
}}
>
{@render children()}
</div>
{/key}
</div>
{:else}
<div class="h-full w-full">
<slot />
{@render children()}
</div>
{/if}
{#if $errorMessage}
<ErrorToast timeout={2000} show={$errorMessage != null}>
<span>{$errorMessage}</span>
</ErrorToast>
{/if}

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { BookOpen, Cog6Tooth, Flag, UserGroup, Users } from 'svelte-heros-v2';
let { data } = $props();
let tabs = [
{
path: `${env.PUBLIC_BASE_PATH}/admin/users`,
icon: UserGroup,
name: 'Registrierte Nutzer',
enabled: data.userCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/reports`,
icon: Flag,
name: 'Reports',
enabled: data.reportCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/feedback`,
icon: BookOpen,
name: 'Feedback',
enabled: data.feedbackCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
icon: Users,
name: 'Website Admins',
enabled: data.adminCount != null
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/settings`,
icon: Cog6Tooth,
name: 'Website Einstellungen',
enabled: data.settingsRead
}
];
</script>
<div class="flex justify-around items-center h-screen">
{#each tabs as tab}
{#if tab.enabled}
{@const Icon = tab.icon}
<div class="flex flex-col gap-4 justify-center items-center">
<a
class="h-48 w-48 border flex justify-center items-center rounded-xl duration-100 hover:bg-base-200"
href={tab.path}
title={tab.name}
>
<Icon />
</a>
<span>{tab.name}</span>
</div>
{/if}
{/each}
</div>

View File

@ -1,3 +1,3 @@
<div class="flex justify-center items-center w-full">
<div class="flex justify-center w-full">
<slot />
</div>

View File

@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
if (adminCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
let admins: (typeof Admin.prototype)[] = [];
if (getSession(cookies, { permissions: [Permissions.AdminRead] }) != null) {
if (getSession(cookies, { permissions: [Permissions.Admin] }) != null) {
admins = await Admin.findAll({ raw: true, attributes: { exclude: ['password'] } });
}

View File

@ -1,25 +1,33 @@
<script lang="ts">
import type { PageData } from './$types';
import Badges from '$lib/components/Input/Badges.svelte';
import { IconOutline } from 'svelte-heros-v2';
import { Check, NoSymbol, PencilSquare, Trash, UserPlus } from 'svelte-heros-v2';
import Input from '$lib/components/Input/Input.svelte';
import { Permissions } from '$lib/permissions';
import { env } from '$env/dynamic/public';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
import { buttonTriggeredRequest } from '$lib/components/utils';
import { goto } from '$app/navigation';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { adminCount } from '$lib/stores';
import { getPopupModalShowFn } from '$lib/context';
let { data } = $props();
let showPopupModal = getPopupModalShowFn();
let admins = $state(data.admins);
let allPermissionBadges = {
'Admin Read': Permissions.AdminRead,
'Admin Write': Permissions.AdminWrite,
'User Read': Permissions.UserRead,
'User Write': Permissions.UserWrite
Admin: Permissions.Admin,
Users: Permissions.Users,
Reports: Permissions.Reports,
Feedback: Permissions.Feedback,
Settings: Permissions.Settings,
Tools: Permissions.Tools
};
let newAdminUsername: string;
let newAdminPassword: string;
let newAdminPermissions: number[];
let newAdminUsername = $state('');
let newAdminPassword = $state('');
let newAdminPermissions = $state([]);
async function addAdmin(username: string, password: string, permissions: Permissions) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, {
@ -34,8 +42,11 @@
let res = await response.json();
$adminCount += 1;
res.permissions = new Permissions(res.permissions).asArray();
data.admins.push(res);
data.admins = data.admins;
admins.push(res);
newAdminUsername = '';
newAdminPassword = '';
newAdminPermissions = [];
} else {
throw new Error();
}
@ -77,117 +88,148 @@
await goto(`${env.PUBLIC_BASE_PATH}/`);
} else {
$adminCount -= 1;
data.admins.splice(
data.admins.findIndex((v) => v.id == id),
admins.splice(
admins.findIndex((v) => v.id == id),
1
);
data.admins = data.admins;
admins = admins;
}
} else {
throw new Error();
}
}
let errorMessage = '';
export let data: PageData;
let permissions = new Permissions(data.permissions);
let permissions = $state(new Permissions(data.permissions));
</script>
<table class="table table-zebra w-full">
<thead>
<tr>
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Benutzername</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Passwort</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Berechtigungen</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
<th></th>
<th>Benutzername</th>
<th>Passwort</th>
<th>Berechtigungen</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.admins as admin, i}
{#each admins as admin, i}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input
type="text"
bind:value={admin.username}
disabled={!permissions.adminWrite() || !admin.edit}
size="sm"
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
<td>{i + 1}</td>
<td><Input type="text" bind:value={admin.username} disabled={!admin.edit} size="sm" /></td>
<td
><Input
type="password"
bind:value={admin.password}
placeholder="Neues Passwort..."
disabled={!permissions.adminWrite() || !admin.edit}
disabled={!admin.edit}
size="sm"
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
<td
><Badges
bind:value={admin.permissions}
available={allPermissionBadges}
disabled={!permissions.adminWrite() || !admin.edit}
disabled={!admin.edit}
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<td>
<div>
{#if admin.edit}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={async (e) => {
await buttonTriggeredRequest(
e,
updateAdmin(
admin.id,
admin.username,
admin.password,
new Permissions(admin.permissions)
)
);
admin.password = '';
admin.edit = false;
onclick={async (e) => {
showPopupModal({
title: 'Speichern',
text: `Sollen die Änderungen für den Admin '${admin.username}' gespeichert werden?`,
actions: [
{
text: 'Speichern',
action: async () => {
await buttonTriggeredRequest(
e,
updateAdmin(
admin.id,
admin.username,
admin.password,
new Permissions(admin.permissions)
)
);
admin.password = '';
admin.edit = false;
}
},
{ text: 'Abbrechen' }
]
});
}}
>
<IconOutline name="check-outline" width="18" height="18" />
<Check size="18" />
</button>
</span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={() => {
admin.edit = false;
admin = admin.before;
onclick={() => {
if (
admin.username === admin.before.username &&
admin.password === admin.before.password &&
JSON.stringify(admin.permissions) === JSON.stringify(admin.before.permissions)
) {
admin.edit = false;
return;
}
showPopupModal({
title: 'Abbrechen',
text: 'Soll die Adminbearbeitung abgebrochen werden?',
actions: [
{
text: 'Abbrechen',
action: () => {
admin.edit = false;
admins[i] = admin.before;
}
},
{ text: 'Schließen' }
]
});
}}
>
<IconOutline name="no-symbol-outline" width="18" height="18" />
<NoSymbol size="18" />
</button>
</span>
{:else}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={() => {
onclick={() => {
admin.before = $state.snapshot(admin);
admin.edit = true;
admin.before = structuredClone(admin);
}}
>
<IconOutline name="pencil-square-outline" width="18" height="18" />
<PencilSquare size="18" />
</button>
</span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={(e) => buttonTriggeredRequest(e, deleteAdmin(admin.id))}
onclick={(e) => {
showPopupModal({
title: 'Admin löschen',
text: `Soll der Admin ${admin.username} wirklich gelöscht werden?`,
actions: [
{
text: 'Löschen',
action: () => buttonTriggeredRequest(e, deleteAdmin(admin.id))
},
{ text: 'Abbrechen' }
]
});
}}
>
<IconOutline name="trash-outline" width="18" height="18" />
<Trash size="18" />
</button>
</span>
{/if}
@ -196,73 +238,45 @@
</tr>
{/each}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{data.admins.length + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" bind:value={newAdminUsername} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="password" bind:value={newAdminPassword} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Badges
bind:value={newAdminPermissions}
available={allPermissionBadges}
disabled={!permissions.adminWrite()}
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<span
class="w-min"
class:cursor-not-allowed={!permissions.adminWrite() ||
!newAdminUsername ||
!newAdminPassword}
>
<td>{admins.length + 1}</td>
<td><Input type="text" bind:value={newAdminUsername} size="sm" /></td>
<td><Input type="password" bind:value={newAdminPassword} size="sm" /></td>
<td><Badges bind:value={newAdminPermissions} available={allPermissionBadges} /></td>
<td>
<span class="w-min" class:cursor-not-allowed={!newAdminUsername || !newAdminPassword}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite() || !newAdminUsername || !newAdminPassword}
on:click={async (e) => {
await buttonTriggeredRequest(
e,
addAdmin(newAdminUsername, newAdminPassword, new Permissions(newAdminPermissions))
);
newAdminUsername = '';
newAdminPassword = '';
newAdminPermissions = [];
disabled={!newAdminUsername || !newAdminPassword}
onclick={async (e) => {
showPopupModal({
title: 'Admin hinzugügen',
text: `Soll der neue Admin ${newAdminUsername} hinzugefügt werden?`,
actions: [
{
text: 'Hinzufügen',
action: async () => {
await buttonTriggeredRequest(
e,
addAdmin(
newAdminUsername,
newAdminPassword,
new Permissions(newAdminPermissions)
)
);
newAdminUsername = '';
newAdminPassword = '';
newAdminPermissions = [];
}
},
{ text: 'Abbrechen' }
]
});
}}
>
<IconOutline name="user-plus-outline" width="18" height="18" />
<UserPlus size="18" />
</button>
</span>
</td>
</tr>
</tbody>
</table>
<ErrorToast show={errorMessage !== ''}>
<span />
</ErrorToast>
<style lang="scss">
thead tr th,
tbody tr td {
@apply relative;
&:not(:first-child) {
@apply border-l-[1px] border-dashed;
&::before {
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
&:not(:last-child) {
@apply border-r-[1px] border-dashed border-base-300;
&::after {
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
}
</style>

View File

@ -1,72 +1,48 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Permissions } from '$lib/permissions';
import {
addSession,
deleteAllUserSessions,
deleteSession,
getSession,
updateAllUserSessions
} from '$lib/server/session';
import { deleteAllUserSessions, getSession, updateAllUserSessions } from '$lib/server/session';
import { Admin } from '$lib/server/database';
import { env as publicEnv } from '$env/dynamic/public';
import { AdminDeleteSchema, AdminEditSchema, AdminListSchema } from './schema';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
return new Response(null, {
status: 401
});
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 });
}
const data = await request.json();
const username = data['username'] as string | null;
const password = data['password'] as string | null;
const permissions = data['permissions'] as number | null;
const parseResult = await AdminListSchema.safeParseAsync(await request.json());
if (!parseResult.success) return new Response(null, { status: 400 });
const data = parseResult.data;
if (username == null || password == null || permissions == null) {
return new Response(null, {
status: 400
});
if (data.username == null || data.password == null || data.permissions == null) {
return new Response(null, { status: 400 });
}
const admin = await Admin.create({
username: username,
password: password,
permissions: new Permissions(permissions)
username: data.username,
password: data.password,
permissions: new Permissions(data.permissions)
});
delete admin.dataValues.password;
return new Response(JSON.stringify(admin), {
status: 201
});
return new Response(JSON.stringify(admin), { status: 201 });
}) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
return new Response(null, {
status: 401
});
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 });
}
const data = await request.json();
const id = data['id'] as string | null;
const parseResult = await AdminEditSchema.safeParseAsync(await request.json());
if (!parseResult.success) return new Response(null, { status: 400 });
const data = parseResult.data;
if (id == null) {
return new Response(null, {
status: 400
});
}
const user = await Admin.findOne({ where: { id: data.id } });
if (!user) return new Response(null, { status: 400 });
const user = await Admin.findOne({ where: { id: id } });
if (!user) {
return new Response(null, {
status: 400
});
}
if (data['username']) user.username = data['username'];
if (data['password']) user.password = data['password'];
if (data['permissions']) user.permissions = new Permissions(data['permissions']);
if (data.username) user.username = data.username;
if (data.password) user.password = data.password;
if (data.permissions) user.permissions = new Permissions(data.permissions);
await user.save();
updateAllUserSessions(user.id, { permissions: user.permissions });
@ -75,23 +51,16 @@ export const PATCH = (async ({ request, cookies }) => {
}) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
return new Response(null, {
status: 401
});
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 });
}
const data = await request.json();
const id = data['id'] as number | null;
const parseResult = await AdminDeleteSchema.safeParseAsync(await request.json());
if (!parseResult.success) return new Response(null, { status: 400 });
const data = parseResult.data;
if (id == null) {
return new Response(null, {
status: 400
});
}
await Admin.destroy({ where: { id: id } });
deleteAllUserSessions(id);
await Admin.destroy({ where: { id: data.id } });
deleteAllUserSessions(data.id);
return new Response();
}) satisfies RequestHandler;

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
export const AdminListSchema = z.object({
username: z.string(),
password: z.string(),
permissions: z.number()
});
export const AdminEditSchema = z.object({
id: z.number(),
username: z.string().nullish(),
password: z.string().nullish(),
permissions: z.number().nullish()
});
export const AdminDeleteSchema = z.object({
id: z.number()
});

View File

@ -0,0 +1,199 @@
<script lang="ts">
import type { Feedback } from '$lib/server/database';
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { fly } from 'svelte/transition';
import HeaderBar from './HeaderBar.svelte';
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
import { MagnifyingGlass, Share } from 'svelte-heros-v2';
import { goto } from '$app/navigation';
import Input from '$lib/components/Input/Input.svelte';
import Textarea from '$lib/components/Input/Textarea.svelte';
import { onDestroy, onMount } from 'svelte';
let feedbacks: (typeof Feedback.prototype.dataValues)[] = $state([]);
let feedbacksPerRequest = 25;
let feedbackFilter = $state({ event: null, content: null, username: null });
let activeFeedback: typeof Feedback.prototype.dataValues | null = $state(null);
async function fetchFeedback(extendedFilter?: {
limit?: number;
from?: number;
hash?: string;
preview?: boolean;
}): Promise<Feedback[]> {
if (!browser) return [];
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/feedback`, {
method: 'POST',
body: JSON.stringify({
...feedbackFilter,
preview: extendedFilter?.preview ?? true,
hash: extendedFilter?.hash ?? undefined,
limit: extendedFilter?.limit ?? feedbacksPerRequest,
from: extendedFilter?.from ?? feedbacks.length
})
});
return await response.json();
}
async function openHashReport() {
if (!window.location.hash) return;
const requestedHash = window.location.hash.substring(1);
const hashFeedback = await fetchFeedback({ hash: requestedHash, preview: false });
if (!hashFeedback) {
await goto(window.location.href.split('#')[0], { replaceState: true });
return;
}
activeFeedback = hashFeedback[0];
}
onMount(async () => {
if (browser) window.addEventListener('hashchange', openHashReport);
});
onDestroy(() => {
if (browser) window.removeEventListener('hashchange', openHashReport);
});
</script>
<div class="h-full flex flex-row">
<div class="w-full flex flex-col overflow-hidden">
<HeaderBar
bind:feedbackFilter
onUpdate={() => fetchFeedback({ from: 0 }).then((r) => (feedbacks = r))}
/>
<hr class="divider my-1 mx-8 border-none" />
<table class="table table-fixed h-fit">
<thead>
<tr>
<th>Event</th>
<th>Titel</th>
<th>Nutzer</th>
<th>Datum</th>
<th>Inhalt</th>
</tr>
</thead>
<PaginationTableBody
onUpdate={async () =>
await fetchFeedback().then((feedback) => (feedbacks = [...feedbacks, ...feedback]))}
>
{#each feedbacks as feedback}
<tr
class="hover [&>*]:text-sm cursor-pointer"
class:bg-base-200={activeFeedback?.url_hash === feedback.url_hash}
onclick={async () => {
await goto(`${window.location.href.split('#')[0]}#${feedback.url_hash}`, {
replaceState: true
});
await openHashReport();
}}
>
<td title={feedback.event}>{feedback.event}</td>
<td class="overflow-hidden overflow-ellipsis">{feedback.title}</td>
<td class="flex">
{feedback.user?.username || ''}
{#if feedback.user}
<button
class="pl-1"
title="Nach Ersteller filtern"
onclick={(e) => {
e.stopPropagation();
feedbackFilter.username = feedback.user.username;
fetchFeedback({ from: 0 }).then((r) => (feedbacks = r));
}}
>
<MagnifyingGlass size="14" />
</button>
{/if}
</td>
<td
>{new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(feedback.updatedAt))} Uhr</td
>
<td class="overflow-hidden overflow-ellipsis"
>{feedback.content}{feedback.content_stripped ? '...' : ''}</td
>
</tr>
{/each}
</PaginationTableBody>
</table>
</div>
{#if activeFeedback}
<div
class="relative flex flex-col w-2/5 h-[calc(100vh-3rem)] bg-base-200/50 px-4 py-6 overflow-scroll"
transition:fly={{ x: 200, duration: 200 }}
>
<div class="absolute right-2 top-2 flex justify-center">
<form class="dropdown dropdown-end">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center">
<Share size="1rem" />
</label>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max"
>
<li>
<button
onclick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeFeedback.url_hash}`
);
}}
>
Internen Link kopieren
</button>
<button
onclick={() =>
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeFeedback.url_hash}`
)}>Öffentlichen Link kopieren</button
>
</li>
</ul>
</form>
<button
class="btn btn-sm btn-circle btn-ghost"
onclick={() => {
activeFeedback = null;
goto(window.location.href.split('#')[0], { replaceState: true });
}}></button
>
</div>
<h3 class="font-roboto font-semibold text-2xl mb-2">Feedback</h3>
<div class="w-full">
<Input readonly={true} size="sm" value={activeFeedback.event} pickyWidth={false}>
{#snippet label()}
<span>Event</span>
{/snippet}
</Input>
<Input readonly={true} size="sm" value={activeFeedback.title} pickyWidth={false}>
{#snippet label()}
<span>Titel</span>
{/snippet}
</Input>
<Textarea readonly={true} rows={4} label="Inhalt" value={activeFeedback.content} />
<div class="divider mb-1"></div>
<Input
readonly={true}
size="sm"
value={activeFeedback.user?.username || ''}
pickyWidth={false}
>
{#snippet label()}
<span>Nutzer</span>
{/snippet}
</Input>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,58 @@
import type { RequestHandler } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { FeedbackListSchema } from './schema';
import { Feedback, sequelize, User } from '$lib/server/database';
import { type Attributes, Op } from 'sequelize';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Feedback] }) == null) {
return new Response(null, {
status: 401
});
}
const parseResult = await FeedbackListSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, {
status: 400
});
}
const data = parseResult.data;
const feedbackFindOptions: Attributes<Feedback> = {
content: { [Op.not]: null }
};
if (data.event) Object.assign(feedbackFindOptions, { event: { [Op.like]: `%${data.event}%` } });
// prettier-ignore
if (data.content) Object.assign(feedbackFindOptions, { content: { [Op.like]: `%${data.content}%` } });
if (data.username)
Object.assign(feedbackFindOptions, {
user_id: await User.findAll({
attributes: ['id'],
where: { username: { [Op.like]: `%${data.username}%` } }
}).then((users) => users.map((user) => user.id))
});
if (data.hash) Object.assign(feedbackFindOptions, { url_hash: data.hash });
const feedback = await Feedback.findAll({
where: feedbackFindOptions,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
attributes: data.preview
? {
exclude: ['content'],
include: [
[sequelize.literal('SUBSTR(content, 1, 50)'), 'content'],
[sequelize.literal('LENGTH(content) > 50'), 'content_stripped']
]
}
: undefined,
include: { model: User, as: 'user' },
order: [['created_at', 'DESC']],
offset: data.hash ? 0 : data.from || 0,
limit: data.hash ? 1 : data.limit || 100
});
return new Response(JSON.stringify(feedback));
}) satisfies RequestHandler;

View File

@ -0,0 +1,26 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
let {
feedbackFilter = $bindable({ event: null, content: null, username: null }),
onUpdate
}: { feedbackFilter: { [k: string]: any }; onUpdate: () => void } = $props();
</script>
<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.username} oninput={onUpdate}>
{#snippet label()}
<span>Nutzer</span>
{/snippet}
</Input>
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.event} oninput={onUpdate}>
{#snippet label()}
<span>Event</span>
{/snippet}
</Input>
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.content} oninput={onUpdate}>
{#snippet label()}
<span>Inhalt</span>
{/snippet}
</Input>
</form>

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
export const FeedbackListSchema = z.object({
limit: z.number().nullish(),
from: z.number().nullish(),
event: z.string().nullish(),
content: z.string().nullish(),
hash: z.string().nullish(),
username: z.string().nullish(),
preview: z.boolean().nullish()
});

View File

@ -1,3 +1,3 @@
<div class="flex justify-center items-center w-full h-full">
<div class="flex justify-center items-center w-full h-screen">
<slot />
</div>

View File

@ -1,53 +1,54 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import { env } from '$env/dynamic/public';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
import { goto } from '$app/navigation';
import { errorMessage } from '$lib/stores';
let password = $state('');
let passwordValue: string;
async function login() {
// eslint-disable-next-line no-async-promise-executor
loginRequest = new Promise(async (resolve, reject) => {
loginRequest = new Promise(async (resolve) => {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/login`, {
method: 'POST',
body: new FormData(document.forms[0])
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
});
if (response.ok) {
await goto(`${env.PUBLIC_BASE_PATH}/admin`, { invalidateAll: true });
window.location.href = `${env.PUBLIC_BASE_PATH}/admin`;
resolve();
} else if (response.status == 403) {
passwordValue = '';
showError = true;
errorToastElement.reset();
} else if (response.status == 401) {
password = '';
$errorMessage = 'Nutzername oder Passwort falsch';
resolve();
} else {
reject(Error(`${response.statusText} (${response.status})`));
$errorMessage = `${response.statusText} (${response.status})`;
}
loginRequest = null;
});
}
let loginRequest: Promise<void> | null = null;
let showError = false;
let errorToastElement: ErrorToast;
let loginRequest: Promise<void> | null = $state(null);
</script>
<div class="card px-14 py-6 shadow-lg">
<h1 class="text-center text-4xl mt-2 mb-4">Craftattack Admin Login</h1>
<form class="flex flex-col items-center" on:submit|preventDefault={login}>
<form
class="flex flex-col items-center"
onsubmit={(e) => {
e.preventDefault();
login();
}}
>
<div class="flex flex-col justify-center items-center">
<div class="grid gap-4">
<Input id="username" name="username" type="text" required={true}>
<span slot="label">Nutzername</span>
{#snippet label()}
<span>Nutzername</span>
{/snippet}
</Input>
<Input
id="password"
name="password"
type="password"
required={true}
bind:value={passwordValue}
>
<span slot="label">Passwort</span>
<Input id="password" name="password" type="password" required={true} bind:value={password}>
{#snippet label()}
<span>Passwort</span>
{/snippet}
</Input>
</div>
</div>
@ -60,29 +61,10 @@
{#await loginRequest}
<span
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
/>
{:catch error}
<dialog
class="modal"
on:close={() => setTimeout(() => (loginRequest = null), 200)}
open
>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 class="font-bold text-lg">Error</h3>
<p class="py-4">{error.message}</p>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
<button>close</button>
</form>
</dialog>
></span>
{/await}
{/if}
{/key}
</div>
</form>
</div>
<ErrorToast timeout={2000} bind:show={showError} bind:this={errorToastElement}>
<span>Nutzername oder Passwort falsch</span>
</ErrorToast>

View File

@ -4,23 +4,20 @@ import { env as publicEnv } from '$env/dynamic/public';
import { env } from '$env/dynamic/private';
import { addSession, sessionCookieName } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { LoginSchema } from './schema';
export const POST = (async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username') as string | null;
const password = data.get('password') as string | null;
if (username == null || password == null) {
return new Response(null, {
status: 401
});
const parseResult = await LoginSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
if (
env.ADMIN_USER &&
env.ADMIN_PASSWORD &&
username == env.ADMIN_USER &&
password == env.ADMIN_PASSWORD
data.username == env.ADMIN_USER &&
data.password == env.ADMIN_PASSWORD
) {
cookies.set(
sessionCookieName,
@ -35,8 +32,8 @@ export const POST = (async ({ request, cookies }) => {
return new Response();
}
const user = await Admin.findOne({ where: { username: username } });
if (user && user.validatePassword(password)) {
const user = await Admin.findOne({ where: { username: data.username } });
if (user && user.validatePassword(data.password)) {
cookies.set(sessionCookieName, addSession(user), {
path: `${publicEnv.PUBLIC_BASE_PATH}/admin`,
maxAge: 60 * 60 * 24 * 90,
@ -45,6 +42,7 @@ export const POST = (async ({ request, cookies }) => {
});
return new Response();
} else {
console.log(`failed login attempt for user ${data.username}`);
return new Response(null, {
status: 401
});

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const LoginSchema = z.object({
username: z.string(),
password: z.string()
});

View File

@ -1,5 +1,6 @@
import type { RequestHandler } from '@sveltejs/kit';
import { deleteSession, getSession, sessionCookieName } from '$lib/server/session';
import { env as publicEnv } from '$env/dynamic/public';
export const POST = (async ({ cookies }) => {
if (getSession(cookies) == null) {
@ -9,7 +10,7 @@ export const POST = (async ({ cookies }) => {
}
deleteSession(cookies);
cookies.delete(sessionCookieName);
cookies.delete(sessionCookieName, { path: `${publicEnv.PUBLIC_BASE_PATH}/admin` });
return new Response();
}) satisfies RequestHandler;

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { StrikeReason } from '$lib/server/database';
export const load: PageServerLoad = async ({ parent }) => {
const { reportCount } = await parent();
if (reportCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
const { self } = await parent();
return {
strike_reasons: JSON.parse(JSON.stringify(await StrikeReason.findAll())),
self: self
};
};

View File

@ -0,0 +1,386 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import type { Report } from '$lib/server/database';
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte';
import Textarea from '$lib/components/Input/Textarea.svelte';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { reportCount } from '$lib/stores';
import HeaderBar from './HeaderBar.svelte';
import { MagnifyingGlass, Plus, Share } from 'svelte-heros-v2';
import NewReportModal from './NewReportModal.svelte';
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import Search from '$lib/components/Input/Search.svelte';
import { usernameSuggestions } from '$lib/utils';
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
import { getPopupModalShowFn } from '$lib/context';
let { data } = $props();
let showPopupModal = getPopupModalShowFn();
let reports: (typeof Report.prototype.dataValues)[] = $state([]);
let reportsPerRequest = 25;
let reportFilter = $state({ draft: false, status: null, reporter: null, reported: null });
let activeReport: typeof Report.prototype.dataValues | null = $state(null);
async function fetchReports(extendedFilter?: {
hash?: string;
limit?: number;
from?: number;
}): Promise<{ reports: typeof reports; count: number }> {
if (!browser) return { reports: [], count: 0 };
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
method: 'POST',
body: JSON.stringify({
...reportFilter,
limit: extendedFilter?.limit ?? reportsPerRequest,
from: extendedFilter?.from ?? reports.length,
hash: extendedFilter?.hash
})
});
if (activeReport) {
activeReport = null;
await goto(window.location.href.split('#')[0], { replaceState: true });
}
return await response.json();
}
async function openHashReport() {
if (!window.location.hash) return;
const requestedHash = window.location.hash.substring(1);
let report = reports.find((r) => r.url_hash === requestedHash);
if (!report) {
const hashReport = (await fetchReports({ hash: requestedHash })).reports[0];
if (hashReport) {
reports = [hashReport, ...reports];
report = hashReport;
} else {
await goto(window.location.href.split('#')[0], { replaceState: true });
return;
}
}
activeReport = report;
activeReport.originalStatus = report;
}
onMount(async () => {
if (browser) window.addEventListener('hashchange', openHashReport);
});
onDestroy(() => {
if (browser) window.removeEventListener('hashchange', openHashReport);
});
async function updateActiveReport() {
await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
method: 'PATCH',
body: JSON.stringify({
id: activeReport.id,
auditor: data.self?.id ?? -1,
notice: activeReport.notice ?? '',
statement: activeReport.statement ?? '',
status: activeReport.status,
reported: activeReport.reported?.uuid ?? null,
strike_reason: activeReport.strike_reason_id ?? null
})
});
}
let newReportModal: HTMLDialogElement;
</script>
<div class="h-full flex flex-row">
<div class="w-full flex flex-col overflow-scroll">
<div class="grid grid-cols-[5fr_1fr_10fr_1fr_5fr]">
<div></div>
<div></div>
<HeaderBar
bind:reportFilter
onUpdate={() => fetchReports({ from: 0 }).then((r) => (reports = r.reports))}
/>
<div class="divider divider-horizontal my-auto h-3/4"></div>
<div class="flex items-center">
<button class="btn" onclick={() => newReportModal.show()}>
<Plus />
<span>Neuer Report</span>
</button>
</div>
</div>
<hr class="divider my-1 mx-8 border-none" />
<table class="table table-fixed h-fit">
<colgroup>
<col style="width: 20%" />
<col style="width: 15%" />
<col style="width: 15%" />
<col style="width: 20%" />
<col style="width: 15%" />
<col style="width: 15%" />
</colgroup>
<thead>
<tr>
<th>Grund</th>
<th>Ersteller</th>
<th>Reporteter User</th>
<th>Datum</th>
<th>Bearbeitungsstatus</th>
<th>Reportstatus</th>
</tr>
</thead>
<PaginationTableBody
onUpdate={async () =>
await fetchReports().then((res) => (reports = [...reports, ...res.reports]))}
>
{#each reports as report}
<tr
class="hover [&>*]:text-sm cursor-pointer"
class:bg-base-200={activeReport?.url_hash === report.url_hash}
onclick={() => {
goto(`${window.location.href.split('#')[0]}#${report.url_hash}`, {
replaceState: true
});
activeReport = $state.snapshot(report);
activeReport.originalStatus = report.status;
}}
>
<td title={report.subject}><div class="overflow-scroll">{report.subject}</div></td>
<td class="flex">
{report.reporter.username}
<button
class="pl-1"
title="Nach Ersteller filtern"
onclick={(e) => {
e.stopPropagation();
reportFilter.reporter = report.reporter.username;
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
}}
>
<MagnifyingGlass size="14" />
</button>
</td>
<td>
{report.reported?.username || ''}
{#if report.reported?.id}
<button
class="pl-1"
title="Nach Reportetem Spieler filtern"
onclick={(e) => {
e.stopPropagation();
reportFilter.reported = report.reported.username;
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
}}
>
<MagnifyingGlass size="14" />
</button>
{/if}
</td>
<td
>{new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(report.createdAt))} Uhr</td
>
<td>
{report.status === 'none'
? 'Unbearbeitet'
: report.status === 'review'
? 'In Bearbeitung'
: report.status === 'reviewed'
? 'Bearbeitet'
: ''}
</td>
<td>{report.draft ? 'Entwurf' : 'Erstellt'}</td>
</tr>
{/each}
</PaginationTableBody>
</table>
</div>
{#if activeReport}
<div
class="relative flex flex-col w-2/5 h-[calc(100vh-3rem)] bg-base-200/50 px-4 py-6 overflow-scroll"
transition:fly={{ x: 200, duration: 200 }}
>
<div class="absolute right-2 top-2 flex justify-center">
<form class="dropdown dropdown-end">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center">
<Share size="1rem" />
</label>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max"
>
<li>
<button
onclick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeReport.url_hash}`
);
}}
>
Internen Link kopieren
</button>
<button
onclick={() =>
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeReport.url_hash}`
)}>Öffentlichen Link kopieren</button
>
</li>
</ul>
</form>
<button
class="btn btn-sm btn-circle btn-ghost"
onclick={() => {
activeReport = null;
goto(window.location.href.split('#')[0], { replaceState: true });
}}></button
>
</div>
<h3 class="font-roboto font-semibold text-2xl mb-2">Report</h3>
<div class="w-full">
<Input readonly={true} size="sm" value={activeReport.reporter.username} pickyWidth={false}>
{#snippet label()}
<span>Reporter</span>
{/snippet}
</Input>
<Search
size="sm"
suggestionRequired={true}
emptyAllowed={true}
searchSuggestionFunc={usernameSuggestions}
invalidMessage="Es können nur registrierte Spieler reportet werden"
label="Reporteter User"
value={activeReport.reported?.username || ''}
onsubmit={(e) =>
(activeReport.reported = {
...activeReport.reported,
username: e.input,
uuid: e.value
})}
/>
<Textarea readonly={true} rows={1} label="Report Grund" value={activeReport.subject} />
<Textarea readonly={true} rows={4} label="Report Details" value={activeReport.body} />
</div>
<div class="divider mx-4"></div>
<div>
<div
class="w-full"
title={activeReport.status === 'none'
? 'Zum Bearbeiten den Bearbeitungsstatus ändern'
: ''}
>
<Textarea
label="Interne Notizen"
readonly={activeReport.status === 'none'}
rows={1}
bind:value={activeReport.notice}
/>
</div>
<div
class="w-full"
title={activeReport.status === 'none'
? 'Zum Bearbeiten den Bearbeitungsstatus ändern'
: ''}
>
<Textarea
label="(Öffentliche) Report Antwort"
readonly={activeReport.status === 'none'}
rows={3}
bind:value={activeReport.statement}
/>
</div>
<Select label="Bearbeitungsstatus" size="sm" bind:value={activeReport.status}>
<option
value="none"
disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement}
>Unbearbeitet</option
>
<option value="review">In Bearbeitung</option>
<option value="reviewed">Bearbeitet</option>
</Select>
<div>
<Select
label="Vergehen"
size="sm"
disabled={activeReport.status === 'none' || !activeReport.reported}
bind:value={activeReport.strike_reason_id}
>
<option value={-1}>Keins</option>
{#each data.strike_reasons as strike_reason}
<option value={strike_reason.id}>{strike_reason.name} ({strike_reason.weight})</option
>
{/each}
</Select>
</div>
</div>
<div class="self-end mt-auto pt-6 w-full flex justify-center">
<Input
type="submit"
value="Speichern"
onclick={() => {
showPopupModal({
title: 'Änderungen Speichern?',
actions: [
{
text: 'Speichern',
action: async () => {
await updateActiveReport();
if (activeReport.reported?.username) {
if (activeReport.reported?.id === undefined) {
activeReport.reported.id = -1;
}
} else {
activeReport.reported = undefined;
}
const activeReportIndex = reports.findIndex((r) => r.id === activeReport.id);
if (activeReportIndex === -1) {
return;
}
reports[activeReportIndex] = activeReport;
if (
activeReport.originalStatus !== 'reviewed' &&
activeReport.status === 'reviewed'
) {
$reportCount -= 1;
} else if (
activeReport.originalStatus === 'reviewed' &&
activeReport.status !== 'reviewed'
) {
$reportCount += 1;
}
}
},
{ text: 'Abbrechen' }
]
});
}}
/>
</div>
</div>
{/if}
</div>
<dialog class="modal" bind:this={newReportModal}>
<NewReportModal
onSubmit={(e) => {
if (!e.draft) $reportCount += 1;
reports = [e, ...reports];
activeReport = $state.snapshot(reports[0]);
newReportModal.close();
}}
/>
</dialog>

View File

@ -0,0 +1,199 @@
import type { RequestHandler } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { Admin, Report, StrikeReason, User } from '$lib/server/database';
import type { Attributes } from 'sequelize';
import { Op } from 'sequelize';
import { env } from '$env/dynamic/private';
import crypto from 'crypto';
import { webhookUserReported } from '$lib/server/webhook';
import { ReportAddSchema, ReportEditSchema, ReportListSchema } from './schema';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, {
status: 401
});
}
const parseResult = await ReportListSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, {
status: 400
});
}
const data = parseResult.data;
let reportFindOptions: Attributes<Report> = {};
reportFindOptions.draft = data.draft;
reportFindOptions.status = data.status ?? ['none', 'review'];
if (data.reporter != null) {
const reporter_ids = await User.findAll({
attributes: ['id'],
where: { username: { [Op.like]: `%${data.reporter}%` } }
});
reportFindOptions.reporter_id = reporter_ids.map((u) => u.id);
}
if (data.reported != null) {
const reported_ids = await User.findAll({
attributes: ['id'],
where: { username: { [Op.like]: `%${data.reported}%` } }
});
reportFindOptions.reported_id = reported_ids.map((u) => u.id);
}
if (data.hash != null) {
reportFindOptions = { url_hash: data.hash };
data.from = 0;
data.limit = 1;
}
let reports = await Report.findAll({
where: reportFindOptions,
include: [{ all: true }],
order: [['created_at', 'DESC']],
offset: data.from || 0,
limit: data.limit || 100
});
reports = reports.map((r) => {
if (r.auditor_id === null && r.status != 'none') {
// if the report was edited by the admin account set by the env variable, it has no relation to the admin
// table in the database, so it gets manually created here. we just assume that the auditor_id is never null
// when not edited by the env admin account
r.auditor_id = -1;
r.dataValues.auditor = {
id: -1,
username: env.ADMIN_USER,
permissions: new Permissions(Permissions.allPermissions()),
createdAt: 0,
updatedAt: 0
};
} else if (r.auditor) {
delete r.dataValues.auditor.password;
}
if (!r.strike_reason) {
r.strike_reason_id = -1;
}
return r;
});
return new Response(
JSON.stringify({ reports: reports, count: await Report.count({ where: reportFindOptions }) })
);
}) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, {
status: 401
});
}
const parseResult = await ReportEditSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, {
status: 400
});
}
const data = parseResult.data;
const report = await Report.findOne({ where: { id: data.id }, include: [{ all: true }] });
const admin = await Admin.findOne({ where: { id: data.auditor } });
const reported = data.reported ? await User.findOne({ where: { uuid: data.reported } }) : null;
if (report === null || (admin === null && data.auditor != -1))
return new Response(null, { status: 400 });
const webhookTriggerUsers: string[] = [];
// check if strike reason has changed and return 400 if it doesn't exist
if (
(report.strike_reason?.id ?? -1) != data.strike_reason &&
data.strike_reason != null &&
data.strike_reason != -1
) {
const strike_reason = await StrikeReason.findByPk(data.strike_reason);
if (strike_reason == null) return new Response(null, { status: 400 });
}
if (data.status === 'reviewed') {
// trigger webhook if status changed to reviewed
if (report.status !== 'reviewed' && data.strike_reason != -1 && reported) {
webhookTriggerUsers.push(reported.uuid!);
}
// trigger webhook if strike reason has changed
else if (
(report.strike_reason?.id ?? -1) != data.strike_reason &&
report.reported &&
reported
) {
webhookTriggerUsers.push(reported.uuid!);
}
} else if (report.status === 'reviewed') {
// trigger webhook if report status is reviewed and reported user has changed
if (report.strike_reason != null && report.reported) {
webhookTriggerUsers.push(report.reported.uuid!);
}
}
if (data.notice != null) report.notice = data.notice;
if (data.statement != null) report.statement = data.statement;
if (data.status != null) report.status = data.status;
if (data.reported != null && reported) report.reported_id = reported.id;
if (data.strike_reason != null)
report.strike_reason_id = data.strike_reason == -1 ? null : data.strike_reason;
if (data.strike_reason != null)
report.striked_at = data.strike_reason == -1 ? null : new Date(Date.now());
if (admin != null) report.auditor_id = admin.id;
await report.save();
for (const webhookTriggerUser of webhookTriggerUsers) {
// no `await` to avoid blocking
webhookUserReported(env.REPORTED_WEBHOOK, webhookTriggerUser).catch((e) =>
console.error(`failed to send reported webhook: ${e}`)
);
}
return new Response();
}) satisfies RequestHandler;
export const PUT = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, {
status: 401
});
}
const parseResult = await ReportAddSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, {
status: 400
});
}
const data = parseResult.data;
const reporter = await User.findOne({ where: { uuid: data.reporter } });
const reported = data.reported ? await User.findOne({ where: { uuid: data.reported } }) : null;
if (reporter == null) return new Response(null, { status: 400 });
const report = await Report.create({
subject: data.reason,
body: data.body,
draft: data.body === null,
status: 'none',
url_hash: crypto.randomBytes(18).toString('hex'),
completed: false,
reporter_id: reporter.id,
reported_id: reported?.id || null
});
report.dataValues.reporter = await User.findOne({ where: { id: report.reporter_id } });
report.dataValues.reported = report.reported_id
? await User.findOne({ where: { id: report.reported_id } })
: null;
report.dataValues.auditor = null;
return new Response(JSON.stringify(report), {
status: 201
});
}) satisfies RequestHandler;

View File

@ -0,0 +1,40 @@
<script lang="ts">
import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte';
let {
reportFilter = $bindable({
reporter: undefined,
reported: undefined,
status: undefined,
draft: false
}),
onUpdate
}: {
reportFilter: { reporter?: string; reported?: string; status?: string; draft: false };
onUpdate: () => void;
} = $props();
</script>
<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter} oninput={onUpdate}>
{#snippet label()}
<span>Report Ersteller</span>
{/snippet}
</Input>
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported} oninput={onUpdate}>
{#snippet label()}
<span>Reporteter Spieler</span>
{/snippet}
</Input>
<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status} onChange={onUpdate}>
<option value="none">Unbearbeitet</option>
<option value="review">In Bearbeitung</option>
<option value={null}>Unbearbeitet & In Bearbeitung</option>
<option value="reviewed">Bearbeitet</option>
</Select>
<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft} onChange={onUpdate}>
<option value={false}>Erstellt</option>
<option value={true}>Entwurf</option>
</Select>
</form>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import { env } from '$env/dynamic/public';
import Textarea from '$lib/components/Input/Textarea.svelte';
import Search from '$lib/components/Input/Search.svelte';
import { usernameSuggestions } from '$lib/utils';
import type { Report } from '$lib/server/database';
import { getPopupModalShowFn } from '$lib/context';
let { onSubmit }: { onSubmit: (data: typeof Report.prototype.dataValues) => void } = $props();
let showPopupModal = getPopupModalShowFn();
let reporter: string | undefined = $state();
let reported: string | undefined = $state();
let reason = $state('');
let body = $state('');
async function newReport() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
method: 'PUT',
body: JSON.stringify({
reporter: reporter,
reported: reported || null,
reason: reason,
body: body || null
})
});
if (response.ok) onSubmit(await response.json());
}
let globalCloseForm: HTMLFormElement;
let reportForm: HTMLFormElement;
</script>
<form method="dialog" class="modal-box" bind:this={reportForm}>
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}>✕</button
>
<h3 class="font-roboto text-3xl">Neuer Report</h3>
<div class="space-y-2 mt-2 px-1 max-h-[70vh] overflow-y-scroll">
<div>
<Search
size="sm"
suggestionRequired={true}
searchSuggestionFunc={usernameSuggestions}
invalidMessage="Es können nur registrierte Spieler Report Autoren sein"
label="Reporter"
required={true}
bind:value={reporter}
/>
<Search
size="sm"
suggestionRequired={true}
emptyAllowed={true}
searchSuggestionFunc={usernameSuggestions}
invalidMessage="Es können nur registrierte Spieler reportet werden"
label="Reporteter User"
bind:value={reported}
/>
</div>
<div class="divider mx-4 pt-3"></div>
<Input type="text" bind:value={reason} required={true} pickyWidth={false}>
{#snippet label()}
<span>Report Grund</span>
{/snippet}
</Input>
<div>
<Textarea rows={4} label="Details über den Report Grund" bind:value={body} />
</div>
</div>
<div class="flex flex-row space-x-2 mt-6">
<Input
type="submit"
value="Erstellen"
onclick={(e) => {
if (reportForm.checkValidity()) {
e.preventDefault();
showPopupModal({
title: 'Report Erstellen?',
text: body
? 'Dadurch, dass bereits Details über den Report Grund hinzugefügt wurden, ist es nach dem Erstellen nicht mehr möglich, den Report Inhalt zu ändern'
: 'Der Report wird als Entwurf gespeichert und kann nach dem Erstellen über den Report-Link bearbeitet werden',
actions: [
{
text: 'Erstellen',
action: async () => {
await newReport();
globalCloseForm.submit();
}
},
{ text: 'Abbrechen' }
]
});
}
}}
/>
<Input
type="submit"
value="Abbrechen"
onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}
/>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
<button>close</button>
</form>

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
export const ReportListSchema = z.object({
limit: z.number().nullish(),
from: z.number().nullish(),
status: z.enum(['none', 'review', 'reviewed']).nullish(),
reporter: z.string().nullish(),
reported: z.string().nullish(),
draft: z.boolean().nullish(),
hash: z.string().nullish()
});
export const ReportEditSchema = z.object({
id: z.number(),
reported: z.string().nullish(),
auditor: z.number(),
notice: z.string().nullish(),
statement: z.string().nullish(),
status: z.enum(['none', 'review', 'reviewed']).nullish(),
strike_reason: z.number().nullish()
});
export const ReportAddSchema = z.object({
reporter: z.string(),
reported: z.string().nullish(),
reason: z.string(),
body: z.string().nullish()
});

View File

@ -0,0 +1,33 @@
import type { PageServerLoad } from './$types';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { Settings } from '$lib/server/database';
export const load: PageServerLoad = async ({ parent, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
}
const { self } = await parent();
const settings = (await Settings.findAll()).reduce(
(prev, curr) => {
return { ...prev, [curr.key]: curr.value };
},
{} as { [key: string]: any }
);
return {
settings: {
global: {},
register: {
enabled: settings['register.enabled'] ?? true,
disabled_title: settings['register.disabled_title'] ?? 'Anmeldung geschlossen',
disabled_details: settings['register.disabled_details'] ?? ''
}
},
self: self
};
};

View File

@ -0,0 +1,76 @@
<script lang="ts">
import type { PageData } from './$types';
import { env } from '$env/dynamic/public';
let { data } = $props();
let settings = $state($state.snapshot(data.settings));
async function change() {
await fetch(`${env.PUBLIC_BASE_PATH}/admin/settings`, {
method: 'POST',
body: JSON.stringify({
global: {},
register: {
enabled: returnIfNoDup(settings.register.enabled, data.settings.register.enabled),
disabled_title: returnIfNoDup(
settings.register.disabled_title,
data.settings.register.disabled_title
),
disabled_details: returnIfNoDup(
settings.register.disabled_details,
data.settings.register.disabled_details
)
}
} as PageData['settings'])
});
data.settings = settings;
settings = $state.snapshot(data.settings);
}
function returnIfNoDup<T>(value: T, original: T): T | undefined {
return value != original ? value : undefined;
}
</script>
<div class="h-full flex flex-col items-center justify-between">
<div class="grid grid-cols-3 w-full [&>*]:mx-8">
<!--div>
<div class="divider">Global</div>
<label class="label">
<span class="label-text">PayPal-Spendenlink</span>
<input type="text" class="input input-bordered" bind:value={settings.global.paypal_link} />
</label>
</div-->
<div>
<div class="divider">Anmeldung</div>
<label class="label cursor-pointer">
<span class="label-text">Aktiviert</span>
<input type="checkbox" class="toggle" bind:checked={settings.register.enabled} />
</label>
<label class="label">
<span class="label-text">Text wenn die Anmeldung deaktiviert ist</span>
<input
type="text"
class="input input-bordered"
bind:value={settings.register.disabled_title}
/>
</label>
<label class="label">
<span class="label-text">Sub-Text wenn die Anmeldung deaktiviert ist</span>
<input
type="text"
class="input input-bordered"
bind:value={settings.register.disabled_details}
/>
</label>
</div>
</div>
<div class="mb-6">
<button
class="btn btn-success mt-auto"
class:btn-disabled={JSON.stringify(data.settings) === JSON.stringify(settings)}
onclick={change}>Speichern</button
>
</div>
</div>

View File

@ -0,0 +1,32 @@
import type { PageData } from './$types';
import type { RequestHandler } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { Settings } from '$lib/server/database';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
return new Response(null, {
status: 401
});
}
const settings: PageData['settings'] = await request.json();
for (const [group, entries] of Object.entries(settings)) {
for (const [key, value] of Object.entries(entries)) {
const setting = await Settings.findOne({ where: { key: `${group}.${key}` } });
if (setting) {
setting.value = JSON.stringify(value);
await setting.save();
} else {
await Settings.create({
key: `${group}.${key}`,
value: JSON.stringify(value)
});
}
}
}
return new Response();
}) satisfies RequestHandler;

View File

@ -0,0 +1,3 @@
<div class="flex justify-center">
<slot />
</div>

View File

@ -0,0 +1,11 @@
import type { PageServerLoad } from './$types';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
export const load: PageServerLoad = async ({ cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
}
};

View File

@ -0,0 +1,15 @@
<script lang="ts">
import UuidFinder from './UuidFinder.svelte';
let tools = [{ label: 'Account UUID finder', component: UuidFinder }];
</script>
<div class="mt-4">
{#each tools as tool}
{@const Component = tool.component}
<fieldset class="border border-solid rounded border-gray-700 py-3 px-6">
<legend class="text-sm px-1">{tool.label}</legend>
<Component />
</fieldset>
{/each}
</div>

View File

@ -0,0 +1,71 @@
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import type { RequestHandler } from '@sveltejs/kit';
import {
ApiError,
getBedrockUuid,
getJavaUuid,
RateLimitError,
UserNotFoundError
} from '$lib/server/minecraft';
import type { ZodType } from 'zod';
import { FindUuidSchema } from './schema';
export const POST = (async ({ url, request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Tools] }) == null) {
return new Response(null, { status: 401 });
}
const action = url.searchParams.get('action');
if (!action) {
return new Response(null, { status: 400 });
}
try {
switch (action) {
case 'getuuid': {
const data = await parseData(FindUuidSchema, await request.json());
return new Response(await findUuid(data.username, data.edition), { status: 200 });
}
}
return new Response(null, { status: 400 });
} catch (error) {
return new Response(JSON.stringify({ error: error }), { status: 400 });
}
}) satisfies RequestHandler;
async function parseData<Output>(schema: ZodType<Output>, json: string): Promise<Output> {
const parseResult = await schema.safeParseAsync(json);
if (!parseResult.success) throw new Error(parseResult.error.toString());
return parseResult.data;
}
async function findUuid(username: string, edition: 'java' | 'bedrock'): Promise<string> {
let uuid = '';
try {
switch (edition) {
case 'java':
uuid = await getJavaUuid(username);
break;
case 'bedrock':
uuid = await getBedrockUuid(username);
break;
}
} catch (e) {
if (e instanceof UserNotFoundError) {
throw `Der Spielername ${username} existiert nicht`;
} else if (e instanceof ApiError) {
throw (e as Error).message;
} else if (e instanceof RateLimitError) {
throw 'Rate limit exceeded, bitte versuche es erneut';
} else {
throw e;
}
}
return JSON.stringify({
uuid: uuid
});
}

View File

@ -0,0 +1,44 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte';
import { sendRequest } from './tools';
let username = $state('');
let edition = $state('java');
let uuid = $state('');
</script>
<div class="flex flex-col items-center space-y-4">
<div class="flex flex-row space-x-2">
<Input type="text" size="sm" bind:value={username}>
{#snippet label()}
<span>Username</span>
{/snippet}
</Input>
<Select label="Edition" size="sm" bind:value={edition}>
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
</Select>
</div>
<Input
type="submit"
size="sm"
disabled={!username}
value="UUID finden"
onclick={() =>
sendRequest('getuuid', { username, edition })
.then((data) => (uuid = data.uuid))
.catch(() => (uuid = ''))}
/>
<div class="w-full">
<Input
type="text"
size="sm"
readonly={true}
disabled={!uuid}
pickyWidth={false}
placeholder="UUID... "
value={uuid}
/>
</div>
</div>

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FindUuidSchema = z.object({
username: z.string(),
edition: z.enum(['java', 'bedrock'])
});

View File

@ -0,0 +1,22 @@
import { env } from '$env/dynamic/public';
import { errorMessage } from '$lib/stores';
type Actions = 'getuuid';
export async function sendRequest<T = any>(action: Actions, data: any): Promise<T> {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/tools?action=${action}`, {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const data = await response.json();
if (Object.hasOwn(data, 'error')) {
errorMessage.set(data['error']);
} else {
errorMessage.set('Ein Fehler ist aufgetreten');
}
throw new Error();
}
return response.json();
}

View File

@ -11,6 +11,6 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
return {
count:
getSession(cookies, { permissions: [Permissions.UserRead] }) != null ? await User.count() : 0
getSession(cookies, { permissions: [Permissions.Users] }) != null ? await User.count() : 0
};
};

View File

@ -1,112 +1,52 @@
<script lang="ts">
import type { PageData } from './$types';
import { IconOutline, IconSolid } from 'svelte-heros-v2';
import { Check, NoSymbol, PencilSquare, Plus, Trash } from 'svelte-heros-v2';
import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte';
import { env } from '$env/dynamic/public';
import type { User } from '$lib/server/database';
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
import { buttonTriggeredRequest } from '$lib/components/utils';
import { browser } from '$app/environment';
import HeaderBar from './HeaderBar.svelte';
import SortableTr from '$lib/components/Table/SortableTr.svelte';
import SortableTh from '$lib/components/Table/SortableTh.svelte';
import NewUserModal from './NewUserModal.svelte';
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
import { getPopupModalShowFn } from '$lib/context';
export let data: PageData;
let showPopupModal = getPopupModalShowFn();
let headers = [
{
name: 'Vorname',
key: 'firstname',
asc: false
},
{
name: 'Nachname',
key: 'lastname',
asc: false
},
{ name: 'Geburtstag', key: 'birthday', asc: false, sort: (a, b) => a.birthday - b.birthday },
{ name: 'Telefon', key: 'telephone', asc: false, sort: (a, b) => a.telephone - b.telephone },
{
name: 'Username',
key: 'username',
asc: false
},
{
name: 'Minecraft Edition',
key: 'playertype',
asc: false
},
{
name: 'Passwort',
key: 'password',
asc: false
},
{ name: 'UUID', key: 'uuid', asc: false }
];
let ascHeader: (typeof headers)[0] | null = null;
let users: (typeof User.prototype.dataValues)[] = $state([]);
let usersPerRequest = 25;
let userFilter: { [k: string]: any } = $state({ name: null, playertype: null });
let currentPageUsers: (typeof User.prototype.dataValues)[] = [];
let currentPageUsersRequest: Promise<void> = new Promise((resolve) => resolve());
let usersCache: (typeof User.prototype.dataValues)[][] = [];
let usersPerPage = 50;
let userPage = 0;
let userTableContainerElement: HTMLDivElement;
let newUserModal: HTMLDialogElement;
function fetchPageUsers(page: number) {
if (!browser) return;
async function fetchUsers(extendedFilter?: {
limit?: number;
from?: number;
}): Promise<typeof users> {
if (!browser) return [];
if (userTableContainerElement) userTableContainerElement.scrollTop = 0;
if (usersCache[page]) {
currentPageUsers = usersCache[page];
return;
}
// eslint-disable-next-line no-async-promise-executor
currentPageUsersRequest = new Promise(async (resolve, reject) => {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'POST',
body: JSON.stringify({
limit: usersPerPage,
from: usersPerPage * page
})
});
if (response.ok) {
const pageUsers = await response.json();
currentPageUsers = usersCache[page] = pageUsers;
resolve();
} else {
reject(Error());
}
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'POST',
body: JSON.stringify({
...userFilter,
limit: extendedFilter?.limit ?? usersPerRequest,
from: extendedFilter?.from ?? users.length
})
});
return await response.json();
}
async function sortUsers(key: string, reverse: boolean) {
const multiplyValue = reverse ? -1 : 1;
currentPageUsers.sort((entryA, entryB) => {
const a = entryA[key];
const b = entryB[key];
switch (typeof a) {
case 'number':
return (a - b) * multiplyValue;
case 'string':
return a.localeCompare(b) * multiplyValue;
default:
return (a - b) * multiplyValue;
}
});
currentPageUsers = currentPageUsers;
}
$: fetchPageUsers(userPage);
async function updateUser(user: typeof User.prototype.dataValues) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'PATCH',
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error();
}
}
async function deleteUser(id: number) {
@ -117,213 +57,202 @@
})
});
if (response.ok) {
currentPageUsers.splice(
currentPageUsers.findIndex((v) => v.id == id),
users.splice(
users.findIndex((v) => v.id == id),
1
);
currentPageUsers = currentPageUsers;
} else {
throw new Error();
users = users;
}
}
let userFilterEffectAlreadyRan = false;
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
userFilter;
if (!userFilterEffectAlreadyRan) {
userFilterEffectAlreadyRan = true;
return;
}
fetchUsers({ from: 0 }).then((u) => (users = u));
});
</script>
<div>
<div class="h-[90vh] overflow-scroll" bind:this={userTableContainerElement}>
<table class="table relative">
<thead>
<tr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
<th />
{#each headers as header}
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>
<button
class="flex items-center"
on:click={() => {
sortUsers(header.key, (ascHeader = ascHeader == header ? null : header));
}}
>
{header.name}
<span class="ml-1">
<IconSolid
name={ascHeader === header ? 'chevron-up-solid' : 'chevron-down-solid'}
width="12"
height="12"
/>
</span>
</button>
</th>
{/each}
<th />
</tr>
</thead>
<tbody>
{#key currentPageUsersRequest}
{#await currentPageUsersRequest}
{#each Array(usersPerPage) as _, i}
<tr class="animate-pulse text-transparent">
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="date" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="tel" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Select id="edition" disabled={true} size="sm">
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="cracked">Java cracked</option>
</Select></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><div class="flex gap-1">
<button class="btn btn-sm btn-square" disabled />
</div></td
>
</tr>
{/each}
{:then _}
{#each currentPageUsers as user, i}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input
type="date"
value={new Date(user.birthday).toISOString().split('T')[0]}
on:input={(e) => (user.birthday = e.detail.target.valueAsDate.toISOString())}
disabled={!user.edit}
size="sm"
/>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Select id="edition" bind:value={user.playertype} disabled={!user.edit} size="sm">
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="cracked">Java cracked</option>
</Select>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input
id="uuid"
type="text"
bind:value={user.uuid}
disabled={!user.edit}
size="sm"
/>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<div class="flex gap-1">
{#if user.edit}
<button
class="btn btn-sm btn-square"
on:click={async (e) => {
await buttonTriggeredRequest(e, updateUser(user));
user.edit = false;
}}
>
<IconOutline name="check-outline" width="18" height="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={() => {
user.edit = false;
user = user.before;
}}
>
<IconOutline name="no-symbol-outline" width="18" height="18" />
</button>
{:else}
<button
class="btn btn-sm btn-square"
on:click={() => {
user.before = structuredClone(user);
user.edit = true;
}}
>
<IconOutline name="pencil-square-outline" width="18" height="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={(e) => buttonTriggeredRequest(e, deleteUser(user.id))}
>
<IconOutline name="trash-outline" width="18" height="18" />
</button>
{/if}
</div>
</td>
</tr>
{/each}
{/await}
{/key}
</tbody>
</table>
</div>
<div class="flex justify-center w-full mt-4 mb-6">
<div class="join">
{#each Array(Math.ceil(data.count / usersPerPage) || 1) as _, i}
<button
class="join-item btn"
class:btn-active={i === userPage}
on:click={() => {
userPage = i;
}}>{i + 1}</button
>
{/each}
<div class="h-full flex flex-col overflow-hidden">
<div class="grid grid-cols-[10fr_1fr_10fr_1fr_10fr]">
<div></div>
<div></div>
<HeaderBar bind:userFilter onUpdate={() => (userFilter = $state.snapshot(userFilter))} />
<div class="divider divider-horizontal my-auto h-3/4"></div>
<div class="flex items-center">
<button class="btn" onclick={() => newUserModal.show()}>
<Plus />
<span>Neuer Spieler</span>
</button>
</div>
</div>
<hr class="divider my-1 mx-8 border-none" />
<div class="h-full overflow-scroll" bind:this={userTableContainerElement}>
<table class="table table-auto">
<thead>
<!-- prettier-ignore -->
<SortableTr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
<th></th>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'firstname', asc: e.asc}}}>Vorname</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'lastname', asc: e.asc}}}>Nachname</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'birthday', asc: e.asc}}}>Geburtstag</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'telephone', asc: e.asc}}}>Telefon</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'username', asc: e.asc}}}>Username</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'playertype', asc: e.asc}}}>Minecraft Edition</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'password', asc: e.asc}}}>Passwort</SortableTh>
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'uuid', asc: e.asc}}}>UUID</SortableTh>
<th></th>
</SortableTr>
</thead>
<PaginationTableBody
onUpdate={async () => {
await fetchUsers().then((u) => (users = [...users, ...u]));
}}
>
{#each users as user, i}
<tr>
<td>{i + 1}</td>
<td>
<Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
</td>
<td>
<Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
</td>
<td>
<Input
type="date"
value={new Date(user.birthday).toISOString().split('T')[0]}
oninput={(e) => (user.birthday = e.currentTarget.valueAsDate.toISOString())}
disabled={!user.edit}
size="sm"
/>
</td>
<td>
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
</td>
<td>
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
</td>
<td>
<Select id="edition" bind:value={user.playertype} disabled={!user.edit} size="sm">
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="noauth">Java noauth</option>
</Select>
</td>
<td>
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
</td>
<td>
<Input id="uuid" type="text" bind:value={user.uuid} disabled={!user.edit} size="sm" />
</td>
<td>
<div class="flex gap-1">
{#if user.edit}
<button
class="btn btn-sm btn-square"
onclick={async (e) => {
showPopupModal({
title: 'Speichern',
text: `Sollen die Änderungen für den Nutzer '${user.username}' gespeichert werden?`,
actions: [
{
text: 'Speichern',
action: async () => {
await buttonTriggeredRequest(e, updateUser(user));
user.edit = false;
}
},
{ text: 'Abbrechen' }
]
});
}}
>
<Check size="18" />
</button>
<button
class="btn btn-sm btn-square"
onclick={() => {
if (
user.firstname === user.before.firstname &&
user.lastname === user.before.lastname &&
user.birthday === user.before.birthday &&
user.telephone === user.before.telephone &&
user.username === user.before.username &&
user.playertype === user.before.playertype &&
user.password === user.before.password &&
user.uuid === user.before.uuid
) {
user.edit = false;
return;
}
showPopupModal({
title: 'Abbrechen',
text: 'Soll die Nutzerbearbeitung abgebrochen werden?',
actions: [
{
text: 'Abbrechen',
action: () => {
user.edit = false;
users[i] = user.before;
}
},
{ text: 'Schließen' }
]
});
}}
>
<NoSymbol size="18" />
</button>
{:else}
<button
class="btn btn-sm btn-square"
onclick={() => {
user.before = $state.snapshot(user);
user.edit = true;
}}
>
<PencilSquare size="18" />
</button>
<button
class="btn btn-sm btn-square"
onclick={(e) => {
showPopupModal({
title: 'Nutzer löschen',
text: `Soll der Nutzer '${user.username}' wirklich gelöscht werden?`,
actions: [
{
text: 'Löschen',
action: () => buttonTriggeredRequest(e, deleteUser(user.id))
},
{ text: 'Abbrechen' }
]
});
}}
>
<Trash size="18" />
</button>
{/if}
</div>
</td>
</tr>
{/each}
</PaginationTableBody>
</table>
</div>
</div>
<style lang="scss">
thead tr th,
tbody tr td {
@apply relative;
&:not(:first-child) {
@apply border-l-[1px] border-dashed;
&::before {
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
&:not(:last-child) {
@apply border-r-[1px] border-dashed border-base-300;
&::after {
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
}
</style>
<dialog class="modal" bind:this={newUserModal}>
<NewUserModal
onSubmit={(e) => {
users = [...users, e];
newUserModal.close();
}}
/>
</dialog>

View File

@ -1,77 +1,179 @@
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import type { RequestHandler } from '@sveltejs/kit';
import { Admin, User } from '$lib/server/database';
import { error, type RequestHandler } from '@sveltejs/kit';
import { User } from '$lib/server/database';
import { type Attributes, Op } from 'sequelize';
import {
ApiError,
getJavaUuid,
getNoAuthUuid,
RateLimitError,
UserNotFoundError
} from '$lib/server/minecraft';
import { UserAddSchema, UserDeleteSchema, UserEditSchema, UserListSchema } from './schema';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) {
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const limit = data['limit'] || 100;
const from = data['from'] || 0;
const parseResult = await UserListSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
const users = await User.findAll({ offset: from, limit: limit });
const usersFindOptions: Attributes<User> = {};
if (data.name) {
Object.assign(usersFindOptions, {
[Op.or]: {
firstname: { [Op.like]: `%${data.name}%` },
lastname: { [Op.like]: `%${data.name}%` },
username: { [Op.like]: `%${data.name}%` }
}
});
} else if (data.search) {
Object.assign(usersFindOptions, {
[Op.or]: {
username: { [Op.like]: `%${data.search}%` },
uuid: { [Op.like]: `%${data.search}%` }
}
});
}
if (data.playertype) {
usersFindOptions.playertype = data.playertype;
}
const users = await User.findAll({
where: usersFindOptions,
attributes: data.slim ? ['username', 'uuid'] : undefined,
offset: data.from || 0,
limit: data.limit || 100,
order: data.sort ? [[data.sort.key, data.sort.asc ? 'ASC' : 'DESC']] : undefined
});
return new Response(JSON.stringify(users));
}) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) {
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const id = data['id'] as string | null;
if (id == null) {
return new Response(null, {
status: 400
});
const parseResult = await UserEditSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
const user = await User.findOne({ where: { id: id } });
const user = await User.findOne({ where: { id: data.id } });
if (!user) {
return new Response(null, {
status: 400
});
}
if (data['firstname']) user.firstname = data['firstname'];
if (data['lastname']) user.lastname = data['lastname'];
if (data['birthday']) user.birthday = data['birthday'];
if (data['telephone']) user.telephone = data['telephone'];
if (data['username']) user.username = data['username'];
if (data['playertype']) user.playertype = data['playertype'];
if (data['password']) user.password = data['password'];
if (data['uuid']) user.uuid = data['uuid'];
if (data.firstname) user.firstname = data.firstname;
if (data.lastname) user.lastname = data.lastname;
if (data.birthday) user.birthday = data.birthday;
if (data.telephone) user.telephone = data.telephone;
if (data.username) user.username = data.username;
if (data.playertype) user.playertype = data.playertype;
if (data.uuid) user.uuid = data.uuid;
await user.save();
return new Response();
}) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) {
export const PUT = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const id = (data['id'] as number) || null;
const parseResult = await UserAddSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
if (id == null) {
return new Response(null, {
status: 400
});
let uuid: string | null;
try {
switch (data.playertype) {
case 'java':
uuid = await getJavaUuid(data.username);
break;
case 'bedrock':
uuid = null;
// uuid = await getBedrockUuid(username);
break;
case 'noauth':
uuid = getNoAuthUuid(data.username);
break;
default:
throw new Error(`invalid player type (${data.playertype})`);
}
} catch (e) {
if (e instanceof UserNotFoundError) {
throw error(400, `Der Spielername ${data.username} existiert nicht`);
} else if (e instanceof ApiError) {
console.error((e as Error).message);
uuid = null;
} else if (e instanceof RateLimitError) {
console.error(`uuid request rate limited for user '${data.username}'`);
uuid = null;
} else {
console.error((e as Error).message);
throw error(500);
}
}
await User.destroy({ where: { id: id } });
if (uuid && (await User.findOne({ where: { uuid: uuid } }))) {
throw error(400, 'Dieser Minecraft-Account wurde bereits registriert');
} else if (
await User.findOne({
where: {
firstname: data.firstname,
lastname: data.lastname,
birthday: new Date(data.birthday).toUTCString()
}
})
) {
throw error(400, 'Ein Nutzer mit demselben Namen und Geburtstag wurde bereits registriert');
}
await User.create({
firstname: data.firstname,
lastname: data.lastname,
birthday: new Date(data.birthday).toUTCString(),
telephone: data.telephone,
username: data.username,
playertype: data.playertype,
password: null,
uuid: uuid
});
return new Response();
}) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, {
status: 401
});
}
const parseResult = await UserDeleteSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
await User.destroy({ where: { id: data.id } });
return new Response();
}) satisfies RequestHandler;

View File

@ -0,0 +1,39 @@
<script lang="ts">
import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte';
let {
userFilter = $bindable({ name: null, playertype: null }),
onUpdate
}: { userFilter: { [k: string]: any }; onUpdate: () => void } = $props();
</script>
<form class="flex flex-row justify-center items-center space-x-4 my-2 w-full">
<div class="w-full">
<Input
size="sm"
placeholder="..."
bind:value={userFilter.name}
pickyWidth={false}
oninput={onUpdate}
>
{#snippet label()}
<span>Username</span>
{/snippet}
</Input>
</div>
<div class="w-full">
<Select
label="Edition"
size="sm"
bind:value={userFilter.playertype}
pickyWidth={false}
onChange={onUpdate}
>
<option value={null}>Alle</option>
<option value="java">Java</option>
<option value="bedrock">Bedrock</option>
<option value="noauth">Noauth</option>
</Select>
</div>
</form>

View File

@ -0,0 +1,135 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import { env } from '$env/dynamic/public';
import Select from '$lib/components/Input/Select.svelte';
import { errorMessage } from '$lib/stores';
import { getPopupModalShowFn } from '$lib/context';
let {
onSubmit
}: {
onSubmit: ({
firstname,
lastname,
birthday,
telephone,
username,
playertype
}: {
firstname: string;
lastname: string;
birthday: string;
telephone: string;
username: string;
playertype: string;
}) => void;
} = $props();
let showPopupModal = getPopupModalShowFn();
let firstname: string = $state('');
let lastname: string = $state('');
let birthday: string = $state('');
let phone: string = $state('');
let username: string = $state('');
let playertype = $state('java');
async function newUser() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'PUT',
body: JSON.stringify({
firstname: firstname,
lastname: lastname,
birthday: birthday,
telephone: phone,
username: username,
playertype: playertype
})
});
if (response.ok) {
onSubmit({
firstname: firstname,
lastname: lastname,
birthday: birthday,
telephone: phone,
username: username,
playertype: playertype
});
globalCloseForm.submit();
} else {
$errorMessage = (await response.json()).message;
}
}
let globalCloseForm: HTMLFormElement;
let reportForm: HTMLFormElement;
</script>
<form method="dialog" class="modal-box" bind:this={reportForm}>
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}>✕</button
>
<h3 class="font-roboto text-3xl">Neuer Spieler</h3>
<div class="grid grid-cols-2 gap-4">
<Input type="text" required bind:value={firstname}>
{#snippet label()}
<span>Vorname</span>
{/snippet}
</Input>
<Input type="text" required bind:value={lastname}>
{#snippet label()}
<span>Nachname</span>
{/snippet}
</Input>
<Input type="date" required bind:value={birthday}>
{#snippet label()}
<span>Geburtstag</span>
{/snippet}
</Input>
<Input type="tel" bind:value={phone}>
{#snippet label()}
<span>Telefonnummer</span>
{/snippet}
</Input>
<Input type="text" required bind:value={username}>
{#snippet label()}
<span>Minecraft-Spielername</span>
{/snippet}
</Input>
<Select required label="Edition" bind:value={playertype}>
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
</Select>
</div>
<div class="flex flex-row space-x-2 mt-6">
<Input
type="submit"
value="Hinzufügen"
onclick={(e) => {
if (reportForm.checkValidity()) {
e.preventDefault();
showPopupModal({
title: 'Spieler hinzufügen?',
actions: [{ text: 'Hinzufügen', action: newUser }, { text: 'Abbrechen' }]
});
}
}}
/>
<Input
type="submit"
value="Abbrechen"
onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}
/>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
<button>close</button>
</form>

View File

@ -0,0 +1,52 @@
import { z } from 'zod';
export const UserListSchema = z.object({
limit: z.number().nullish(),
from: z.number().nullish(),
name: z.string().nullish(),
playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(),
search: z.string().nullish(),
slim: z.boolean().nullish(),
sort: z
.object({
key: z.enum([
'firstname',
'lastname',
'birthday',
'telephone',
'username',
'playertype',
'password',
'uuid'
]),
asc: z.boolean()
})
.nullish()
});
export const UserEditSchema = z.object({
id: z.number(),
firstname: z.string().nullish(),
lastname: z.string().nullish(),
birthday: z.coerce.date().nullish(),
telephone: z.string().nullish(),
username: z.string().nullish(),
playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(),
uuid: z.string().nullish()
});
export const UserAddSchema = z.object({
firstname: z.string(),
lastname: z.string(),
birthday: z.coerce.date(),
telephone: z.string().nullish(),
username: z.string(),
playertype: z.enum(['java', 'bedrock', 'noauth'])
});
export const UserDeleteSchema = z.object({
id: z.number()
});

View File

@ -0,0 +1,49 @@
import type { RequestHandler } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { FeedbackAddSchema } from './schema';
import { Feedback, User } from '$lib/server/database';
import crypto from 'crypto';
import type { CreationAttributes } from 'sequelize';
import { env as public_env } from '$env/dynamic/public';
export const POST = (async ({ request, url }) => {
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 });
const parseResult = await FeedbackAddSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
const feedback = {} as { [k: string]: CreationAttributes<Feedback> };
for (const user of await User.findAll({
where: { uuid: data.users },
attributes: ['id', 'uuid']
})) {
feedback[user.uuid!] = {
url_hash: crypto.randomBytes(18).toString('hex'),
event: data.event,
title: data.title ?? null,
draft: true,
user_id: user.id
};
}
await Feedback.bulkCreate(Object.values(feedback));
return new Response(
JSON.stringify(
Object.entries(feedback).reduce(
(curr, [k, v]) => {
curr[k] = `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/feedback/${
v.url_hash
}`;
return curr;
},
{} as { [k: string]: string }
)
),
{ status: 201 }
);
}) satisfies RequestHandler;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const FeedbackAddSchema = z.object({
event: z.string(),
title: z.string().nullish(),
users: z.array(z.string())
});

View File

@ -0,0 +1,95 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Report, User } from '$lib/server/database';
import * as crypto from 'crypto';
import { env as public_env } from '$env/dynamic/public';
import { env } from '$env/dynamic/private';
import { ReportAddSchema } from './schema';
export const GET = (async ({ url }) => {
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 });
const user = await User.findOne({ where: { uuid: url.searchParams.get('uuid') ?? '' } });
if (user === null) return new Response(null, { status: 400 });
const reports = {
from_self: await Report.findAll({
where: { reporter_id: user.id },
include: [{ model: User, as: 'reported' }]
}).then((reports) =>
reports.map((report) => {
return {
reported: report.reported
? {
username: report.reported.username,
uuid: report.reported.uuid
}
: null,
subject: report.subject,
draft: report.draft,
status: report.status,
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}`
};
})
),
to_self: await Report.findAll({
where: { reported_id: user.id },
include: [{ model: User, as: 'reporter' }]
}).then((reports) =>
reports.map((report) => {
return {
reporter: {
username: report.reporter.username,
uuid: report.reporter.uuid
},
subject: report.subject,
draft: report.draft,
status: report.status,
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}`
};
})
)
};
return new Response(JSON.stringify(reports), { status: 200 });
}) satisfies RequestHandler;
export const POST = (async ({ request, url }) => {
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 });
const parseResult = await ReportAddSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
const reporter = await User.findOne({ where: { uuid: data.reporter } });
const reported = data.reported
? await User.findOne({ where: { uuid: data.reported } })
: undefined;
if (reporter == null || reported === null) return new Response(null, { status: 400 });
const report = await Report.create({
subject: data.reason,
body: null,
draft: true,
status: 'none',
url_hash: crypto.randomBytes(18).toString('hex'),
completed: false,
reporter_id: reporter.id,
reported_id: reported?.id || null
});
return new Response(
JSON.stringify({
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${
report.url_hash
}`
}),
{
status: 201
}
);
}) satisfies RequestHandler;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const ReportAddSchema = z.object({
reporter: z.string(),
reported: z.string().nullish(),
reason: z.string()
});

View File

@ -0,0 +1,56 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Report, sequelize, StrikePunishment, StrikeReason, User } from '$lib/server/database';
import { env } from '$env/dynamic/private';
import { Op } from 'sequelize';
export const GET = (async ({ url }) => {
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 });
const uuid = url.searchParams.get('uuid');
if (uuid == null) return new Response(null, { status: 400 });
const user = await User.findOne({ where: { uuid: uuid } });
if (user == null) return new Response(null, { status: 404 });
const query = (await Report.findOne({
where: { reported_id: user.id },
attributes: [
[sequelize.fn('SUM', sequelize.col('strike_reason.weight')), 'weight'],
[sequelize.fn('MAX', sequelize.col('striked_at')), 'striked_at']
],
include: { model: StrikeReason, as: 'strike_reason' }
})) as { dataValues: { weight: number | null; striked_at: Date | null } } | null;
const ban_time = (
await StrikePunishment.findOne({
attributes: ['punishment_in_seconds'],
where: { type: ['ban'], weight: { [Op.lte]: query?.dataValues.weight } },
order: [['weight', 'DESC']]
})
)?.punishment_in_seconds;
const outlawed_time = (
await StrikePunishment.findOne({
attributes: ['punishment_in_seconds'],
where: { type: ['outlawed'], weight: { [Op.lte]: query?.dataValues.weight } },
order: [['weight', 'DESC']]
})
)?.punishment_in_seconds;
return new Response(
JSON.stringify({
uuid: user.uuid,
username: user.username,
firstname: user.firstname,
lastname: user.lastname,
banned_until:
query && query.dataValues.striked_at && ban_time
? Math.round(query.dataValues.striked_at.getTime() / 1000 + ban_time)
: null,
outlawed_until:
query && query.dataValues.striked_at && outlawed_time
? Math.round(query.dataValues.striked_at.getTime() / 1000 + outlawed_time)
: null
}),
{ status: 200 }
);
}) satisfies RequestHandler;

View File

@ -0,0 +1,3 @@
<div class="mx-4 my-6 sm:mx-24 sm:my-12">
<slot />
</div>

213
src/routes/faq/+page.svelte Normal file
View File

@ -0,0 +1,213 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
let faq = [
{
section: 'Allgemein',
questions: [
{
title: 'Wie kann ich einen Admin kontaktieren?',
content: `<p>Einen Admin kannst du im Chat, über WhatsApp, per Teamspeak (<a class="link" href="${env.PUBLIC_TS_LINK}">mhsl.eu</a>) oder Discord (<a class="link" href="https://discord.gg/EBGefWPc2K" target="_blank">https://discord.gg/EBGefWPc2K</a>) kontaktieren.</p>`
},
{
title: 'Wer ist eigentlich Organisator und warum?',
content: `<p>Wir sind ein kleines Team von Minecraft-Enthusiasten, das bereits im siebten Jahr in Folge
Minecraft CraftAttack organisiert. Jedes Jahr arbeiten wir daran, das Spielerlebnis zu
verbessern und die Teilnehmerzahl zu steigern. Weitere Infos findest du auf der Teamseite.</p>`
},
{
title: 'Wie lange bleibt der Server online?',
content: `<p>Der Server wird traditionell so lange online bleiben, wie noch aktiv darauf gespielt wird.</p>`
},
{
title: 'Warum benötigt ihr meine Daten bei der Anmeldung?',
content: `<p>Deine Daten werden nur intern gespeichert und dienen den Admins rein zur Organisation
des Projekts.</p>`
},
{
title: 'Gibt es einen Teamspeak-Server?',
content: `<p>Ja, den offiziellen Teamspeak-Server erreichst du unter der IP <a class="link" href="${env.PUBLIC_TS_LINK}">mhsl.eu</a>.</p>`
},
{
title: 'Gibt es einen Discord-Server?',
content: `<p>Ja, den offiziellen Discord-Server erreichst du unter <a class="link" href="${env.PUBLIC_DISCORD_LINK}" target="_blank">${env.PUBLIC_DISCORD_LINK}</a>.</p>`
},
{
title: 'Wozu dient die CraftAttack-WhatsApp-Gruppe?',
content: `<p>In der WhatsApp-Gruppe erhältst du alle wichtigen Infos bezüglich CraftAttack.</p>`
}
]
},
{
section: 'Anmeldung',
questions: [
{
title: 'Wann startet CraftAttack 7?',
content: `<p>Der Start von CraftAttack 7 findet gemeinsam am 27.12.2024 um 14:00 Uhr statt. Am besten
bist du schon einige Minuten vorher auf dem Server. Natürlich kannst du aber auch danach
jederzeit dazustoßen.</p>`
},
{
title: 'Wer kann alles mitspielen?',
content: `<p>Jeder, der entweder Minecraft Java oder Bedrock (Handy und Konsole) besitzt und
mindestens 6 Jahre alt ist, kann mitspielen.</p>`
},
{
title: 'Wie kann ich mitspielen?',
content: `<p>Um mitzuspielen, musst du dich einfach hier auf der Website anmelden und der WhatsApp-
Gruppe beitreten.</p>`
},
{
title: 'Auf welcher Version läuft der Server?',
content: `<p>Gespielt wird immer auf der neuesten Version, also laut aktuellem Stand Version 1.21.4.</p>`
},
{
title: 'Kann ich auch als Bedrock-Spieler (Handy oder Konsole) mitspielen?',
content: `<p>Ja, auch als Bedrock-Spieler kannst du mitspielen, sofern du anderen Servern beitreten kannst.</p>`
},
{
title: 'Ich kann mich nicht anmelden, was kann ich tun?',
content: `<p>Wenn du dich nicht anmelden kannst, solltest du Folgendes überprüfen:</p>
<ol class="list-decimal pl-8 py-3">
<li>Ist dein Spielername korrekt geschrieben?</li>
<li>Hast du dich bereits angemeldet? Es ist nur ein Account pro Spieler erlaubt.</li>
<li>Hast du die richtige Spieledition ausgewählt?</li>
</ol>
<p>Falls du dich aus unerklärlichen Gründen trotzdem nicht anmelden kannst, kannst du
dich jederzeit beim Admin-Team melden.</p>`
},
{
title: 'Ich komme nicht auf den Server, was kann ich tun?',
content: `<p>Wenn du dem Server nicht beitreten kannst, überprüfe Folgendes:</p>
<ol class="list-decimal pl-8 py-3">
<li>Hast du die korrekte IP verwendet? Sie lautet <span class="underline italic">${env.PUBLIC_SERVER_IP}</span>.</li>
<li>Hast du Leerzeichen verwendet, insbesondere vor oder hinter der IP, oder dich vertippt?</li>
<li>Kommst du auf andere Server, oder ist es nur ein Problem beim CraftAttack-Server?</li>
<li>Hast du dich korrekt auf der Webseite angemeldet?</li>
</ol>
<p>Falls du trotzdem nicht beitreten kannst, melde dich beim Admin-Team und halte die
Fehlermeldung bereit.</p>`
},
{
title: 'Was ist die Server-IP?',
content: `<p>Die Serveradresse lautet: <span class="underline italic">${env.PUBLIC_SERVER_IP}</span>.</p>`
},
{
title: 'Ist es kostenlos mitzuspielen?',
content: `<p>Ja, die Teilnahme ist selbstverständlich kostenlos.${
env.PUBLIC_PAYPAL_LINK
? `Wir freuen uns aber, wenn du das Projekt mit einer Spende nach der Anmeldung unterstützen würdest.<br>
Hier kannst du für das Projekt spenden: <a class="link" href=${env.PUBLIC_PAYPAL_LINK} target="_blank">${env.PUBLIC_PAYPAL_LINK}</a>.`
: ''
}</p>`
},
{
title:
'Die Anmeldefrist ist vorbei, aber ich möchte mich trotzdem noch anmelden. Was kann ich tun?',
content: `<p>Generell solltest du dich immer während des Anmeldezeitraums anmelden. Falls die
Anmeldung allerdings bereits geschlossen ist, kannst du einen Admin kontaktieren, der dich
im Fall der Fälle noch nachträglich anmelden kann.</p>`
},
{
title: 'Ist ein 2. Account erlaubt?',
content: `<p>Nein, pro Teilnehmer ist nur ein Account zugelassen.</p>`
}
]
},
{
section: 'Ingame',
questions: [
{
title: 'Wo kann ich meinen Shop errichten?',
content: `<p>Generell darfst du Shops überall errichten, aber es bietet sich an, alle Shops in einem
Shopping-District nahe des Spawns anzusiedeln.</p>`
},
{
title: 'Sind Farmen erlaubt?',
content: `<p>Ja, Farmen sind generell erlaubt. Allerdings sind lag-erzeugende Maschinen, Farmen (Zero-
Tick-Farmen etc.) oder andere Bauten, die den Spielfluss stören könnten, verboten.</p>`
},
{
title: 'Was und wann sind Events?',
content: `<p>Abends, meist gegen 18 Uhr, finden gelegentlich Events statt, bei denen du Items gewinnen
kannst und in kleinen Minispielen gegen deine Mitspieler antrittst. Die genauen Abläufe
siehst du, wenn du abends auf dem Server bist.</p>`
},
{
title: 'Wo und wie kann ich einen Regelverstoß melden?',
content: `<p>Wenn du einen Regelverstoß melden willst, kannst du ingame den Befehl <code>/report</code> nutzen, um
einen Admin zu kontaktieren.</p>`
},
{
title: 'Was hat es mit dem Blutmond auf sich?',
content: `<p>Alle dreißig ingame-Tage solltest du nachts auf der Hut sein, denn die Monster sind in dieser
Nacht deutlich stärker als üblich, droppen aber auch besseren Loot.</p>`
},
{
title: 'Was hat es mit dem Vogelfrei-Modus auf sich?',
content: `<p>CraftAttack ist grundsätzlich ein friedliches Projekt. Falls du jedoch kein Problem damit hast,
angegriffen zu werden, kannst du dich mit <code>/vogelfrei</code> in den Vogelfrei-Modus setzen.
Dadurch sehen andere Spieler, dass du für einen Kampf offen bist. Der Vogelfrei-Modus kann
allerdings erst nach einigen Stunden wieder beendet werden.</p>`
},
{
title: 'Was hat es mit dem Rang „Langzeitspieler“ auf sich?',
content: `<p>Spieler, die seit über drei Jahren am Projekt teilnehmen, erhalten den Langzeitrang. Dieser
wirkt sich allerdings nicht auf das Spielgeschehen aus.</p>`
},
{
title: 'Was gibt es für neue Features?',
content: `<ul class="list-disc pl-8">
<li>Miniböcke, die du selbst gestalten kannst</li>
<li>Neue Event-Spiele</li>
<li>Einige Quality-of-Life-Features, die du mit <code>/settings</code> erreichst</li>
<li>Langzeitrang</li>
</ul>`
},
{
title: 'Wann wird das End geöffnet?',
content: `<p>Das End wird gemeinsam am 03.01.2025 um 19:00 Uhr geöffnet, und wir besiegen
gemeinsam den Enderdrachen.</p>`
},
{
title: 'Darf ich andere Spieler töten?',
content: `<p>Andere Spieler zu töten ist generell verboten. Wenn es jedoch nur zum Spaß und mit dem
anderen Spieler abgesprochen ist, haben wir nichts dagegen einzuwenden. Außerdem ist es
erlaubt, vogelfreie Spieler zu töten.</p>`
},
{
title: 'Welche Minecraft-Clients sind erlaubt?',
content: `<p>Jegliche Clientmodifikationen, die deutliche Vorteile gegenüber anderen Spielern bringen,
sind nicht gestattet.</p>`
}
]
}
];
</script>
<svelte:head>
<title>Craftattack - FAQ</title>
<meta property="og:title" content="Craftattack - FAQ" />
</svelte:head>
<h1 class="text-3xl lg:text-5xl mb-16 text-center">FAQ</h1>
<div class="grid lg:grid-cols-2 2xl:grid-cols-3 gap-10">
{#each faq as questions}
<div>
<h2 class="text-4xl text-center mb-3">{questions.section}</h2>
<div>
{#each questions.questions as question}
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" />
<div class="collapse-title">{question.title}</div>
<div class="collapse-content">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="ml-2">{@html question.content}</div>
</div>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
{/each}
</div>
</div>
{/each}
</div>

View File

@ -0,0 +1,5 @@
<div class="flex justify-center items-center w-full">
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
<slot />
</div>
</div>

View File

@ -0,0 +1,82 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import Textarea from '$lib/components/Input/Textarea.svelte';
import { env } from '$env/dynamic/public';
import Select from '$lib/components/Input/Select.svelte';
import { getPopupModalShowFn } from '$lib/context';
let showPopupModal = getPopupModalShowFn();
let content = $state('');
let type = $state('feedback');
async function submitFeedback() {
await fetch(`${env.PUBLIC_BASE_PATH}/feedback`, {
method: 'POST',
body: JSON.stringify({
content: content,
type: type
})
});
}
</script>
<svelte:head>
<title>Feedback & Kontakt</title>
</svelte:head>
<div>
<h2 class="text-3xl text-center">Feedback & Kontakt</h2>
<form
onsubmit={(e) => {
e.preventDefault();
showPopupModal({
title: `${type === 'feedback' ? 'Feedback' : 'Kontaktanfrage'} abschicken?`,
text:
type === 'feedback'
? 'Nach dem Abschicken des Feedbacks lässt es sich nicht mehr bearbeiten.'
: 'Bitte hinterlege eine Rückmeldemöglichkeit in deiner Anfrage. Nachdem sie abgeschickt wurde, kannst du die Nachricht nicht mehr bearbeiten.',
actions: [
{
text: 'Abschicken',
action: () =>
setTimeout(async () => {
await submitFeedback();
showPopupModal({
title: `${type === 'feedback' ? 'Feedback' : 'Kontaktanfrage'} abgeschickt`,
text:
type === 'feedback'
? 'Dein Feedback wurde abgeschickt.'
: 'Deine Kontaktanfrage wurde abgeschickt. Jemand aus dem Team wird sich nächstmöglich bei Dir melden.',
actions: [{ text: 'Schließen' }],
onClose: () => {
content = '';
type = 'feedback';
}
});
}, 200)
},
{ text: 'Abbrechen' }
]
});
}}
>
<div class="space-y-4 mt-6 mb-4">
<Select size="sm" bind:value={type}>
<option value="feedback">Feedback</option>
<option value="contact">Kontakt</option>
</Select>
<Textarea
required={true}
rows={4}
label={type === 'feedback' ? 'Feedback' : 'Anfrage'}
bind:value={content}
/>
<div>
<Input type="submit" disabled={type === '' || content === ''} value="Senden" />
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,20 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Feedback } from '$lib/server/database';
import { FeedbackSubmitSchema } from './schema';
import crypto from 'crypto';
export const POST = (async ({ request }) => {
const parseResult = await FeedbackSubmitSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
await Feedback.create({
event: `website-${data.type}`,
content: data.content,
url_hash: crypto.randomBytes(18).toString('hex')
});
return new Response(null, { status: 200 });
}) satisfies RequestHandler;

View File

@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { Feedback } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
export const load: PageServerLoad = async ({ params }) => {
const feedback = await Feedback.findOne({
where: { url_hash: params.url_hash }
});
if (!feedback) throw redirect(302, `${env.PUBLIC_BASE_PATH}/`);
return {
draft: feedback.content === null,
title: feedback.title,
anonymous: feedback.user_id === null
};
};

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import FeedbackDraft from './FeedbackDraft.svelte';
import FeedbackSent from './FeedbackSent.svelte';
let { data } = $props();
let draft = $state(data.draft);
</script>
<svelte:head>
<title>Feedback</title>
<!-- just in case... -->
<meta name="robots" content="noindex" />
</svelte:head>
{#if draft}
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
<FeedbackDraft title={data.title} anonymous={data.anonymous} onSubmit={() => (draft = false)} />
</div>
{:else}
<div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}>
<FeedbackSent />
</div>
{/if}

View File

@ -0,0 +1,24 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Feedback } from '$lib/server/database';
import { FeedbackSubmitSchema } from './schema';
export const POST = (async ({ request, params }) => {
const feedback = await Feedback.findOne({ where: { url_hash: params.url_hash } });
if (feedback == null) return new Response(null, { status: 400 });
const parseResult = await FeedbackSubmitSchema.safeParseAsync(await request.json());
if (!parseResult.success) {
return new Response(null, { status: 400 });
}
const data = parseResult.data;
feedback.content = data.content;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (data.anonymous) feedback.user_id = null;
await feedback.save();
return new Response(null, { status: 200 });
}) satisfies RequestHandler;

View File

@ -0,0 +1,76 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import Textarea from '$lib/components/Input/Textarea.svelte';
import { env } from '$env/dynamic/public';
import { page } from '$app/stores';
import { getPopupModalShowFn } from '$lib/context';
let {
title,
anonymous,
onSubmit
}: { title: string | null; anonymous: boolean; onSubmit?: () => void } = $props();
let showPopupModal = getPopupModalShowFn();
let content = $state('');
let sendAnonymous = $state(false);
async function submitFeedback() {
await fetch(`${env.PUBLIC_BASE_PATH}/feedback/${$page.params.url_hash}`, {
method: 'POST',
body: JSON.stringify({
content: content,
anonymous: sendAnonymous
})
});
}
</script>
<div>
<h2 class="text-3xl text-center">Feedback</h2>
<form
onsubmit={(e) => {
e.preventDefault();
showPopupModal({
title: 'Feedback abschicken?',
text: 'Nach dem Abschicken des Feedbacks lässt es sich nicht mehr bearbeiten.',
actions: [
{
text: 'Abschicken',
action: async () => {
await submitFeedback();
onSubmit?.();
}
},
{ text: 'Abbrechen' }
]
});
}}
>
<div class="space-y-4 my-4">
{#if title}
<Input size="sm" pickyWidth={false} disabled value={title}>
{#snippet label()}
<span>Event</span>
{/snippet}
</Input>
{/if}
<Textarea required={true} rows={4} label="Feedback" bind:value={content} />
{#if !anonymous}
<div class="flex items-center gap-2 mt-2">
<Input type="checkbox" id="anonymous" size="xs" bind:checked={sendAnonymous} />
<label
for="anonymous"
title="Dein Spielername wird nach dem Abschicken nicht mit dem Feedback zusammen gespeichert"
>Anonym senden</label
>
</div>
{/if}
<div>
<Input type="submit" disabled={content === ''} value="Feedback senden" />
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,4 @@
<div>
<h2 class="text-2xl text-center">Feedback abgeschickt</h2>
<p class="mt-4">Das Feedback wurde abgeschickt.</p>
</div>

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FeedbackSubmitSchema = z.object({
content: z.string(),
anonymous: z.boolean()
});

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FeedbackSubmitSchema = z.object({
content: z.string(),
type: z.enum(['feedback', 'contact'])
});

View File

@ -1,3 +1,3 @@
<div class="flex justify-center w-full">
<div class="flex justify-center w-full min-h-screen bg-base-200">
<slot />
</div>

View File

@ -0,0 +1,13 @@
import type { PageServerLoad } from './$types';
import { Settings } from '$lib/server/database';
export const load: PageServerLoad = async () => {
return {
enabled: (await Settings.findOne({ where: { key: 'register.enabled' } }))?.value ?? true,
disabled_title:
(await Settings.findOne({ where: { key: 'register.disabled_title' } }))?.value ??
'Anmeldung geschlossen',
disabled_details:
(await Settings.findOne({ where: { key: 'register.disabled_details' } }))?.value ?? ''
};
};

View File

@ -3,25 +3,61 @@
import RegistrationComplete from './RegistrationComplete.svelte';
import Register from './Register.svelte';
let registered = false;
let { data } = $props();
let registered = $state(false);
let firstname: string | null = $state(null);
let lastname: string | null = $state(null);
let birthday: Date | null = $state(null);
let phone: string | null = $state(null);
let username: string | null = $state(null);
let edition: string | null = $state(null);
</script>
<svelte:head>
<title>Craftattack - Anmeldung</title>
<meta property="og:title" content="Craftattack - Anmeldung" />
</svelte:head>
<!--the tooltip when not all fields are correctly filled won't completely show if the overflow is hidden-->
<div
class="absolute top-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg"
class="relative grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 my-12 bg-base-100 shadow-lg h-min"
class:overflow-hidden={registered}
>
{#if !data.enabled}
<div
class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 backdrop-blur-sm z-10 rounded-xl flex lg:justify-center items-center flex-col pt-20 lg:pt-0"
>
<h1 class="text-2xl sm:text-3xl md:text-5xl text-white">{data.disabled_title}</h1>
<h3>{data.disabled_details}</h3>
</div>
{/if}
{#if !registered}
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
<Register on:submit={() => (registered = true)} />
<Register
submit={(e) => {
registered = true;
firstname = e.firstname;
lastname = e.lastname;
birthday = e.birthday;
phone = e.phone;
phone = e.phone;
username = e.username;
edition = e.edition;
}}
/>
</div>
{:else}
<div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}>
<RegistrationComplete on:close={() => (registered = false)} />
<RegistrationComplete
{firstname}
{lastname}
{birthday}
{phone}
{username}
{edition}
close={() => (registered = false)}
/>
</div>
{/if}
</div>

View File

@ -1,47 +1,100 @@
import {
getBedrockUuid,
getCrackedUuid,
ApiError,
getJavaUuid,
getNoAuthUuid,
RateLimitError,
UserNotFoundError
} from '$lib/server/minecraft';
import { error, type RequestHandler } from '@sveltejs/kit';
import { User } from '$lib/server/database';
import { Settings, User } from '$lib/server/database';
import { RegisterSchema } from './schema';
export const POST = (async ({ request }) => {
const data = await request.formData();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if ((await Settings.findOne({ where: { key: 'register.enabled' } }))?.value === false) {
throw error(400, 'Anmeldung geschlossen');
}
let uuid: string;
try {
// available playertypes are 'java', 'bedrock' and 'cracked'
switch (data.get('playertype')) {
// eslint-disable-next-line no-var
var data = RegisterSchema.parse(await request.json());
} catch (e) {
console.error(e);
throw error(400, 'Ungültige Parameter');
}
let uuid: string | null;
try {
switch (data.playertype) {
case 'java':
uuid = await getJavaUuid(data.get('username') as string);
uuid = await getJavaUuid(data.username);
break;
case 'bedrock':
uuid = await getBedrockUuid(data.get('username') as string);
uuid = null;
// uuid = await getBedrockUuid(username);
break;
case 'cracked':
uuid = getCrackedUuid(data.get('username') as string);
case 'noauth':
uuid = getNoAuthUuid(data.username);
break;
default:
throw new Error(`invalid player type (${data.get('playertype')})`);
}
} catch (e) {
if (e instanceof UserNotFoundError) {
throw error(400, e.message);
throw error(
400,
"Der Spielername '" +
data.username +
"' existiert nicht. Hast Du Deinen Spielernamen korrekt geschrieben " +
'und besitzt Du einen Minecraft-Account?\n\nKontaktiere bitte einen Admin, falls Du Dich trotz korrekter ' +
'Angabe nicht registrieren kannst.'
);
} else if (e instanceof ApiError) {
console.error((e as Error).message);
uuid = null;
} else if (e instanceof RateLimitError) {
console.error(`uuid request rate limited for user '${data.username}'`);
uuid = null;
} else {
console.error((e as Error).message);
throw error(500);
}
console.error((e as Error).message);
return new Response();
}
if (
(uuid && (await User.findOne({ where: { uuid: uuid } }))) ||
(!uuid && (await User.findOne({ where: { username: data.username } })))
) {
throw error(
400,
'Dein Minecraft-Account wurde bereits registriert.\n\nKontaktiere bitte einen Admin, falls diese ' +
'Informationen für Dich fehlerhaft erscheinen oder Du Angaben Deiner bestehenden Registrierung verändern ' +
'möchtest.'
);
} else if (
await User.findOne({
where: {
firstname: data.firstname,
lastname: data.lastname,
birthday: data.birthday.toUTCString()
}
})
) {
throw error(
400,
'In Deinem Namen wurde bereits ein Minecraft-Account registriert. Es ist nur ein Account pro Spieler ' +
'erlaubt.\n\nKontaktiere bitte einen Admin, falls diese Informationen für Dich fehlerhaft erscheinen oder ' +
'Du Angaben Deiner bestehenden Registrierung verändern möchtest.'
);
}
await User.create({
firstname: data.get('firstname'),
lastname: data.get('lastname'),
birthday: data.get('birthday'),
telephone: data.get('telephone'),
username: data.get('username'),
playertype: data.get('playertype'),
password: data.get('password'),
firstname: data.firstname,
lastname: data.lastname,
birthday: data.birthday.toUTCString(),
telephone: data.telephone,
username: data.username,
playertype: data.playertype,
password: null,
uuid: uuid
});

View File

@ -1,22 +1,57 @@
<script lang="ts">
import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { rules } from '$lib/rules';
import { rulesShort } from '$lib/rules';
import { RegisterSchema } from './schema';
import { dev } from '$app/environment';
import { getPopupModalShowFn } from '$lib/context';
const dispatch = createEventDispatcher();
let {
submit
}: {
submit: ({
firstname,
lastname,
birthday,
phone,
username,
edition
}: {
firstname: string;
lastname: string;
birthday: Date;
phone: string;
username: string;
edition: string;
}) => void;
} = $props();
// eslint-disable-next-line @typescript-eslint/no-empty-function
let checkInputs = () => {};
let playertype = 'java';
let firstnameInput: HTMLInputElement;
let lastnameInput: HTMLInputElement;
let birthdayInput: HTMLInputElement;
let usernameInput: HTMLInputElement;
let privacyInput: HTMLInputElement;
let logsInput: HTMLInputElement;
let rulesInput: HTMLInputElement;
let showPopupModal = getPopupModalShowFn();
const modalTimeoutSeconds = dev ? 0 : 30;
let checkInputs = $state(() => {});
let playertype = $state('java');
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
let firstnameInput: HTMLInputElement = $state();
// @ts-ignore
let lastnameInput: HTMLInputElement = $state();
// @ts-ignore
let birthdayInput: HTMLInputElement = $state();
// @ts-ignore
let phoneInput: HTMLInputElement = $state();
// @ts-ignore
let usernameInput: HTMLInputElement = $state();
// @ts-ignore
let privacyInput: HTMLInputElement = $state();
// @ts-ignore
let logsInput: HTMLInputElement = $state();
// @ts-ignore
let rulesInput: HTMLInputElement = $state();
/* eslint-enable @typescript-eslint/ban-ts-comment */
onMount(() => {
checkInputs = () => {
let allInputs = [
@ -38,31 +73,69 @@
async function sendRegister() {
// eslint-disable-next-line no-async-promise-executor
registerRequest = new Promise(async (resolve, reject) => {
registerRequest = new Promise<void>(async (resolve, reject) => {
const parseResult = RegisterSchema.safeParse(
Object.fromEntries(new FormData(document.forms[0]))
);
if (!parseResult.success) {
reject(Error(parseResult.error.issues.map((i) => i.message).join('\n')));
return;
}
const response = await fetch(`${env.PUBLIC_BASE_PATH}/register`, {
method: 'POST',
body: new FormData(document.forms[0])
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
});
if (response.ok) {
dispatch('submit', {});
submit({
firstname: firstnameInput.value,
lastname: lastnameInput.value,
birthday: birthdayInput.valueAsDate!,
phone: phoneInput.value,
username: usernameInput.value,
edition: playertype == 'java' ? 'Java (PC)' : 'Bedrock (Konsolen und Handys)'
});
resolve();
} else if (response.status < 500) {
reject(Error((await response.json()).message));
} else {
reject(Error(`${response.statusText} (${response.status})`));
}
}).catch((e) => {
errorMessage = (e as Error).message;
registerRequest = null;
});
}
let rulesAccepted = false;
let rulesModal: HTMLDialogElement | null = null;
let rulesModal: HTMLDialogElement;
let rulesModalSecondsOpened = $state(0);
// eslint-disable-next-line no-undef
let rulesModalTimer: number | NodeJS.Timeout | undefined = undefined;
let inputsInvalidMessage: string | null = 'Bitte fülle alle erforderlichen Felder aus';
let registerRequest: Promise<void> | null = null;
let inputsInvalidMessage: string | null = $state('Bitte fülle alle erforderlichen Felder aus');
let registerRequest: Promise<void> | null = $state(null);
let errorMessage: string = $state('');
$effect(() => {
if (!errorMessage) return;
showPopupModal({
title: 'Fehler',
text: errorMessage
});
});
</script>
<h1 class="text-center text-3xl lg:text-5xl">Anmeldung</h1>
<form id="form" on:input={checkInputs} on:submit|preventDefault={sendRegister}>
<form
id="form"
oninput={checkInputs}
onsubmit={(e) => {
e.preventDefault();
sendRegister();
}}
>
<div class="divider">Persönliche Angaben</div>
<div class="mx-2 grid grid-cols-1 sm:grid-cols-2 gap-y-4">
<Input
@ -72,7 +145,9 @@
required={true}
bind:inputElement={firstnameInput}
>
<span slot="label">Vorname</span>
{#snippet label()}
<span>Vorname</span>
{/snippet}
</Input>
<Input
id="lastname"
@ -81,7 +156,9 @@
required={true}
bind:inputElement={lastnameInput}
>
<span slot="label">Nachname</span>
{#snippet label()}
<span>Nachname</span>
{/snippet}
</Input>
<Input
id="birthday"
@ -90,16 +167,30 @@
required={true}
bind:inputElement={birthdayInput}
>
<span slot="label">Geburtstag</span>
<span slot="notice">Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span>
{#snippet label()}
<span>Geburtstag</span>
{/snippet}
{#snippet notice()}
<span>Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span>
{/snippet}
</Input>
<Input id="telephone" name="telephone" type="tel">
<span slot="label">Telefonnummer</span>
<p slot="notice">
Diese nutzen wir, um Dich in der Whatsapp-Gruppe zuzuordnen und kontaktieren zu können.
<br />
<b>Die Angabe ist freiwillig, hilft den Administratoren jedoch sehr!</b>
</p>
<Input
id="telephone"
name="telephone"
type="tel"
bind:inputElement={phoneInput}
pattern={new RegExp(/^[+()\s/\d]+$/)}
>
{#snippet label()}
<span>Telefonnummer</span>
{/snippet}
{#snippet notice()}
<p>
Diese nutzen wir, um Dich in der Whatsapp-Gruppe zuzuordnen und kontaktieren zu können.
<br />
<b>Die Angabe ist freiwillig, hilft den Administratoren jedoch sehr!</b>
</p>
{/snippet}
</Input>
</div>
<div class="divider">Spiel</div>
@ -111,7 +202,9 @@
required={true}
bind:inputElement={usernameInput}
>
<span slot="label">Minecraft-Spielername</span>
{#snippet label()}
<span>Minecraft-Spielername</span>
{/snippet}
</Input>
<Select
id="playertype"
@ -120,26 +213,11 @@
bind:value={playertype}
required={true}
>
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="cracked">Java cracked</option>
<option value="java">Java Edition (PC)</option>
<option value="bedrock">Bedrock Edition (Konsolen und Handys)</option>
</Select>
{#if playertype === 'cracked'}
<div class="sm:col-span-2">
<Input id="password" name="password" type="password" required={true}>
<span slot="label">Passwort</span>
<span slot="notice">
Da Du cracked spielst, musst Du ein Passwort festlegen, mit welchem Du Dich auf dem
Server authentifizierst! Das Passwort wird im Klartext gespeichert und ist in deinen
Clientlogs sowie in Serverlogs für Admins sichtbar. Verwende daher ein neues Passwort,
welches Du nirgends sonst verwendest! Merke Dir das Passwort gut, ohne kannst Du Dich
nicht auf dem Server einloggen
</span>
</Input>
</div>
{/if}
</div>
<div class="divider" />
<div class="divider"></div>
<div class="mx-2 grid gap-y-3 mb-6">
<div class="flex gap-4">
<Input
@ -152,7 +230,9 @@
<label for="privacy">
<span>
Ich bin mit der Speicherung meiner in der Anmeldung angegebenen, persönlichen Daten
einverstanden. Siehe <a class="link" href="https://mhsl.eu/id.html">Datenschutz</a>
einverstanden. Siehe <a class="link" href="https://mhsl.eu/id.html" target="_blank"
>Datenschutz</a
>
</span>
<span class="text-red-700">*</span>
</label>
@ -165,6 +245,11 @@
persönlichen Daten durch den Server einverstanden
</span>
<span class="text-red-700">*</span>
<br />
<p class="text-[.75rem]">
Dies betrifft jede Interaktion im Spiel und zugehörige Daten wie z.B. Chatnachrichten
welche vom Minecraft Client an den Server übermittelt werden
</p>
</label>
</div>
<div class="flex gap-4">
@ -173,25 +258,26 @@
name="rules"
type="checkbox"
required={true}
on:input={(e) => {
oninput={(e) => {
if (!rulesAccepted) {
e.detail.target.checked = false;
e.currentTarget.checked = false;
rulesModal.show();
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
}
}}
bind:inputElement={rulesInput}
/>
<label for="rules">
Ich bin mit den <a target="_blank" class="link" href="{env.PUBLIC_BASE_PATH}/rules"
>Regeln</a
Ich bin mit den <button
class="link"
onclick={(e) => {
e.preventDefault();
rulesModal.show();
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
}}>Regeln</button
>
einverstanden und achte sie
<span class="text-red-700">*</span>
<br />
<p class="text-[.75rem]">
Dies betrifft jede Interaktion im Spiel und zugehörige Daten wie z.B. Chatnachrichten
welche vom Minecraft Client an den Server übermittelt werden
</p>
</label>
</div>
</div>
@ -212,32 +298,24 @@
{#await registerRequest}
<span
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
/>
{:catch error}
<dialog
class="modal"
on:close={() => setTimeout(() => (registerRequest = null), 200)}
open
>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 class="font-bold text-lg">Error</h3>
<p class="py-4">{error.message}</p>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
<button>close</button>
</form>
</dialog>
></span>
{/await}
{/if}
{/key}
</div>
</form>
<dialog class="modal" bind:this={rulesModal}>
<dialog
class="modal"
onclose={() => {
clearInterval(rulesModalTimer);
rulesModalTimer = undefined;
}}
bind:this={rulesModal}
>
<form method="dialog" class="modal-box flex max-w-[95%] md:max-w-[90%] lg:max-w-[75%]">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div class="overflow-auto">
<div class="overflow-auto mt-5">
<div class="mb-4">
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" checked />
@ -245,12 +323,12 @@
<p>0. Vorwort</p>
</div>
<div class="collapse-content">
<p>{rules.header}</p>
<p class="mt-1 text-[.75rem]">{rules.footer}</p>
<p>{rulesShort.header}</p>
<p class="mt-1 text-[.75rem]">{rulesShort.footer}</p>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600" />
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
</div>
{#each rules.sections as section, i}
{#each rulesShort.sections as section, i}
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" />
<div class="collapse-title">
@ -260,17 +338,41 @@
<p>{section.content}</p>
</div>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600" />
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
{/each}
</div>
<div>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="relative w-min"
title={rulesModalSecondsOpened < modalTimeoutSeconds
? `Regeln können in ${Math.max(
modalTimeoutSeconds - rulesModalSecondsOpened,
0
)} Sekunden akzeptiert werden`
: ''}
onclick={() => {
if (rulesModalSecondsOpened < modalTimeoutSeconds) {
errorMessage =
'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.';
}
}}
>
<div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
<div
style="width: {Math.min((rulesModalSecondsOpened / modalTimeoutSeconds) * 100, 100)}%"
class="h-full bg-base-300"
></div>
</div>
<Input
id="rules-accept"
type="submit"
value="Akzeptieren"
on:click={() => {
disabled={rulesModalSecondsOpened < modalTimeoutSeconds}
containerClass="bg-transparent z-[1] relative"
onclick={() => {
rulesAccepted = true;
rulesInput.checked = true;
checkInputs();
}}
/>
</div>

View File

@ -1,9 +1,26 @@
<script lang="ts">
import { IconSolid } from 'svelte-heros-v2';
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte';
const dispatch = createEventDispatcher();
let {
firstname,
lastname,
birthday,
phone,
username,
edition,
close
}: {
firstname: string;
lastname: string;
birthday: Date;
phone?: string;
username: string;
edition: string;
close: () => void;
} = $props();
let startDayOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
@ -14,19 +31,95 @@
hour: '2-digit',
minute: '2-digit'
};
let skin: string | null = $state(null);
onMount(async () => {
let skinview3d = await import('skinview3d');
let skinViewer = new skinview3d.SkinViewer({
width: 200,
height: 300,
renderPaused: true
});
skinViewer.camera.rotation.x = -0.62;
skinViewer.camera.rotation.y = 0.534;
skinViewer.camera.rotation.z = 0.348;
skinViewer.camera.position.x = 30.5;
skinViewer.camera.position.y = 22.0;
skinViewer.camera.position.z = 42.0;
await skinViewer.loadSkin(`https://mc-heads.net/skin/${username}`);
skinViewer.render();
skin = skinViewer.canvas.toDataURL();
skinViewer.dispose();
});
</script>
<div class="flex items-center h-12 mb-2">
<button class="sm:absolute btn btn-sm btn-square" on:click={() => dispatch('close')}>
<IconSolid name="chevron-left-solid" />
</button>
<h1 class="text-center text-xl sm:text-3xl m-auto">Registrierung erfolgreich</h1>
</div>
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
<p>
<b>Du hast dich erfolgreich für Craftattack 6 registriert</b>. Spielstart ist am {new Date(
env.PUBLIC_START_DATE
).toLocaleString('de-DE', startDayOptions)} um {new Date(env.PUBLIC_START_DATE).toLocaleString(
'de-DE',
startTimeOptions
)} Uhr.
<b>Du hast Dich erfolgreich für Craftattack 7 registriert</b>. Spielstart ist am
<span class="underline"
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', startDayOptions)}</span
>
um
<span class="underline"
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', startTimeOptions)} Uhr</span
>.
</p>
<p>Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
<p class="mt-2">
Falls du uns unterstützen möchtest, kannst du dies ganz einfach über <a
class="link"
href={env.PUBLIC_PAYPAL_LINK}
target="_blank">PayPal</a
>
tun. Antworten auf häufig gestellte Fragen findest du in unserer
<a class="link" href="{env.PUBLIC_BASE_PATH}/faq" target="_blank">FAQ</a>. Außerdem freuen wir
uns, dich auf unserem <a class="link" href={env.PUBLIC_TS_LINK} target="_blank">TeamSpeak</a> oder
in unserem <a class="link" href={env.PUBLIC_DISCORD_LINK} target="_blank">Discord</a> begrüßen zu dürfen!
</p>
<div class="divider"></div>
<div class="flex justify-around mt-2 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
<Input value={firstname} size="sm" disabled>
{#snippet label()}
<span>Vorname</span>
{/snippet}
</Input>
<Input value={lastname} size="sm" disabled>
{#snippet label()}
<span>Nachname</span>
{/snippet}
</Input>
<Input value={birthday.toISOString().substring(0, 10)} type="date" size="sm" disabled>
{#snippet label()}
<span>Geburtstag</span>
{/snippet}</Input
>
<Input value={phone} size="sm" disabled>
{#snippet label()}
<span>Telefonnummer</span>
{/snippet}
</Input>
<Input value={username} size="sm" disabled>
{#snippet label()}
<span>Spielername</span>
{/snippet}
</Input>
<Select value="edition" size="sm" disabled label="Edition">
<option value="edition">{edition}</option>
</Select>
</div>
<div class="relative hidden md:flex justify-center w-[200px] my-4">
{#if skin}
<img class="absolute" src={skin} alt="" />
{:else}
<span class="loading loading-spinner loading-lg"></span>
{/if}
</div>
</div>
<div class="divider"></div>
<div class="flex justify-center gap-8">
<button class="btn" onclick={close}>Weitere Person anmelden</button>
</div>

View File

@ -0,0 +1,25 @@
import { z } from 'zod';
export const RegisterSchema = z.object({
firstname: z
.string()
.min(
2,
'Bitte gib Deinen vollständigen Vornamen an, dieser muss mindestens aus 2 Zeichen bestehen'
),
lastname: z
.string()
.min(
2,
'Bitte gib Deinen vollständigen Nachnamen an, dieser muss mindestens aus 2 Zeichen bestehen'
),
birthday: z.coerce
.date()
.max(
new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
'Bitte gib Deinen vollständigen Geburtstag und die korrekte Jahreszahl an. Du musst mindestens 6 Jahre alt sein.'
),
telephone: z.string().optional(),
username: z.string(),
playertype: z.enum(['java', 'bedrock', 'noauth'])
});

Some files were not shown because too many files have changed in this diff Show More