326 Commits

Author SHA1 Message Date
1b391b193e Implement code changes to enhance functionality and improve performance
Some checks failed
SteamWarCI Build failed
2025-04-14 18:18:40 +02:00
c05c032e3f Fix Merge
Some checks failed
SteamWarCI Build failed
2025-04-14 18:15:05 +02:00
da6f741806 Trigger Rebuild
All checks were successful
SteamWarCI Build successful
2025-04-14 17:55:39 +02:00
6b54791331 Merge pull request 'Merge branch sw-arcade' (#9) from sw-arcade into master
Some checks failed
SteamWarCI Build failed
Reviewed-on: #9
Reviewed-by: YoyoNow <yoyonow@noreply.localhost>
2025-04-14 17:54:22 +02:00
36685bffd1 Fix wording in SteamWar Arcade event announcement for clarity
All checks were successful
SteamWarCI Build successful
2025-04-14 17:53:46 +02:00
caf9ea6cf1 Add SteamWar Arcade event image and update markdown file
All checks were successful
SteamWarCI Build successful
2025-04-14 17:48:14 +02:00
d505265910 Update sw-arcade.md with event details and correct creation date
All checks were successful
SteamWarCI Build successful
2025-04-14 17:36:20 +02:00
Lixfel
78e1a7b726 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-04-12 21:29:10 +02:00
Chaoscaot
cd485e8dda Update sw-arcade.md
All checks were successful
SteamWarCI Build successful
2025-04-06 23:07:10 +02:00
Chaoscaot
182c402c7e Update sw-arcade.md
Some checks failed
SteamWarCI Build failed
2025-04-06 23:05:35 +02:00
Chaoscaot
098f5b9270 Create page announcements/de/sw-arcade.md
Some checks failed
SteamWarCI Build failed
2025-04-06 23:04:24 +02:00
Lixfel
cf0c66c910 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-04-06 14:12:00 +02:00
c8156ea47e Set fixed height for chart and disable aspect ratio maintenance
All checks were successful
SteamWarCI Build successful
2025-04-05 15:21:36 +02:00
20a47ca6b6 Add favicon.svg to the public directory
All checks were successful
SteamWarCI Build successful
2025-04-05 15:06:10 +02:00
2d601b9c4d Update schematic stats label and remove permission check
All checks were successful
SteamWarCI Build successful
2025-04-02 09:58:11 +02:00
48586f1a50 Add error handling and improve file upload UX
All checks were successful
SteamWarCI Build successful
2025-04-02 09:52:37 +02:00
7153cacbab Merge remote-tracking branch 'origin/master'
All checks were successful
SteamWarCI Build successful
# Conflicts:
#	src/content/modes/missilewars.json
2025-04-02 09:40:05 +02:00
73cee211f2 Refactor referee management into standalone component 2025-04-02 09:39:58 +02:00
83074df7ef Refactor referee management into standalone component
Some checks failed
SteamWarCI Build failed
2025-04-02 09:21:35 +02:00
d1c926c093 Refactor referee management into standalone component
Some checks failed
SteamWarCI Build failed
2025-04-02 09:20:36 +02:00
f8a16acfeb Remove shop
All checks were successful
SteamWarCI Build successful
2025-04-02 09:00:37 +02:00
9ca63cd286 Add shop.md
All checks were successful
SteamWarCI Build successful
2025-03-31 22:30:26 +02:00
a2456c8b46 Merge remote-tracking branch 'origin/master'
All checks were successful
SteamWarCI Build successful
2025-03-31 22:28:54 +02:00
0952035091 Add shop.md 2025-03-31 22:28:51 +02:00
Lixfel
9c8c02f679 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-31 19:43:24 +02:00
Lixfel
3b5fdc57c0 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-25 06:40:14 +01:00
Lixfel
733c63946f Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-25 06:32:35 +01:00
fd846250ab Merge pull request 'Addapted script side for new example hotkey script' (#8) from add-example-script-to-downloads into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #8
Reviewed-by: Chaoscaot <max@chaoscaot.de>
2025-03-22 10:36:18 +01:00
D4rkr34lm
17460772e9 Addapted script side for new example hotkey script
All checks were successful
SteamWarCI Build successful
2025-03-21 18:54:26 +01:00
Lixfel
9a20860072 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-18 06:48:53 +01:00
8f51723a3b Fix Graf Spee name
All checks were successful
SteamWarCI Build successful
2025-03-14 13:49:36 +01:00
8ad2f283aa Fixup things
All checks were successful
SteamWarCI Build successful
2025-03-13 16:30:12 +01:00
39f1af8b73 Add MissileWars.md
All checks were successful
SteamWarCI Build successful
2025-03-13 16:27:48 +01:00
266c4cb4ea Add MissileWars ranking to Navbar.svelte
All checks were successful
SteamWarCI Build successful
2025-03-13 16:24:45 +01:00
f3df3c0000 Add ranked to Micro WarGear
All checks were successful
SteamWarCI Build successful
2025-03-13 16:05:26 +01:00
Lixfel
cb78fc598b Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-09 16:52:42 +01:00
ba7ecc1a8e Merge remote-tracking branch 'origin/master'
All checks were successful
SteamWarCI Build successful
2025-03-04 23:33:17 +01:00
6ea92f9383 Tracking and adding LFS artifacts 2025-03-04 23:33:00 +01:00
Lixfel
998770bf59 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-04 07:24:58 +01:00
a231032555 Add and standardize MissileWars translations and links
All checks were successful
SteamWarCI Build successful
2025-03-02 16:21:43 +01:00
3aa3731bcb Add MissileWars mode config and leaderboard link
All checks were successful
SteamWarCI Build successful
2025-03-02 16:18:37 +01:00
5e80c95bfd Add download link and update source URL in teamserver.json
All checks were successful
SteamWarCI Build successful
2025-03-02 16:12:47 +01:00
Lixfel
09dc28b6da Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-02 15:07:14 +01:00
Lixfel
fd7cf716ca Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-01 22:08:23 +01:00
73bd6a5e96 Fix Website
All checks were successful
SteamWarCI Build successful
2025-03-01 20:07:09 +01:00
9c02cc1f4d Fix Website
All checks were successful
SteamWarCI Build successful
2025-03-01 20:04:41 +01:00
de8457fe45 Fix Website
All checks were successful
SteamWarCI Build successful
2025-03-01 20:03:04 +01:00
4fbe01f987 New Dashboard 2025-03-01 20:01:04 +01:00
86d90e3fd2 New Dashboard 2025-03-01 20:00:46 +01:00
bccd5eb5a0 Enhance request handling with token refresh and retries
All checks were successful
SteamWarCI Build successful
2025-03-01 11:55:01 +01:00
53afe70b27 Refactor token refresh logic to streamline error handling.
All checks were successful
SteamWarCI Build successful
2025-03-01 11:34:09 +01:00
4bbdaa06a9 Refactor auth handling to improve token refresh logic
All checks were successful
SteamWarCI Build successful
2025-03-01 11:30:30 +01:00
f03867b9a7 Add retry mechanism and limit for token requests
All checks were successful
SteamWarCI Build successful
2025-03-01 11:27:06 +01:00
23e10eef0f Fix group filtering logic in FightTable.svelte
All checks were successful
SteamWarCI Build successful
2025-03-01 11:15:39 +01:00
4c72f4f26b FIx MW3 creation date
All checks were successful
SteamWarCI Build successful
2025-03-01 10:52:44 +01:00
624ba7f296 Merge pull request 'Merge branch wgs25-kampfplan' (#7) from wgs25-kampfplan into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #7
2025-03-01 10:51:19 +01:00
Lixfel
d7d20e4347 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-01 10:50:17 +01:00
Lixfel
43bd8f4a7c Update wgs25-kampfplan.md
Some checks failed
SteamWarCI Build failed
2025-03-01 10:49:03 +01:00
Lixfel
18e8627b54 Update wgs25-kampfplan.md
Some checks failed
SteamWarCI Build failed
2025-03-01 10:42:19 +01:00
Lixfel
0efc46c7e2 Create page announcements/de/wgs25-kampfplan.md
Some checks failed
SteamWarCI Build failed
2025-03-01 09:53:50 +01:00
62fff0c0b2 Merge pull request 'Refactor authentication and implement password reset.' (#3) from develop/authv2 into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #3
Reviewed-by: Lixfel <lixfel@noreply.localhost>
2025-02-25 22:39:40 +01:00
TheBreadBeard
86b479fb28 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 18:34:53 +01:00
Chaoscaot
489402292d Update adventskalender-schems.md
All checks were successful
SteamWarCI Build successful
2025-02-23 18:31:10 +01:00
b53ce04a75 Remove reset password functionality
All checks were successful
SteamWarCI Build successful
2025-02-23 17:23:45 +01:00
069a9973a4 Add Gitea link and icon to navbar layout
All checks were successful
SteamWarCI Build successful
2025-02-23 15:20:50 +01:00
c3410de1d7 Refactor event handling to use Promises for better efficiency.
All checks were successful
SteamWarCI Build successful
2025-02-23 12:25:56 +01:00
a23c514102 Revert "Refactor event mounts and update script management."
This reverts commit bf8110af6c.
2025-02-23 12:20:34 +01:00
bf8110af6c Refactor event mounts and update script management.
All checks were successful
SteamWarCI Build successful
2025-02-23 12:18:58 +01:00
349f71af1c Add event listener for "astro:before-swap" in slug page
All checks were successful
SteamWarCI Build successful
2025-02-23 12:14:11 +01:00
dda37127ca Use type import and update page load event handling.
All checks were successful
SteamWarCI Build successful
2025-02-23 09:59:37 +01:00
6d210eb0ff Merge pull request 'Merge branch missilewars-iii-eventplan' (#4) from missilewars-iii-eventplan into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #4
2025-02-23 09:49:04 +01:00
Chaoscaot
cfede8f299 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 09:47:30 +01:00
TheBreadBeard
597153ed39 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 07:43:22 +01:00
TheBreadBeard
697e903a26 Create page announcements/de/missilewars-iii-eventplan.md
Some checks failed
SteamWarCI Build failed
2025-02-23 07:21:02 +01:00
1433784369 Update auth API endpoints to remove "/v2" prefix
All checks were successful
SteamWarCI Build successful
2025-02-20 22:15:02 +01:00
2c63a33bda Refine token validation and update user stats endpoint.
All checks were successful
SteamWarCI Build successful
Extend access token validation to include a 10-second buffer to prevent potential expiry issues. Modify the user stats API call to use the base `/stats/user` endpoint for improved consistency.
2025-02-18 00:09:06 +01:00
87265e5ccc Add "Repeat Password" label to i18n and form components
All checks were successful
SteamWarCI Build successful
2025-02-17 18:32:54 +01:00
75f1a6528b Refactor authentication and implement password reset.
All checks were successful
SteamWarCI Build successful
2025-02-17 18:29:17 +01:00
TheBreadBeard
23f35a35c4 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:58:50 +01:00
TheBreadBeard
973f469c7b Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:58:04 +01:00
TheBreadBeard
107caafc26 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:57:15 +01:00
TheBreadBeard
7f26845802 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:54:28 +01:00
TheBreadBeard
37b2e82e05 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:53:28 +01:00
TheBreadBeard
7e2ba9dbce Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:50:59 +01:00
TheBreadBeard
69426da5be Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:50:11 +01:00
TheBreadBeard
fd2ad65ad4 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:46:58 +01:00
TheBreadBeard
a728651cca Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:46:04 +01:00
TheBreadBeard
b9e73ed7d0 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 21:38:36 +01:00
f1d55b3c99 Merge pull request 'Merge branch missilewars-iii-ankündigung' (#2) from missilewars-iii-ankündigung into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #2
Reviewed-by: YoyoNow <yoyonow@noreply.localhost>
2025-01-29 20:27:43 +01:00
TheBreadBeard
9b49a0f81c Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 19:46:02 +01:00
TheBreadBeard
11144043c1 Update missilewars-iii-ankündigung.md
All checks were successful
SteamWarCI Build successful
2025-01-29 19:39:12 +01:00
TheBreadBeard
e8f866ce8a Update missilewars-iii-ankündigung.md
Some checks failed
SteamWarCI Build failed
2025-01-29 19:28:01 +01:00
TheBreadBeard
e57a90feaf Create page announcements/de/missilewars-iii-ankündigung.md
Some checks failed
SteamWarCI Build failed
2025-01-29 19:19:43 +01:00
TheBreadBeard
3aa8fea1fd Update verhaltensrichtlinien.md
All checks were successful
SteamWarCI Build successful
2025-01-29 18:59:19 +01:00
TheBreadBeard
95b327951c Update verhaltensrichtlienien.md
All checks were successful
SteamWarCI Build successful
2025-01-29 18:51:55 +01:00
TheBreadBeard
86b99b4e76 Update miniwargear.md
All checks were successful
SteamWarCI Build successful
2025-01-29 18:36:59 +01:00
14b31be465 Fix i18n MircoWG
All checks were successful
SteamWarCI Build successful
2025-01-26 10:35:23 +01:00
341e629aaf Merge pull request 'Merge branch MicroRW' (#1) from MicroRW into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #1
2025-01-26 10:33:54 +01:00
Friderik
694ded4c61 Update microwargear.md
All checks were successful
SteamWarCI Build successful
2025-01-26 10:32:11 +01:00
a75b5b7c09 Fix Branch Creation
All checks were successful
SteamWarCI Build successful
2025-01-26 10:24:20 +01:00
9146f65455 Fix Padding
All checks were successful
SteamWarCI Build successful
2025-01-21 14:57:05 +01:00
254807efa6 Rebuild
All checks were successful
SteamWarCI Build successful
2025-01-20 23:06:52 +01:00
36931aabb1 Fixes and Upgrade to Astro 5
Some checks failed
SteamWarCI Build failed
2025-01-20 23:04:34 +01:00
628599f019 Fix Login Page and add Jahresplan
All checks were successful
SteamWarCI Build successful
2025-01-20 19:21:21 +01:00
0a6c61bd88 Fix Upload
All checks were successful
SteamWarCI Build successful
2025-01-20 17:42:08 +01:00
8bbad8b3cc Fix CI
All checks were successful
SteamWarCI Build successful
2025-01-20 15:50:17 +01:00
5af6176889 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:49:59 +01:00
9250dd5088 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:49:36 +01:00
276e19409d Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:48:49 +01:00
11fa9fa126 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:46:46 +01:00
17ec6023a9 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:45:43 +01:00
3c7c899868 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:41:59 +01:00
9cb161e470 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:38:58 +01:00
7fc7c2a6eb Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:37:28 +01:00
2fce94d46b Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:35:21 +01:00
6356c9911a Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:34:38 +01:00
2402896fd5 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:33:12 +01:00
2940304492 Fix CI
Some checks failed
SteamWarCI Build failed
2025-01-20 15:32:20 +01:00
4c0a237b27 Merge remote-tracking branch 'origin/master'
Some checks failed
SteamWarCI Build failed
2025-01-20 15:26:55 +01:00
77b8b41afb Fix CodeMirror 2025-01-20 15:19:18 +01:00
163d049829 astro.config.mjs aktualisiert
Some checks failed
SteamWarCI Build failed
2025-01-20 15:06:44 +01:00
a321b12680 Merge remote-tracking branch 'origin/master'
Some checks failed
SteamWarCI Build failed
2025-01-20 15:05:42 +01:00
feba5a5b4a Fix CodeMirror 2025-01-20 15:05:33 +01:00
faaf5f1852 steamwarci.yml aktualisiert
Some checks failed
SteamWarCI Build failed
2025-01-20 15:03:34 +01:00
18997e1384 tailwind.config.cjs aktualisiert
Some checks failed
SteamWarCI Build failed
2025-01-20 15:02:11 +01:00
fdc7bb93dd Add some stuff 2025-01-19 17:58:26 +01:00
TheBreadBeard
437dfa223b Update neujahrsevent-2025-eventplan.md 2025-01-01 18:34:35 +01:00
012a56e177 Merge remote-tracking branch 'origin/master' 2025-01-01 17:09:02 +01:00
9f60071e48 Some (alot) of fixes 2025-01-01 17:08:55 +01:00
3bcadde949 Merge pull request 'Merge branch neujahrsevent-2025-eventplan' (#23) from neujahrsevent-2025-eventplan into master
Reviewed-on: https://steamwar.de/devlabs/SteamWar/Website/pulls/23
2025-01-01 15:20:47 +01:00
f5332411d2 Fixes 2025-01-01 14:25:33 +01:00
TheBreadBeard
b546c7b2b2 Update neujahrsevent-2025-eventplan.md 2025-01-01 13:45:48 +01:00
TheBreadBeard
7716aa1e89 Create page announcements/de/neujahrsevent-2025-eventplan.md 2025-01-01 13:42:09 +01:00
a7e961fc0c Fixes 2024-12-21 16:13:33 +01:00
8c6f5f5729 Fix Render 2024-12-21 16:06:23 +01:00
41a3b75c97 Fix Render 2024-12-21 16:03:45 +01:00
fe9610a970 Kampfplan 2024-12-21 15:58:22 +01:00
6b38f37711 Add Redirects 2024-12-17 20:44:53 +01:00
68b8e92661 Add latest announcements 2024-12-15 14:51:55 +01:00
a29689da0a Add latest announcements 2024-12-15 14:50:32 +01:00
292d1b6bcc Fixes 2024-12-15 12:02:54 +01:00
9e6ef73ccb Fixes 2024-12-15 11:59:28 +01:00
02ab822801 Fixes 2024-12-15 11:49:43 +01:00
d7000c084b Fixes 2024-12-14 18:10:50 +01:00
d4ac123654 Fix Modal 2024-12-08 17:21:17 +01:00
e93445d933 Merge branch 'kick-ai-bots' 2024-12-08 17:10:32 +01:00
2383cd6472 Fix Styles 2024-12-08 17:10:01 +01:00
d86a8493d1 Merge pull request 'Give AI bots the boot' (#22) from kick-ai-bots into master
Reviewed-on: https://steamwar.de/devlabs/SteamWar/Website/pulls/22
Reviewed-by: Chaoscaot <chaos@chaoscaot.de>
2024-11-26 15:34:50 +01:00
Lixfel
5f90025493 Give AI bots the boot
Mix of

- https://blog.cloudflare.com/declaring-your-aindependence-block-ai-bots-scrapers-and-crawlers-with-a-single-click/
- https://www.google.com/robots.txt
2024-11-26 15:31:44 +01:00
bbb6d87ccc Fix Build 2024-11-24 23:05:29 +01:00
0ab7d204f2 Fix Build 2024-11-24 23:00:44 +01:00
72933a46d1 Update 2024-11-24 22:57:21 +01:00
bbf13cf203 Update 2024-11-24 22:46:47 +01:00
cb65e96165 Update Backend 2024-11-23 13:28:33 +01:00
e70951c9dd Fix Stats 2024-08-15 17:10:00 +02:00
d60d29bf98 fixes... 2024-08-15 17:07:06 +02:00
2321289edb fixes... 2024-08-15 17:04:19 +02:00
a70b00ae14 Add Schem Download 2024-08-15 17:04:06 +02:00
fe37a70d26 smoll Fixes 2024-08-15 16:46:01 +02:00
f652b4a4f2 Trigger Rebuild 2024-08-15 16:42:13 +02:00
22f24e92c3 changes 2024-08-15 16:38:50 +02:00
Chaoscaot
4afd833276 Create page announcements/de/ein-test.md 2024-08-15 15:52:19 +02:00
6c6767ce05 Fix frostbite.glb Location 2024-08-14 00:58:55 +02:00
530e0d9f19 Add Public Images 2024-08-13 19:14:26 +02:00
9d95da1fbe Merge remote-tracking branch 'origin/master' 2024-08-13 13:01:36 +02:00
aafb8ad56a Fixes... 2024-08-13 13:01:30 +02:00
b9e3e68b9b Fixes... 2024-08-13 13:00:00 +02:00
4b368f73e8 Fixes... 2024-08-13 12:55:04 +02:00
74b47efcad Add Shadow 2024-08-13 12:43:25 +02:00
60c1565d9d Fixes... 2024-08-13 12:34:03 +02:00
0cf9a8a48d Change to Border 2024-08-13 12:19:04 +02:00
ee8eeb8ad1 Fix 2024-08-13 12:12:50 +02:00
40a055c2a1 Cards Idea 2024-08-13 12:09:35 +02:00
38728774fd Redesign Cards 2024-08-13 10:56:47 +02:00
be4967f34b Redesign Cards 2024-08-13 10:48:31 +02:00
52c9ed0118 *another* Fix page 2024-08-12 22:38:54 +02:00
38047b1ddf Fix page 2024-08-12 22:32:39 +02:00
39512dc131 Maybe new Image...? 2024-08-12 22:29:23 +02:00
7c18feaa98 Maybe new Image... 2024-08-12 22:21:48 +02:00
659f23f317 Maybe new Image... 2024-08-12 22:13:47 +02:00
c9a06443b2 Fixes... 2024-08-12 21:06:35 +02:00
f11769b46b refactor: Rename "server" to "bau" in common translation file 2024-08-08 10:23:45 +02:00
e84e2b7e31 Lixfel anmerkungen #3 2024-08-07 22:33:41 +02:00
154c25ca36 Lixfel anmerkungen #2 2024-08-07 22:19:04 +02:00
25a9f21ab4 Lixfel anmerkungen #1 2024-08-06 18:58:07 +02:00
d9f34e6316 Fix Lang 2024-08-05 00:48:01 +02:00
5409d4a248 Redo some Startpage :D 2024-08-05 00:37:17 +02:00
4778429452 Add experimental Search Feature 2024-08-02 01:26:05 +02:00
f0426f5225 Fix 2024-07-29 13:07:00 +02:00
4bdf059c9a Fix 2024-07-29 13:03:14 +02:00
e0d2d8b427 Another Try 2024-07-29 12:59:18 +02:00
4d5504ef68 Fixes and Changes 2024-07-29 12:55:50 +02:00
39e4a37ac5 Fix Pages 2024-05-19 22:35:36 +02:00
3862a85150 Fix Pages 2024-05-19 22:35:07 +02:00
7859c3c797 Fix Pages 2024-05-19 22:34:37 +02:00
5ce099e49d Merge branch 'refs/heads/gitbutler/integration' 2024-05-19 22:31:44 +02:00
e65f9aaaa4 Refactoring 2024-05-19 22:31:34 +02:00
aef5d2f2eb Refactoring 2024-05-19 22:30:30 +02:00
TheBreadBeard
e01f3852d5 Update password.md, fixed grammar 2024-05-19 21:22:11 +02:00
TheBreadBeard
ed5c22222a Update about.md. slight grammar fixes 2024-05-19 21:20:31 +02:00
GitButler
5ac4a80a5c GitButler Integration Commit
This is an integration commit for the virtual branches that GitButler is tracking.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

Here are the branches that are currently applied:
 - Virtual branch (refs/gitbutler/Virtual-branch)
   branch head: 6e6eb5069f
   - .gitignore
 - update-frostbite-images-2024 (refs/gitbutler/update-frostbite-images-2024)
   branch head: 6e6eb5069f
   - .gitignore
 - master (refs/gitbutler/master)

Your previous branch was: 073d474bc3

The sha for that commit was: 073d474bc3

For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/virtual-branches/integration-branch
2024-05-03 20:52:06 +02:00
073d474bc3 Refactoring 2024-03-25 21:22:53 +01:00
6e6eb5069f feat: Add .idea directory to .gitignore
Ignore the .idea directory to prevent unnecessary files being tracked.
2024-03-25 01:34:38 +01:00
195a66fd60 Add Password Reset 2024-03-25 00:39:43 +01:00
899b41d051 Unification 2024-03-24 23:52:55 +01:00
296fe40085 Unification 2024-03-24 23:35:50 +01:00
f062f3eaf9 Add Xray Preview 2024-03-24 21:34:02 +01:00
7cf76bc7c7 Updates 2024-03-16 00:15:54 +01:00
91978ce03c Updates 2024-03-16 00:13:23 +01:00
85f570433d Updates 2024-03-16 00:11:02 +01:00
49fe9728e5 Neues Referee zeug 2024-03-15 22:41:44 +01:00
b52b2bcfe1 Update to Astro 4.5 2024-03-11 22:40:14 +01:00
f888b6946b Fix Lang 2024-03-10 08:13:13 +01:00
e800c7b345 Add WGS Kampfplan 2024-03-09 19:47:48 +01:00
0590cd349a Add more Prerendering 2024-03-09 18:22:14 +01:00
7599038c82 Fix Elo Page fr fr 2024-03-09 14:32:22 +01:00
c5e2ad9426 Fix Elo Page fr 2024-03-09 14:30:37 +01:00
72cf00934a Fix Elo Page 2024-03-09 14:25:18 +01:00
1b55503e12 Fix Elo Page 2024-03-09 14:25:08 +01:00
08371f57d1 Fix Elo Page 2024-03-09 14:23:11 +01:00
30808bfd5b Fix Elo Page 2024-03-09 14:21:26 +01:00
e08f75d849 Add Page Filter 2024-03-09 14:19:00 +01:00
82d0403c88 Add Page Filter 2024-03-09 14:16:58 +01:00
228bb43518 Fix Language Warning 2024-03-09 14:00:38 +01:00
72df9c1833 Remove migration code 2024-03-09 13:57:43 +01:00
c6d8ccdf92 Fixing... 2024-03-09 13:56:06 +01:00
9312089e96 Migrate Site to German as Default Locale 2024-03-09 13:53:43 +01:00
fd56de0451 TIFU 2024-03-06 17:13:07 +01:00
42436e5d3b TIFU 2024-03-06 17:10:40 +01:00
404404d19c Fix 2024-03-06 17:09:54 +01:00
eec0203e15 Add Ranked Page 2024-03-06 17:08:06 +01:00
8a7c8597da Add Teardown code 2024-03-06 16:58:41 +01:00
7c473d3545 Add Teardown code 2024-03-06 16:56:31 +01:00
f2e2a6ff59 Add Teardown code 2024-03-06 16:54:46 +01:00
16cb987aff Fixing... 2024-03-06 16:49:36 +01:00
3de8832689 3D Public Preview Initial Test 2024-03-06 15:46:28 +01:00
d46b3ec511 Fix Styles 2024-03-01 21:59:37 +01:00
ea46ad57dd use ViewTransitions Hero 2024-03-01 21:58:30 +01:00
474187899f Add Table Styles 2024-03-01 21:45:50 +01:00
77bf19a1c8 Remove Reload 2024-02-28 17:42:10 +01:00
c613cef5c4 Remove Reload 2024-02-28 17:38:18 +01:00
678746c89b Begin Display, Add View Transitions 2024-02-28 17:28:21 +01:00
361d7dae6a New Backdrop 2024-02-25 11:32:39 +01:00
4c1b337676 Update Dependencies and Refactor Navbar 2024-02-25 11:24:19 +01:00
04859c6858 Bouncy Cards 2024-02-25 10:03:47 +01:00
7448a77bb1 Add 3D Card Effect 2024-02-21 23:59:14 +01:00
568e8dbf7d Again 2024-02-17 02:53:15 +01:00
dee73abb90 Again 2024-02-17 02:51:58 +01:00
dc771db3e4 Adjust Chart 2024-02-17 02:49:44 +01:00
af870a70bc Optimize astro.config.mjs 2024-02-16 23:51:55 +01:00
4ed5ed3e35 Optimize WarGear Links 2024-02-16 23:48:52 +01:00
d15e37d6a1 Update Dependencies 2024-02-13 21:43:33 +01:00
deef2ae345 Use the LightMode 2024-02-13 20:31:47 +01:00
5fb5234370 use Skin Cache 2024-02-12 19:13:02 +01:00
9fd8ddb9bd Code Cleanup™ 2024-02-11 11:16:23 +01:00
4b27eb76fe Merge remote-tracking branch 'origin/master' 2024-01-07 13:38:32 +01:00
076fc7046d Fix TypeAheadSearch.svelte, Remove Logout 2024-01-07 13:38:27 +01:00
dd8a1c023b Merge pull request 'Merge branch AboutEnglish' (#17) from AboutEnglish into master
Reviewed-on: https://steamwar.de/devlabs/SteamWar/Website/pulls/17
Reviewed-by: YoyoNow <jwsteam@nidido.de>
2024-01-07 13:24:57 +01:00
YoyoNow
1c6f770f25 Update about.md 2024-01-07 13:21:07 +01:00
c76811d5f5 Merge pull request 'Merge branch fix-about' (#16) from fix-about into master
Reviewed-on: https://steamwar.de/devlabs/SteamWar/Website/pulls/16
Reviewed-by: YoyoNow <jwsteam@nidido.de>
2024-01-07 13:16:44 +01:00
Chaoscaot
b5ced01722 Update about.md 2024-01-07 13:16:17 +01:00
dd85aeca7b Update Schematic Modal 2024-01-07 13:14:51 +01:00
66c57e8aa6 Fix Freeze 2024-01-07 12:42:48 +01:00
cb6d5bfd9e Fix Freeze 2024-01-07 12:40:40 +01:00
8d64901d07 Rebuild 2024-01-06 22:47:34 +01:00
890f71d144 Update Wargear Regelwerk 2024-01-06 22:37:06 +01:00
36c7505c5d Fix Token 2024-01-06 21:36:22 +01:00
925db8b356 Fix Translation 2024-01-06 17:39:21 +01:00
fb0e5bb824 Fix API 2024-01-06 17:37:19 +01:00
6cff2bd7ec Fix Font (For real now) 2024-01-06 17:30:53 +01:00
e2ec2229b0 Fix Font (Finally) 2024-01-06 17:28:23 +01:00
c4d44ca4fa Fix Font (Hopefully) 2024-01-06 17:25:16 +01:00
ce27973c68 Add Roboto 2024-01-06 17:19:59 +01:00
5e57a8c99d Fix CI 2024-01-06 16:43:03 +01:00
0a0946e335 Remove Schem Download 2024-01-06 16:39:52 +01:00
926624542d Test Build&Release 2024-01-06 16:07:24 +01:00
dd8bd1e094 Change to IPv4 2024-01-06 15:48:25 +01:00
0f74e7f7e8 Add to Git ignore 2024-01-06 15:43:10 +01:00
4442909633 Delete 2024-01-06 15:41:46 +01:00
36c2695c8c Add Alt Button 2024-01-06 15:38:35 +01:00
9ee0fd5448 Updates 2024-01-06 15:08:54 +01:00
efd674eae1 Some Code Cleanup 2023-12-27 19:18:08 +01:00
9a16c4b560 Some Code Cleanup 2023-12-27 19:16:54 +01:00
3108d9bf20 Some Code Cleanup 2023-12-25 21:54:40 +01:00
a2687083e0 Images 2023-12-24 23:00:20 +01:00
c5effd8f7f Test CI 2023-12-23 16:50:46 +01:00
8fcab610fb Add EsLint 2023-12-23 15:36:22 +01:00
5a5cce199b Change to PW 2023-12-19 19:22:19 +01:00
Chaoscaot
aa83cddcec Update warship.md 2023-12-16 13:30:38 +01:00
Chaoscaot
d8db4ae2d3 Create page rules/en/warship.md 2023-12-16 13:29:10 +01:00
643db90b15 Updates 2023-12-15 16:11:33 +01:00
a72dd2124d Adjustments 2023-12-14 22:14:41 +01:00
ecb906e614 Add how to create token 2023-12-14 21:07:31 +01:00
2286c6a3eb Some a11y and Lighthouse testing 2023-12-10 21:52:07 +01:00
6c9c496f05 Fixing and Sitemap and Robots.txt 2023-12-10 17:14:10 +01:00
3d95bffb6a Transfer some Posts and automate original German 2023-12-10 01:48:53 +01:00
311856415e Migrate to dayjs and Astro 4.0 2023-12-07 00:17:32 +01:00
505ee26622 Comment out Helpcenter 2023-12-05 18:12:07 +01:00
224a3929aa Finish MVP 2023-12-05 17:55:48 +01:00
0fc220ce94 Refactor 2023-12-05 17:36:31 +01:00
89e6f9cff4 Some™️Pages 2023-12-05 15:36:11 +01:00
fbd52f3edb New Code Editor and fun 2023-12-03 19:31:29 +01:00
2abe554059 Scheiß Line Separator 2023-11-28 12:00:06 +01:00
3996376381 Download and Rules 2023-11-18 16:52:54 +01:00
3889f28eb8 Updates and more 2023-11-12 22:43:42 +01:00
7450ecdabb Updates and more 2023-11-05 22:27:20 +01:00
e97e86f9ac Updates and more 2023-11-03 20:31:27 +01:00
Chaoscaot
b5a54d087b Update imprint.md 2023-10-12 21:07:56 +02:00
Chaoscaot
d476a1500a Create page pages/de/imprint.md 2023-10-12 21:06:14 +02:00
c5164f2bd3 Updates and more 2023-10-12 21:02:57 +02:00
Chaoscaot
f10d4c17d6 Update about.md 2023-10-08 19:51:41 +02:00
Chaoscaot
d84de0ab46 Create page pages/en/about.md 2023-10-08 19:51:18 +02:00
Chaoscaot
0eb05e4f64 Delete Test.md 2023-10-08 15:44:30 +02:00
Chaoscaot
c620a0f0d4 Update Test.md 2023-10-08 15:42:21 +02:00
Chaoscaot
8cc3072c3c Merge pull request 'Test2' (#2) from dev into master 2023-10-08 15:30:39 +02:00
Chaoscaot
ba817a4a88 Create page pages/en/Test.md 2023-10-08 15:26:50 +02:00
e65a70aa40 Changes 2023-10-08 15:08:39 +02:00
Chaoscaot
17d46c3964 Create page src/content/pages/en/about.md 2023-10-08 15:07:29 +02:00
48961abdf6 Changes 2023-10-08 14:34:38 +02:00
Chaoscaot
51a605ffa5 2 2023-10-08 12:34:18 +02:00
Chaoscaot
0c534990e8 Save 2023-10-08 12:32:39 +02:00
Chaoscaot
e0f2702eca Changes 2023-10-01 10:04:04 +02:00
7728d9e177 Update testfile.txt 2023-09-30 16:34:40 +02:00
Chaoscaot
9f2ce1c8c6 Add testfile.txt 2023-09-30 15:16:17 +02:00
Chaoscaot
53443cf383 lil home page 2023-09-24 16:33:14 +02:00
Chaoscaot
4dca085cec Initial Commit 2023-09-20 20:52:40 +02:00
161 changed files with 11496 additions and 8451 deletions

View File

@@ -37,6 +37,10 @@
"error", "error",
4 4
], ],
"linebreak-style": [
"error",
"unix"
],
"quotes": [ "quotes": [
"error", "error",
"double" "double"

1
.gitignore vendored
View File

@@ -26,4 +26,3 @@ pnpm-debug.log*
/src/env.d.ts /src/env.d.ts
/src/pages/en/ /src/pages/en/
/.idea /.idea
pnpm-lock.yaml

View File

@@ -1,4 +1,4 @@
import { defineConfig, sharpImageService } from "astro/config"; import {defineConfig, sharpImageService} from "astro/config";
import svelte from "@astrojs/svelte"; import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
import configureI18n from "./astro-i18n.adapter"; import configureI18n from "./astro-i18n.adapter";
@@ -8,8 +8,6 @@ import robotsTxt from "astro-robots-txt";
import path from "node:path"; import path from "node:path";
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
import starlight from "@astrojs/starlight";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: "static", output: "static",
@@ -20,40 +18,14 @@ export default defineConfig({
site: "https://steamwar.de", site: "https://steamwar.de",
integrations: [ integrations: [
svelte(), svelte(),
starlight({
disable404Route: true,
title: "SteamWar Docs",
defaultLocale: "de",
logo: {
src: "./src/images/logo.png",
},
social: [
{ icon: "discord", label: "Discord", href: "https://steamwar.de/discord" },
{ icon: "document", label: "Gitea", href: "https://git.steamwar.de" },
],
sidebar: [
{ label: "Startseite", slug: "docs" },
{ label: "Bau", badge: "WIP", items: ["docs/bausystem", { label: "Script System", items: ["docs/bausystem/script"] }] },
{ label: "Kampfsystem", badge: "WIP", items: ["docs/fightsystem"] },
{ label: "Minigames", badge: "WIP", items: ["docs/minigames"] },
{ label: "Schematicsystem", badge: "WIP", items: ["docs/schematicsystem"] },
{ label: "API", badge: "WIP", items: ["docs/api"] },
],
editLink: {
baseUrl: "https://git.steamwar.de/SteamWar/Website/src/branch/master/",
},
}),
tailwind({ tailwind({
configFile: "./tailwind.config.js", configFile: "./tailwind.config.js",
applyBaseStyles: false,
}), }),
configureI18n(), configureI18n(),
sitemap({ sitemap({
i18n: { i18n: {
defaultLocale: "en", defaultLocale: "en", locales: {
locales: { en: "en-US", de: "de-DE",
en: "en-US",
de: "de-DE",
}, },
}, },
}), }),
@@ -77,7 +49,7 @@ export default defineConfig({
{ userAgent: "omgili", disallow: "/" }, { userAgent: "omgili", disallow: "/" },
{ userAgent: "OmigliBot", disallow: "/" }, { userAgent: "OmigliBot", disallow: "/" },
{ userAgent: "PerplexityBot", disallow: "/" }, { userAgent: "PerplexityBot", disallow: "/" },
{ userAgent: "Timpibot", disallow: "/" }, { userAgent: "Timpibot", disallow: "/" }
], ],
}), }),
mdx(), mdx(),
@@ -94,8 +66,8 @@ export default defineConfig({
"@layouts": path.resolve("./src/layouts"), "@layouts": path.resolve("./src/layouts"),
"@repo": path.resolve("./src/components/repo"), "@repo": path.resolve("./src/components/repo"),
"@stores": path.resolve("./src/components/stores"), "@stores": path.resolve("./src/components/stores"),
$lib: path.resolve("./src"), "$lib": path.resolve("./src"),
}, },
}, },
}, },
}); });

View File

@@ -1,95 +1,82 @@
{ {
"name": "steamwar-website", "name": "steamwar-website",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"i18n:extract": "astro-i18n extract", "i18n:extract": "astro-i18n extract",
"i18n:generate:pages": "astro-i18n generate:pages --purge", "i18n:generate:pages": "astro-i18n generate:pages --purge",
"i18n:generate:types": "astro-i18n generate:types", "i18n:generate:types": "astro-i18n generate:types",
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types", "i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node_modules": "rm -rf node_modules", "clean:node_modules": "rm -rf node_modules",
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build" "ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/svelte": "^7.1.0", "@astrojs/svelte": "^7.0.4",
"@astrojs/tailwind": "^5.1.5", "@astrojs/tailwind": "^5.1.5",
"@astropub/icons": "^0.2.0", "@astropub/icons": "^0.2.0",
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.7.0",
"@lucide/svelte": "^0.488.0", "@types/color": "^4.2.0",
"@types/color": "^4.2.0", "@types/node": "^22.9.3",
"@types/js-yaml": "^4.0.9", "@types/three": "^0.170.0",
"@types/node": "^22.15.23", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@types/three": "^0.170.0", "@typescript-eslint/parser": "^8.15.0",
"@typescript-eslint/eslint-plugin": "^8.33.0", "autoprefixer": "^10.4.20",
"@typescript-eslint/parser": "^8.33.0", "bits-ui": "1.3.4",
"autoprefixer": "^10.4.21", "clsx": "^2.1.1",
"bits-ui": "1.3.4", "cmdk-sv": "^0.0.18",
"clsx": "^2.1.1", "cssnano": "^7.0.6",
"cmdk-sv": "^0.0.18", "embla-carousel-svelte": "^8.5.2",
"cssnano": "^7.0.7", "esbuild": "^0.24.0",
"embla-carousel-svelte": "^8.6.0", "eslint": "^9.15.0",
"esbuild": "^0.24.2", "eslint-plugin-astro": "^1.3.1",
"eslint": "^9.27.0", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-svelte": "^2.46.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "formsnap": "1.0.1",
"eslint-plugin-svelte": "^2.46.1", "lucide-svelte": "^0.476.0",
"formsnap": "1.0.1", "mode-watcher": "^0.5.1",
"lucide-svelte": "^0.476.0", "paneforge": "^0.0.6",
"mode-watcher": "^0.5.1", "postcss-nesting": "^13.0.1",
"paneforge": "^0.0.6", "sass": "^1.81.0",
"postcss-nesting": "^13.0.1", "svelte": "^5.16.0",
"sass": "^1.89.0", "svelte-sonner": "^0.3.28",
"svelte": "^5.33.4", "tailwind-merge": "^2.5.5",
"svelte-sonner": "^0.3.28", "tailwind-variants": "^0.3.1",
"tailwind-merge": "^2.6.0", "tailwindcss": "^3.4.15",
"tailwind-variants": "^0.3.1", "three": "^0.170.0",
"tailwindcss": "^3.4.17", "typescript": "^5.7.2",
"three": "^0.170.0", "vaul-svelte": "^0.3.2",
"typescript": "^5.8.3", "zod": "^3.23.8"
"vaul-svelte": "^0.3.2", },
"zod": "^3.25.31" "dependencies": {
}, "@astrojs/mdx": "^4.0.7",
"dependencies": { "@astrojs/sitemap": "^3.2.1",
"@astrojs/mdx": "^4.3.0", "@codemirror/commands": "^6.8.0",
"@astrojs/sitemap": "^3.4.0", "@codemirror/lang-json": "^6.0.1",
"@astrojs/starlight": "^0.34.4", "@ddietr/codemirror-themes": "^1.4.4",
"@astrojs/starlight-tailwind": "^4.0.1", "@tanstack/table-core": "^8.21.2",
"@codemirror/commands": "^6.8.1", "astro": "^5.1.8",
"@codemirror/lang-json": "^6.0.1", "astro-i18n": "^2.2.4",
"@codemirror/view": "^6.36.8", "astro-robots-txt": "^1.0.0",
"@ddietr/codemirror-themes": "^1.5.1", "astro-seo": "^0.8.4",
"@tanstack/table-core": "^8.21.3", "chart.js": "^4.4.6",
"astro": "5.7.14", "chartjs-adapter-dayjs-4": "^1.0.4",
"astro-i18n": "^2.2.4", "chartjs-adapter-moment": "^1.0.1",
"astro-robots-txt": "^1.0.0", "color": "^4.2.3",
"astro-seo": "^0.8.4", "dayjs": "^1.11.13",
"chart.js": "^4.4.9", "easymde": "^2.18.0",
"chartjs-adapter-dayjs-4": "^1.0.4", "flowbite": "^2.5.2",
"chartjs-adapter-moment": "^1.0.1", "flowbite-svelte": "^0.47.3",
"codemirror": "^6.0.1", "flowbite-svelte-icons": "^2.0.2",
"color": "^4.2.3", "qs": "^6.13.1",
"dayjs": "^1.11.13", "sharp": "^0.33.5",
"easymde": "^2.20.0", "svelte-awesome": "^3.3.5",
"flowbite": "^2.5.2", "svelte-codemirror-editor": "^1.4.1",
"flowbite-svelte": "^0.47.4", "svelte-spa-router": "^4.0.1"
"flowbite-svelte-icons": "^2.2.0", }
"js-yaml": "^4.1.0",
"qs": "^6.14.0",
"sharp": "^0.33.5",
"svelte-awesome": "^3.3.5",
"svelte-spa-router": "^4.0.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"sharp"
]
}
} }

9276
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,13 +18,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import { twMerge } from "tailwind-merge"; import {twMerge} from "tailwind-merge";
import { onMount } from "svelte"; import {onMount} from "svelte";
let cardElement: HTMLDivElement = $state(); let cardElement: HTMLDivElement = $state();
function rotateElement(event: MouseEvent) { function rotateElement(event: MouseEvent) {
if (!hoverEffect) return; if(!hoverEffect) return;
const x = event.clientX; const x = event.clientX;
const y = event.clientY; const y = event.clientY;
@@ -36,23 +36,23 @@
const rotateX = (centerY - y) / 20; const rotateX = (centerY - y) / 20;
const rotateY = -(centerX - x) / 20; const rotateY = -(centerX - x) / 20;
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`); cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`); cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
} }
function resetElement() { function resetElement() {
cardElement.style.setProperty("--rotate-x", "0"); cardElement.style.setProperty('--rotate-x', "0");
cardElement.style.setProperty("--rotate-y", "0"); cardElement.style.setProperty('--rotate-y', "0");
} }
interface Props { interface Props {
hoverEffect?: boolean; hoverEffect?: boolean;
extraClasses?: string; extraClasses?: string;
children?: import("svelte").Snippet; children?: import('svelte').Snippet;
} }
let { hoverEffect = true, extraClasses = "", children }: Props = $props(); let { hoverEffect = true, extraClasses = '', children }: Props = $props();
let classes = $derived(twMerge("w-72 border-2 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses)); let classes = $derived(twMerge("w-72 border-2 bg-zinc-50 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg dark:bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses))
</script> </script>
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect> <div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
@@ -61,20 +61,20 @@
<style lang="scss"> <style lang="scss">
div { div {
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important; transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6); transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
:global(h1) { :global(h1) {
@apply text-xl font-bold mt-4; @apply text-xl font-bold mt-4;
} }
:global(svg) { :global(svg) {
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl; @apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl
} }
} }
.hoverEffect:hover { .hoverEffect:hover {
scale: 105%; scale: 105%;
} }
</style> </style>

View File

@@ -19,8 +19,8 @@
<script lang="ts"> <script lang="ts">
import FightStatsChart from "./FightStatsChart.svelte"; import FightStatsChart from "./FightStatsChart.svelte";
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import { statsRepo } from "@repo/stats.ts"; import {statsRepo} from "@repo/stats.ts";
let request = getStats(); let request = getStats();
@@ -35,4 +35,4 @@
<FightStatsChart data={stats} /> <FightStatsChart data={stats} />
{:catch error} {:catch error}
<p>error: {error}</p> <p>error: {error}</p>
{/await} {/await}

View File

@@ -19,27 +19,25 @@
--> -->
<script lang="ts"> <script lang="ts">
import { window } from "./utils.ts"; import {window} from "./utils.ts";
import { astroI18n, t } from "astro-i18n"; import {astroI18n, t} from "astro-i18n";
import type { EventFight, ExtendedEvent } from "@type/event"; import type {EventFight, ExtendedEvent} from "@type/event";
import "@styles/table.css"; import "@styles/table.css";
export let event: ExtendedEvent; export let event: ExtendedEvent;
export let group: number; export let group: string;
export let rows: number = 1; export let rows: number = 1;
function getWinner(fight: EventFight) { function getWinner(fight: EventFight) {
if (!fight.hasFinished) {
return t("announcements.table.notPlayed");
}
switch (fight.ergebnis) { switch (fight.ergebnis) {
case 1: case 1:
return fight.blueTeam.kuerzel; return fight.blueTeam.kuerzel;
case 2: case 2:
return fight.redTeam.kuerzel; return fight.redTeam.kuerzel;
default: case 3:
return t("announcements.table.draw"); return t("announcements.table.draw");
default:
return t("announcements.table.notPlayed");
} }
} }
</script> </script>
@@ -57,15 +55,13 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows ) as fights} {#each window(event.fights.filter(f => group === undefined ? true : f.group === group), rows) as fights}
<tr> <tr>
{#each fights as fight (fight.id)} {#each fights as fight (fight.id)}
<td <td>{Intl.DateTimeFormat(astroI18n.locale, {
>{Intl.DateTimeFormat(astroI18n.locale, { hour: "numeric",
hour: "numeric", minute: "numeric",
minute: "numeric", }).format(new Date(fight.start))}</td>
}).format(new Date(fight.start))}</td
>
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td> <td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td> <td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
<td>{getWinner(fight)}</td> <td>{getWinner(fight)}</td>
@@ -74,4 +70,4 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -19,29 +19,33 @@
--> -->
<script lang="ts"> <script lang="ts">
import { window } from "./utils.ts"; import {window} from "./utils.ts";
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import type { ExtendedEvent } from "@type/event.ts"; import type {ExtendedEvent} from "@type/event.ts";
import "@styles/table.css"; import "@styles/table.css"
let { export let event: ExtendedEvent;
event, export let group: string;
group, export let rows: number = 1;
rows = 1,
}: {
event: ExtendedEvent;
group: number;
rows?: number;
} = $props();
let teamPoints = $derived( $: teamPoints = event.teams.map(team => {
Object.entries(event.groups.find((g) => g.id === group)?.points ?? {}) const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
.map(([teamId, points]) => ({ const points = fights.reduce((acc, fight) => {
team: event.teams.find((t) => t.id === Number(teamId))!!, if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
points: points, return acc + 3;
})) } else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
.sort((a, b) => b.points - a.points) return acc + 3;
); } else if (fight.ergebnis === 3) {
return acc + 1;
} else {
return acc;
}
}, 0);
return {
team,
points,
};
}).sort((a, b) => b.points - a.points);
</script> </script>
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto"> <div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">

View File

@@ -1,7 +1,7 @@
<!-- <!--
- This file is a part of the SteamWar software. - This file is a part of the SteamWar software.
- -
- Copyright (C) 2026 SteamWar.de-Serverteam - Copyright (C) 2023 SteamWar.de-Serverteam
- -
- This program is free software: you can redistribute it and/or modify - This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by - it under the terms of the GNU Affero General Public License as published by
@@ -18,20 +18,20 @@
--> -->
<script lang="ts"> <script lang="ts">
import { preventDefault } from "svelte/legacy"; import { preventDefault } from 'svelte/legacy';
import { l } from "@utils/util.ts";
import { t } from "astro-i18n"; import {l} from "@utils/util.ts";
import { get } from "svelte/store"; import {t} from "astro-i18n";
import { navigate } from "astro:transitions/client"; import {get} from "svelte/store";
import { onMount } from "svelte"; import {navigate} from "astro:transitions/client";
import { authV2Repo } from "./repo/authv2.ts";
let username: string = $state(""); let username: string = $state("");
let pw: string = $state(""); let pw: string = $state("");
let error: string = $state(""); let error: string = $state("");
async function login() { async function login() {
let { authV2Repo } = await import("./repo/authv2.ts"); let {authV2Repo} = await import("./repo/authv2.ts");
if (username === "" || pw === "") { if (username === "" || pw === "") {
pw = ""; pw = "";
error = t("login.error"); error = t("login.error");
@@ -52,26 +52,6 @@
error = t("login.error"); error = t("login.error");
} }
} }
onMount(() => {
if (window.location.hash.includes("access_token")) {
const params = new URLSearchParams(window.location.hash.substring(1));
const accessToken = params.get("access_token");
if (accessToken) {
(async () => {
let auth = await $authV2Repo.loginDiscord(accessToken);
if (!auth) {
pw = "";
error = t("login.error");
return;
}
navigate(l("/dashboard"));
})();
}
}
});
</script> </script>
<form class="bg-gray-100 dark:bg-neutral-900 p-12 rounded-2xl shadow-2xl border-2 border-gray-600 flex flex-col" onsubmit={preventDefault(login)}> <form class="bg-gray-100 dark:bg-neutral-900 p-12 rounded-2xl shadow-2xl border-2 border-gray-600 flex flex-col" onsubmit={preventDefault(login)}>
@@ -83,16 +63,12 @@
<input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} /> <input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} />
</div> </div>
<p class="mt-2"> <p class="mt-2">
<a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a> <a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a></p>
</p>
{#if error} {#if error}
<p class="mt-2 text-red-500">{error}</p> <p class="mt-2 text-red-500">{error}</p>
{/if} {/if}
<button class="btn mt-4 !mx-0 justify-center" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button> <button class="btn mt-4 !mx-0 justify-center" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button>
<a class="btn mt-4 !mx-0 justify-center" href="https://discord.com/oauth2/authorize?client_id=869606970099904562&response_type=token&redirect_uri=https%3A%2F%2Fsteamwar.de%2Flogin&scope=identify">
{t("login.discord")}
</a>
</form> </form>
<style lang="postcss"> <style lang="postcss">
@@ -103,4 +79,4 @@
label { label {
@apply text-neutral-300; @apply text-neutral-300;
} }
</style> </style>

View File

@@ -19,18 +19,18 @@
<script lang="ts"> <script lang="ts">
import "../styles/button.css"; import "../styles/button.css";
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons"; import {CaretDownOutline, SearchOutline} from "flowbite-svelte-icons";
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import { l } from "../util/util"; import {l} from "../util/util";
import { onMount } from "svelte"; import {onMount} from "svelte";
import { loggedIn } from "@repo/authv2.ts"; import {loggedIn} from "@repo/authv2.ts";
interface Props { interface Props {
logo?: import("svelte").Snippet; logo?: import('svelte').Snippet;
} }
let { logo }: Props = $props(); let { logo }: Props = $props();
let navbar = $state<HTMLElement>(); let navbar = $state<HTMLDivElement>();
let searchOpen = $state(false); let searchOpen = $state(false);
let accountBtn = $state<HTMLAnchorElement>(); let accountBtn = $state<HTMLAnchorElement>();
@@ -41,11 +41,11 @@
} else { } else {
accountBtn!.href = l("/login"); accountBtn!.href = l("/login");
} }
}); })
onMount(() => { onMount(() => {
handleScroll(); handleScroll();
}); })
function handleScroll() { function handleScroll() {
if (window.scrollY > 0) { if (window.scrollY > 0) {
@@ -56,17 +56,13 @@
} }
</script> </script>
<svelte:window onscroll={handleScroll} /> <svelte:window onscroll={handleScroll}/>
<nav <nav data-pagefind-ignore class="fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors z-10 flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top" bind:this={navbar}>
data-pagefind-ignore
class="z-20 fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top"
bind:this={navbar}
>
<div class="flex flex-row items-center justify-evenly md:justify-between match"> <div class="flex flex-row items-center justify-evenly md:justify-between match">
<a class="flex items-center" href={l("/")}> <a class="flex items-center" href={l("/")}>
{@render logo?.()} {@render logo?.()}
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block"> <span class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block">
{t("navbar.title")} {t("navbar.title")}
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span> <span class="before:scale-y-100" style="display: none" aria-hidden="true"></span>
</span> </span>
@@ -77,11 +73,10 @@
<a href={l("/")}> <a href={l("/")}>
<span class="btn__text">{t("navbar.links.home.title")}</span> <span class="btn__text">{t("navbar.links.home.title")}</span>
</a> </a>
<CaretDownOutline class="ml-2 mt-auto" /> <CaretDownOutline class="ml-2 mt-auto"/>
</button> </button>
<div> <div>
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a> <a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
<a class="btn btn-gray" href={l("/events")}>{t("navbar.links.home.events")}</a>
<a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a> <a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a>
<a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a> <a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a>
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a> <a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a>
@@ -92,7 +87,7 @@
<a rel="prefetch" href={l("/rules")}> <a rel="prefetch" href={l("/rules")}>
<span class="btn__text">{t("navbar.links.rules.title")}</span> <span class="btn__text">{t("navbar.links.rules.title")}</span>
</a> </a>
<CaretDownOutline class="ml-2 mt-auto" /> <CaretDownOutline class="ml-2 mt-auto"/>
</button> </button>
<div> <div>
<a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a> <a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a>
@@ -101,8 +96,10 @@
<a href={l("/rules/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a> <a href={l("/rules/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a>
<a href={l("/rules/quickgear")} class="btn btn-gray">{t("navbar.links.rules.qg")}</a> <a href={l("/rules/quickgear")} class="btn btn-gray">{t("navbar.links.rules.qg")}</a>
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2> <h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</a> <a href={l("/rules/megawargear")}
<a href={l("/rules/microwargear")} class="btn btn-gray">{t("navbar.links.rules.micro")}</a> class="btn btn-gray">{t("navbar.links.rules.megawg")}</a>
<a href={l("/rules/microwargear")}
class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
<a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a> <a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a>
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2> <h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a> <a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a>
@@ -144,4 +141,4 @@
.match { .match {
width: min(100vw, 70em); width: min(100vw, 70em);
} }
</style> </style>

View File

@@ -1,60 +1,50 @@
--- ---
import type { CollectionEntry } from "astro:content"; import type {CollectionEntry} from "astro:content";
import { l } from "../util/util"; import {l} from "../util/util";
import { astroI18n } from "astro-i18n"; import {astroI18n} from "astro-i18n";
import { Image } from "astro:assets"; import {Image} from "astro:assets";
import TagComponent from "./TagComponent.astro"; import TagComponent from "./TagComponent.astro";
import P from "./P.astro"; import P from "./P.astro";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
interface Props { interface Props {
post: CollectionEntry<"announcements">; post: CollectionEntry<"announcements">
} }
const { const { post, slim }: {
post, post: CollectionEntry<"announcements">,
slim, slim: boolean,
}: {
post: CollectionEntry<"announcements">;
slim: boolean;
} = Astro.props as Props; } = Astro.props as Props;
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`); const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
--- ---
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}> <Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
<div class={`flex flex-row ${slim ? "" : "p-4"}`}> <div class={`flex flex-row ${slim ? "":"p-4"}`}>
{ {post.data.image != null
post.data.image != null ? ( ? (
<a href={postUrl}> <a href={postUrl}>
<div class="flex-shrink-0 pr-2"> <div class="flex-shrink-0 pr-2">
<Image <Image transition:name={post.data.title + "-image"} src={post.data.image} alt="Post Image" class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105" />
transition:name={post.data.title + "-image"}
src={post.data.image}
alt="Post Image"
class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105"
/>
</div> </div>
</a> </a>
) : null )
} : null}
<div> <div>
<a href={postUrl} class="flex flex-col items-start"> <a href={postUrl} class="flex flex-col items-start">
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2> <h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
<P class="text-gray-500" <P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
>{ day: "numeric",
Intl.DateTimeFormat(astroI18n.locale, { month: "long",
day: "numeric", year: "numeric",
month: "long", }).format(post.data.created)}</P>
year: "numeric",
}).format(post.data.created)
}</P
>
<P>{post.data.description}</P> <P>{post.data.description}</P>
</a> </a>
<div class="mt-1" transition:name={post.data.title + "-tags"}> <div class="mt-1" transition:name={post.data.title + "-tags"}>
{post.data.tags.map((tag) => <TagComponent tag={tag} />)} {post.data.tags.map((tag) => (
<TagComponent tag={tag} />
))}
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -18,103 +18,83 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Input, Label, Select } from "flowbite-svelte"; import {Input, Label, Select} from "flowbite-svelte";
import TypeAheadSearch from "./TypeAheadSearch.svelte"; import TypeAheadSearch from "./TypeAheadSearch.svelte";
import { gamemodes, groups, maps } from "@stores/stores.ts"; import {gamemodes, groups, maps, players} from "@stores/stores.ts";
import type { Team } from "@type/team.ts"; import type {Team} from "@type/team.ts";
interface Props { interface Props {
teams?: Team[]; teams?: Team[];
blueTeam: string; blueTeam: string;
redTeam: string; redTeam: string;
start?: string; start?: string;
gamemode?: string; gamemode?: string;
map?: string; map?: string;
spectatePort?: string | null; spectatePort?: string | null;
group?: string | null; group?: string | null;
groupSearch?: string; groupSearch?: string;
} }
let { let {
teams = [], teams = [],
blueTeam = $bindable(), blueTeam = $bindable(),
redTeam = $bindable(), redTeam = $bindable(),
start = $bindable(""), start = $bindable(""),
gamemode = $bindable(""), gamemode = $bindable(""),
map = $bindable(""), map = $bindable(""),
spectatePort = $bindable(null), spectatePort = $bindable(null),
group = $bindable(""), group = $bindable(""),
groupSearch = $bindable(""), groupSearch = $bindable("")
}: Props = $props(); }: Props = $props();
let selectableTeams = $derived( let selectableTeams = $derived(teams.map(team => {
teams return {
.map((team) => { name: team.name,
return { value: team.id.toString()
name: team.name, };
value: team.id.toString(), }).sort((a, b) => a.name.localeCompare(b.name)));
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let selectableGamemodes = $derived( let selectableGamemodes = $derived($gamemodes.map(gamemode => {
$gamemodes return {
.map((gamemode) => { name: gamemode,
return { value: gamemode
name: gamemode, };
value: gamemode, }).sort((a, b) => a.name.localeCompare(b.name)));
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== ""); let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== "");
let selectableCustomGamemode = $derived([ let selectableCustomGamemode = $derived([
...selectableGamemodes, ...selectableGamemodes, {
{
name: gamemode + " (custom)", name: gamemode + " (custom)",
value: gamemode, value: gamemode
}, }
]); ]);
let mapsStore = $derived(maps(gamemode)); let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived( let selectableMaps = $derived($mapsStore.map(map => {
$mapsStore return {
.map((map) => { name: map,
return { value: map
name: map, };
value: map, }).sort((a, b) => a.name.localeCompare(b.name)));
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== ""); let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== "");
let selectableCustomMaps = $derived([ let selectableCustomMaps = $derived([
...selectableMaps, ...selectableMaps, {
{
name: map + " (custom)", name: map + " (custom)",
value: map, value: map
}, }
]); ]);
let selectableGroups = $derived([ let selectableGroups = $derived([{
{ name: "None",
name: "None", value: ""
value: "", }, {
}, value: groupSearch,
{ name: `Create: '${groupSearch}'`
value: groupSearch, }, ...$groups.map(group => {
name: `Create: '${groupSearch}'`, return {
}, name: group,
...$groups value: group
.map((group) => { };
return { }).sort((a, b) => a.name.localeCompare(b.name))]);
name: group,
value: group,
};
})
.sort((a, b) => a.name.localeCompare(b.name)),
]);
</script> </script>
<div class="m-2"> <div class="m-2">
@@ -127,29 +107,32 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<Label for="fight-start">Start</Label> <Label for="fight-start">Start</Label>
<Input id="fight-start" bind:value={start}> <Input id="fight-start" bind:value={start} >
{#snippet children({ props })} {#snippet children({ props })}
<input type="datetime-local" {...props} bind:value={start} /> <input type="datetime-local" {...props} bind:value={start}/>
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-gamemode">Gamemode</Label> <Label for="fight-gamemode">Gamemode</Label>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select> <Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode}
id="fight-gamemode"></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-maps">Map</Label> <Label for="fight-maps">Map</Label>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select> <Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps"
disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-port">Spectate Port</Label> <Label for="fight-port">Spectate Port</Label>
<Input id="fight-port" bind:value={spectatePort}> <Input id="fight-port" bind:value={spectatePort} >
{#snippet children({ props })} {#snippet children({ props })}
<input type="number" inputmode="numeric" {...props} bind:value={spectatePort} /> <input type="number" inputmode="numeric" {...props} bind:value={spectatePort}/>
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-kampf">Group</Label> <Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch> <TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch}
all></TypeAheadSearch>
</div> </div>

View File

@@ -18,37 +18,21 @@
--> -->
<script lang="ts"> <script lang="ts">
import { run, preventDefault } from "svelte/legacy"; import { run, preventDefault } from 'svelte/legacy';
import { Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner } from "flowbite-svelte"; import {Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner} from "flowbite-svelte";
import { ArrowLeftOutline } from "flowbite-svelte-icons"; import {ArrowLeftOutline} from "flowbite-svelte-icons";
import { capitalize } from "../util.ts"; import {players} from "@stores/stores.ts";
import { permsRepo } from "@repo/perms.ts"; import {capitalize} from "../util.ts";
import { me } from "@stores/me.ts"; import {permsRepo} from "@repo/perms.ts";
import {me} from "@stores/me.ts";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import { userRepo } from "@repo/user.ts"; import {userRepo} from "@repo/user.ts";
import { dataRepo } from "@repo/data.ts";
import type { Player } from "@type/data";
let search = $state(""); let search = $state("");
let playersList: Player[] = $state([]);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 100, 0, undefined, undefined);
playersList = res.players;
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
let selectedPlayerName: string = $state("");
let playerPerms = $state(loadPlayer(selectedPlayer)); let playerPerms = $state(loadPlayer(selectedPlayer));
let prefixEdit = $state("PREFIX_NONE"); let prefixEdit = $state("PREFIX_NONE");
@@ -62,7 +46,7 @@
if (!id) { if (!id) {
return; return;
} }
return $permsRepo.getPerms(id).then((value) => { return $permsRepo.getPerms(id).then(value => {
activePerms = value.perms; activePerms = value.perms;
prefixEdit = value.prefix.name; prefixEdit = value.prefix.name;
return value; return value;
@@ -72,7 +56,7 @@
function togglePerm(perm: string) { function togglePerm(perm: string) {
return () => { return () => {
if (activePerms.includes(perm)) { if (activePerms.includes(perm)) {
activePerms = activePerms.filter((value) => value !== perm); activePerms = activePerms.filter(value => value !== perm);
} else { } else {
activePerms = [...activePerms, perm]; activePerms = [...activePerms, perm];
} }
@@ -80,7 +64,7 @@
} }
function save() { function save() {
playerPerms!.then(async (perms) => { playerPerms!.then(async perms => {
if (perms.prefix.name != prefixEdit) { if (perms.prefix.name != prefixEdit) {
await $permsRepo.setPrefix(selectedPlayer!, prefixEdit); await $permsRepo.setPrefix(selectedPlayer!, prefixEdit);
} }
@@ -115,20 +99,24 @@
resetPasswordRepeat = ""; resetPasswordRepeat = "";
resetPasswordModal = false; resetPasswordModal = false;
} }
let lowerCaseSearch = $derived(search.toLowerCase());
let filteredPlayers = $derived($players.filter(value => value.name.toLowerCase().includes(lowerCaseSearch)));
let player = $derived($players.find(value => value.uuid === selectedPlayer));
run(() => { run(() => {
playerPerms = loadPlayer(selectedPlayer); playerPerms = loadPlayer(selectedPlayer);
}); });
</script> </script>
<div class="flex flex-col h-screen overflow-hidden"> <div class="flex flex-col h-screen overflow-hidden">
<Navbar> <Navbar >
{#snippet children({ hidden, toggle })} {#snippet children({ hidden, toggle })}
<NavBrand href="#"> <NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline> <ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> Permissions </span> <span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
</NavBrand> Permissions
{/snippet} </span>
</NavBrand>
{/snippet}
</Navbar> </Navbar>
<div class="p-4 flex-1 overflow-hidden"> <div class="p-4 flex-1 overflow-hidden">
@@ -136,19 +124,14 @@
<Card class="h-full flex flex-col overflow-hidden !max-w-full"> <Card class="h-full flex flex-col overflow-hidden !max-w-full">
<div class="border-b border-b-gray-600 pb-2"> <div class="border-b border-b-gray-600 pb-2">
<Label for="user_search" class="mb-2">Search Users...</Label> <Label for="user_search" class="mb-2">Search Users...</Label>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search} /> <Input type="text" id="user_search" placeholder="Name..." bind:value={search}/>
</div> </div>
{#if playersList.length < 100} {#if filteredPlayers.length < 100}
<ul class="flex-1 overflow-scroll"> <ul class="flex-1 overflow-scroll">
{#each playersList as player (player.uuid)} {#each filteredPlayers as player (player.uuid)}
<li <li class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
class:text-orange-500={player.uuid === selectedPlayer} class:text-orange-500={player.uuid === selectedPlayer}
onclick={preventDefault(() => { onclick={preventDefault(() => selectedPlayer = player.uuid)}>
selectedPlayer = player.uuid;
selectedPlayerName = player.name;
})}
>
{player.name} {player.name}
</li> </li>
{/each} {/each}
@@ -157,7 +140,7 @@
</Card> </Card>
<Card class="!max-w-full" style="grid-column: 2/4"> <Card class="!max-w-full" style="grid-column: 2/4">
{#if selectedPlayer} {#if selectedPlayer}
<h1 class="text-3xl">{selectedPlayerName}</h1> <h1 class="text-3xl">{player.name}</h1>
{#await permsFuture} {#await permsFuture}
<Spinner></Spinner> <Spinner></Spinner>
{:then perms} {:then perms}
@@ -166,27 +149,39 @@
{:then player} {:then player}
<h1>Prefix</h1> <h1>Prefix</h1>
{#each Object.entries(perms.prefixes) as [key, prefix]} {#each Object.entries(perms.prefixes) as [key, prefix]}
<Radio name="prefix" bind:group={prefixEdit} value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio> <Radio name="prefix" bind:group={prefixEdit}
value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
{/each} {/each}
<h1>Permissions</h1> <h1>Permissions</h1>
{#each perms.perms as perm} {#each perms.perms as perm}
<Checkbox checked={activePerms.includes(perm)} onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox> <Checkbox checked={activePerms.includes(perm)}
onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
{/each} {/each}
<div class="mt-4"> <div class="mt-4">
<Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} onclick={save}>Save</Button> <Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])}
onclick={save}>Save
</Button>
{#if $me != null && $me.perms.includes("ADMINISTRATION")} {#if $me != null && $me.perms.includes("ADMINISTRATION")}
<Button onclick={() => (resetPasswordModal = true)}>Reset Password</Button> <Button onclick={() => resetPasswordModal = true}>
Reset Password
</Button>
<SWModal bind:open={resetPasswordModal} title="Reset Password"> <SWModal bind:open={resetPasswordModal} title="Reset Password">
<Label for="new_password">New Password</Label> <Label for="new_password">New Password</Label>
<Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword} /> <Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword}/>
<Label for="repeat_password">Repeat Password</Label> <Label for="repeat_password">Repeat Password</Label>
<Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat} /> <Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat}/>
{#snippet footer()} {#snippet footer()}
<Button class="ml-auto mr-4" onclick={resetResetPassword}>Cancel</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>Reset Password</Button> <Button class="ml-auto mr-4" onclick={resetResetPassword}>
{/snippet} Cancel
</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>
Reset Password
</Button>
{/snippet}
</SWModal> </SWModal>
{/if} {/if}
</div> </div>

View File

@@ -18,22 +18,23 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte"; import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte";
import { json } from "@codemirror/lang-json"; import {json} from "@codemirror/lang-json";
import { base64ToBytes } from "../../util.ts"; import CodeMirror from "svelte-codemirror-editor";
import type { Page } from "@type/page.ts"; import {base64ToBytes} from "../../util.ts";
import { materialDark } from "@ddietr/codemirror-themes/material-dark"; import type {Page} from "@type/page.ts";
import { createEventDispatcher } from "svelte"; import {materialDark} from "@ddietr/codemirror-themes/material-dark";
import {createEventDispatcher} from "svelte";
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte"; import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
import { pageRepo } from "@repo/page.ts"; import {pageRepo} from "@repo/page.ts";
interface Props { interface Props {
pageId: number; pageId: number;
branch: string; branch: string;
dirty?: boolean; dirty?: boolean;
} }
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props(); let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
let dispatcher = createEventDispatcher(); let dispatcher = createEventDispatcher();
@@ -70,32 +71,35 @@
} }
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage)); let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
</script> </script>
<svelte:window onbeforeunload={() => {
<svelte:window if (dirty) {
onbeforeunload={() => { return "You have unsaved changes. Are you sure you want to leave?";
if (dirty) { }
return "You have unsaved changes. Are you sure you want to leave?"; }}/>
}
}}
/>
{#await pageFuture} {#await pageFuture}
<Spinner /> <Spinner/>
{:then p} {:then p}
<div> <div>
<div> <div>
<Toolbar class="!bg-gray-900"> <Toolbar class="!bg-gray-900">
{#snippet end()} {#snippet end()}
<ToolbarGroup> <ToolbarGroup >
<ToolbarButton onclick={deletePage}>Delete</ToolbarButton> <ToolbarButton onclick={deletePage}>
<ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton> Delete
</ToolbarGroup> </ToolbarButton>
{/snippet} <ToolbarButton color="primary" onclick={savePage}>
Save
</ToolbarButton>
</ToolbarGroup>
{/snippet}
</Toolbar> </Toolbar>
</div> </div>
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")} {#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
<MDEMarkdownEditor bind:value={pageContent} bind:dirty /> <MDEMarkdownEditor bind:value={pageContent} bind:dirty/>
{:else}{/if} {:else}
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} onchange={() => dirty = true}/>
{/if}
</div> </div>
{:catch error} {:catch error}
<p>{error.message}</p> <p>{error.message}</p>
{/await} {/await}

View File

@@ -36,9 +36,8 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import FightCard from "./FightCard.svelte"; import FightCard from "./FightCard.svelte";
import CreateFightModal from "./modals/CreateFightModal.svelte"; import CreateFightModal from "./modals/CreateFightModal.svelte";
import {groups} from "@stores/stores.ts"; import {groups, players} from "@stores/stores.ts";
import TypeAheadSearch from "../../components/TypeAheadSearch.svelte"; import TypeAheadSearch from "../../components/TypeAheadSearch.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {fightRepo, type UpdateFight} from "@repo/fight.ts"; import {fightRepo, type UpdateFight} from "@repo/fight.ts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
@@ -98,6 +97,12 @@
} }
let spectatePortOpen = $state(false); let spectatePortOpen = $state(false);
let selectPlayers = $derived($players.map(player => {
return {
name: player.name,
value: player.uuid
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let spectatePort = $state(""); let spectatePort = $state("");
async function updateSpectatePort() { async function updateSpectatePort() {
@@ -257,12 +262,12 @@
<TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch> <TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch>
</div> </div>
{#snippet footer()} {#snippet footer()}
<Modal bind:open={spectatePortOpen} title="Change Kampfleiter" size="sm">
<div class="m-2"> <Button class="ml-auto" onclick={updateSpectatePort}>Change</Button>
<Label for="fight-kampf">Kampfleiter</Label> <Button onclick={() => spectatePortOpen = false} color="alternative">Cancel</Button>
<PlayerSelector bind:value={spectatePort} placeholder="Search player..." />
</div> {/snippet}
{#snippet footer()} </Modal>
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm"> <Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2"> <div class="m-2">

View File

@@ -18,19 +18,20 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { ExtendedEvent } from "@type/event.ts"; import type {ExtendedEvent} from "@type/event.ts";
import { Button } from "flowbite-svelte"; import {Button} from "flowbite-svelte";
import { PlusOutline } from "flowbite-svelte-icons"; import {PlusOutline} from "flowbite-svelte-icons";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte"; import TypeAheadSearch from "@components/admin/components/TypeAheadSearch.svelte";
import { eventRepo } from "@repo/event.ts"; import {players} from "@stores/stores.ts";
import {eventRepo} from "@repo/event.ts";
interface Props { interface Props {
data: ExtendedEvent; data: ExtendedEvent;
} }
let { data }: Props = $props(); let { data }: Props = $props();
let searchValue = $state(""); let searchValue = $state("");
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
@@ -41,19 +42,17 @@
async function addReferee() { async function addReferee() {
if (selectedPlayer) { if (selectedPlayer) {
referees = ( referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
await $eventRepo.updateEvent(data.event.id.toString(), { deadline: null,
deadline: null, end: null,
end: null, maxTeamMembers: null,
maxTeamMembers: null, name: null,
name: null, publicSchemsOnly: null,
publicSchemsOnly: null, removeReferee: null,
removeReferee: null, schemType: null,
schemType: null, start: null,
start: null, addReferee: [selectedPlayer]
addReferee: [selectedPlayer], })).referees;
})
).referees;
} }
reset(); reset();
@@ -61,20 +60,18 @@
function removeReferee(id: string) { function removeReferee(id: string) {
return async () => { return async () => {
referees = ( referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
await $eventRepo.updateEvent(data.event.id.toString(), { deadline: null,
deadline: null, end: null,
end: null, maxTeamMembers: null,
maxTeamMembers: null, name: null,
name: null, publicSchemsOnly: null,
publicSchemsOnly: null, addReferee: null,
addReferee: null, schemType: null,
schemType: null, start: null,
start: null, removeReferee: [id],
removeReferee: [id], })).referees;
}) }
).referees;
};
} }
function reset() { function reset() {
@@ -87,7 +84,9 @@
{#each referees as referee} {#each referees as referee}
<li class="flex flex-grow justify-between"> <li class="flex flex-grow justify-between">
{referee.name} {referee.name}
<SWButton onclick={removeReferee(referee.uuid)}>Entfernen</SWButton> <SWButton onclick={removeReferee(referee.uuid)}>
Entfernen
</SWButton>
</li> </li>
{/each} {/each}
@@ -96,22 +95,23 @@
{/if} {/if}
</ul> </ul>
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => (showAdd = true)}> <Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => showAdd = true}>
<PlusOutline /> <PlusOutline/>
</Button> </Button>
<SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}> <SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}>
<div class="flex flex-grow justify-center h-80"> <div class="flex flex-grow justify-center h-80">
<div> <div>
<PlayerSelector bind:value={selectedPlayer} placeholder="Search player..." /> <TypeAheadSearch bind:searchValue bind:selected={selectedPlayer}
items={$players.map(v => ({ name: v.name, value: v.uuid }))}/>
</div> </div>
</div> </div>
{#snippet footer()} {#snippet footer()}
<div class="flex flex-grow justify-end"> <div class="flex flex-grow justify-end">
<SWButton onclick={reset} type="gray">Abbrechen</SWButton> <SWButton onclick={reset} type="gray">Abbrechen</SWButton>
<SWButton onclick={addReferee}>Hinzufügen</SWButton> <SWButton onclick={addReferee}>Hinzufügen</SWButton>
</div> </div>
{/snippet} {/snippet}
</SWModal> </SWModal>
<style> <style>

View File

@@ -18,14 +18,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Card } from "@components/ui/card"; import {createEventDispatcher} from "svelte";
interface Props {
children?: import('svelte').Snippet;
}
interface Props { let { children }: Props = $props();
children?: import("svelte").Snippet;
ondrop: (event: DragEvent) => void;
}
let { children, ondrop }: Props = $props();
let dragover = $state(false); let dragover = $state(false);
@@ -34,16 +32,19 @@
dragover = true; dragover = true;
} }
const dispatch = createEventDispatcher();
function handleDrop(ev: DragEvent) { function handleDrop(ev: DragEvent) {
ev.preventDefault(); ev.preventDefault();
dragover = false; dragover = false;
ondrop(ev); dispatch("drop", ev);
} }
</script> </script>
<Card class="w-56 p-4 rounded m-px {dragover ? 'border-white' : ''}" ondrop={handleDrop} ondragover={handleDragOver} ondragleave={() => (dragover = false)} role="none"> <div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} ondrop={handleDrop}
ondragover={handleDragOver} ondragleave={() => dragover = false} role="none">
{@render children?.()} {@render children?.()}
</Card> </div>
<style> <style>
div { div {

View File

@@ -18,28 +18,28 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Team } from "@type/team.ts"; import { createBubbler } from 'svelte/legacy';
import { brightness, colorFromTeam, lighten } from "../../util";
interface Props { const bubble = createBubbler();
team: Team; import type {Team} from "@type/team.ts";
ondragstart: (event: DragEvent) => void; import {brightness, colorFromTeam, lighten} from "../../util";
}
let { team, ondragstart }: Props = $props(); interface Props {
team: Team;
}
let { team }: Props = $props();
let hover = $state(false); let hover = $state(false);
</script> </script>
<div <div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center" style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)} class:text-black={brightness(colorFromTeam(team))} draggable="true"
class:text-black={brightness(colorFromTeam(team))} ondragstart={bubble('dragstart')}
draggable="true" onmouseenter={() => hover = true}
{ondragstart} onmouseleave={() => hover = false}
onmouseenter={() => (hover = true)} role="figure">
onmouseleave={() => (hover = false)}
role="figure"
>
<span>{team.name}</span> <span>{team.name}</span>
</div> </div>

View File

@@ -18,19 +18,19 @@
--> -->
<script lang="ts"> <script lang="ts">
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import type { Player } from "@type/data.ts"; import type {Player} from "@type/data.ts";
import { l } from "@utils/util.ts"; import {l} from "@utils/util.ts";
import Statistics from "./Statistics.svelte"; import Statistics from "./Statistics.svelte";
import { authV2Repo } from "@repo/authv2.ts"; import {authV2Repo} from "@repo/authv2.ts";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
import { navigate } from "astro:transitions/client"; import {navigate} from "astro:transitions/client";
interface Props { interface Props {
user: Player; user: Player;
} }
let { user }: Props = $props(); let { user }: Props = $props();
async function logout() { async function logout() {
await $authV2Repo.logout(); await $authV2Repo.logout();
@@ -43,25 +43,19 @@
<Card> <Card>
<figure> <figure>
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption> <figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
<img <img src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`} class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl" alt={user.name + "s bust"} width="150" height="150" />
src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`}
class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl"
alt={user.name + "s bust"}
width="150"
height="150"
/>
</figure> </figure>
</Card> </Card>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button> <button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
{#if user.perms.includes("MODERATION")} {#if user.perms.includes("MODERATION")}
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a> <a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a>
{/if} {/if}
</div> </div>
</div> </div>
<div> <div>
<h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1> <h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1>
<p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p> <p>{t("dashboard.rank", {rank: t("home.prefix." + (user.prefix || "User"))})}</p>
<Statistics {user} /> <Statistics {user} />
</div> </div>
</div> </div>

View File

@@ -1,95 +0,0 @@
<script lang="ts">
import dayjs from "dayjs";
import "dayjs/locale/de";
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import * as Card from "../ui/card";
import EventCard from "./EventCard.svelte";
import SWButton from "@components/styled/SWButton.svelte";
const {
events,
}: {
events: { slug: string; data: { event: ExtendedEvent } }[];
} = $props();
let currentYear = $state(dayjs().year());
// Group events by month
let eventsByMonth = $derived.by(() => {
const grouped = new Map<string, typeof events>();
events.forEach((event) => {
const eventDate = dayjs(event.data.event.event.start).locale("de");
if (eventDate.year() === currentYear) {
const monthKey = eventDate.format("YYYY-MM");
if (!grouped.has(monthKey)) {
grouped.set(monthKey, []);
}
grouped.get(monthKey)!.push(event);
}
});
return grouped;
});
// Generate all 12 months for the current year
let months = $derived.by(() => {
return Array.from({ length: 12 }, (_, i) => {
const monthDate = dayjs().locale("de").year(currentYear).month(i);
const monthKey = monthDate.format("YYYY-MM");
return {
date: monthDate,
key: monthKey,
name: monthDate.format("MMMM"),
events: eventsByMonth.get(monthKey) || [],
};
});
});
function prevYear() {
currentYear = currentYear - 1;
}
function nextYear() {
currentYear = currentYear + 1;
}
</script>
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-white">
{currentYear}
</h2>
<div class="flex gap-2">
<SWButton onclick={prevYear} type="gray">
<ChevronLeft size={20} />
</SWButton>
<SWButton onclick={nextYear} type="gray">
<ChevronRight size={20} />
</SWButton>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each months as month}
<EventCard title={month.name} unsized={true}>
{#if month.events.length > 0}
{#each month.events as event}
<a href={`/events/${event.slug}/`} class="block p-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-600 transition-colors group">
<div class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</div>
<div class="text-xs text-gray-400 mt-1">
{dayjs(event.data.event.event.start).format("MMM D, YYYY • HH:mm")}
</div>
</a>
{/each}
{:else}
<p class="text-gray-500 text-sm italic">Keine Events für diesen Monat</p>
{/if}
</EventCard>
{/each}
</div>
</div>

View File

@@ -1,108 +0,0 @@
<script lang="ts">
import { fightConnector } from "./connections.svelte";
import { onMount, onDestroy } from "svelte";
let root: HTMLElement | null = null;
let refresh = $state(0);
function handleScroll() {
refresh++;
}
function getScrollableParent(el: HTMLElement | null): HTMLElement | null {
let node: HTMLElement | null = el?.parentElement ?? null;
while (node) {
const style = getComputedStyle(node);
const canScrollX = (style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth;
const canScrollY = (style.overflowY === "auto" || style.overflowY === "scroll") && node.scrollHeight > node.clientHeight;
if (canScrollX || canScrollY) return node;
node = node.parentElement;
}
return null;
}
let cleanup: (() => void) | null = null;
onMount(() => {
const scrollParent = getScrollableParent(root);
const target: EventTarget | null = scrollParent ?? window;
target?.addEventListener("scroll", handleScroll, { passive: true } as AddEventListenerOptions);
window.addEventListener("resize", handleScroll, { passive: true });
cleanup = () => {
target?.removeEventListener?.("scroll", handleScroll as EventListener);
window.removeEventListener("resize", handleScroll as EventListener);
};
});
onDestroy(() => {
cleanup?.();
cleanup = null;
});
</script>
<div bind:this={root} class="connection-renderer-root">
{#key refresh}
{#each $fightConnector.showedConnections as connection}
{@const fromLeft = connection.fromElement.offsetLeft + connection.fromElement.offsetWidth}
{@const toLeft = connection.toElement.offsetLeft}
{@const fromTop = connection.fromElement.offsetTop + connection.fromElement.offsetHeight / 2}
{@const toTop = connection.toElement.offsetTop + connection.toElement.offsetHeight / 2}
{@const horizontalDistance = toLeft - fromLeft}
{@const verticalDistance = toTop - fromTop}
<!-- Apply horizontal offset only to the mid bridge and second segment fan-out; also shift vertical line to keep continuity -->
{@const midLeft = fromLeft + horizontalDistance / 2 + connection.offset}
{@const firstSegmentWidth = horizontalDistance / 2}
{@const secondSegmentWidth = horizontalDistance / 2}
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {fromLeft}px;
top: {fromTop + connection.offset / 4}px;
width: {firstSegmentWidth + connection.offset + 2}px;
"
></div>
<div
class="vertical-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {Math.min(fromTop + connection.offset / 4, toTop + connection.offset / 4)}px;
height: {Math.abs(toTop + connection.offset / 4 - (fromTop + connection.offset / 4))}px;
"
></div>
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {toTop + connection.offset / 4}px;
width: {secondSegmentWidth - connection.offset}px;
"
></div>
{/each}
{/key}
</div>
<style>
.connection-renderer-root {
position: static;
pointer-events: none;
}
.vertical-line {
position: absolute;
width: 2px;
z-index: -10;
pointer-events: none;
}
.horizontal-line {
position: absolute;
height: 2px;
z-index: -10;
pointer-events: none;
}
</style>

View File

@@ -1,165 +0,0 @@
<script lang="ts">
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
import type { DoubleEleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { fightConnector } from "./connections.svelte.ts";
const { event, config }: { event: ExtendedEvent; config: DoubleEleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Double Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function indexRelations(ev: ExtendedEvent): Map<number, ResponseRelation[]> {
const map = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = map.get(rel.fight) ?? [];
list.push(rel);
map.set(rel.fight, list);
}
return map;
}
const relationsByFight = indexRelations(event);
const fightMap = new Map<number, EventFight>(event.fights.map((f) => [f.id, f]));
function collectBracket(startFinalId: number): EventFight[][] {
const finalFight = fightMap.get(startFinalId);
if (!finalFight) return [];
const bracketGroupId = finalFight.group?.id ?? null;
const stages: EventFight[][] = [];
let layer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (layer.length) {
stages.push(layer);
const next: EventFight[] = [];
for (const fight of layer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (!src) continue;
// Only traverse within the same bracket (group) to avoid cross-bracket pollution
if (bracketGroupId !== null && src.group?.id !== bracketGroupId) continue;
if (!visited.has(src.id)) {
visited.add(src.id);
next.push(src);
}
}
}
}
layer = next;
}
stages.reverse();
return stages;
}
const winnersStages = $derived(collectBracket(config.winnersFinalFight));
const losersStages = $derived(collectBracket(config.losersFinalFight));
const grandFinal = fightMap.get(config.grandFinalFight);
function stageName(count: number, isWinners: boolean): string {
switch (count) {
case 1:
return isWinners ? "Finale (W)" : "Finale (L)";
case 2:
return isWinners ? "Halbfinale (W)" : "Halbfinale (L)";
case 4:
return isWinners ? "Viertelfinale (W)" : "Viertelfinale (L)";
case 8:
return isWinners ? "Achtelfinale (W)" : "Achtelfinale (L)";
default:
return `Runde (${count}) ${isWinners ? "W" : "L"}`;
}
}
let connector: any;
const unsubscribe = fightConnector.subscribe((v) => (connector = v));
onDestroy(() => {
connector.clearAllConnections();
unsubscribe();
});
function buildConnections() {
if (!connector) return;
connector.clearAllConnections();
// Track offsets per source fight and team to stagger multiple outgoing lines for visual clarity
const fightTeamOffsetMap = new Map<string, number>();
const step = 8; // px separation between parallel lines
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromId = rel.fromFight.id;
const fromEl = document.getElementById(`fight-${fromId}`) as HTMLElement | null;
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
if (!fromEl || !toEl) continue;
// Use team-signed offsets so BLUE goes left (negative), RED goes right (positive)
const key = `${fromId}:${rel.team}`;
const index = fightTeamOffsetMap.get(key) ?? 0;
const sign = rel.team === "BLUE" ? -1 : 1;
const offset = sign * (index + 1) * step;
const color = rel.fromPlace === 0 ? "#60a5fa" : "#f87171";
connector.addConnection(fromEl, toEl, color, offset);
fightTeamOffsetMap.set(key, index + 1);
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if !grandFinal}
<p class="text-gray-400 italic">Konfiguration unvollständig (Grand Final fehlt).</p>
{:else}
{#key winnersStages.length + ":" + losersStages.length}
<!-- Build a grid where rows: winners (stages), losers (stages), with losers offset by one stage/column -->
{@const totalColumns = Math.max(winnersStages.length, losersStages.length + 1) + 1}
<div class="grid gap-x-16 gap-y-6 items-start" style={`grid-template-columns: repeat(${totalColumns}, max-content);`}>
<!-- Winners heading spans all columns -->
<h2 class="font-bold text-center">Winners Bracket</h2>
<!-- Winners stages in row 2 -->
{#each winnersStages as stage, i}
<div style={`grid-row: 2; grid-column: ${i + 1};`}>
<EventCard title={stageName(stage.length, true)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
<!-- Place Grand Final at the far right, aligned with winners row -->
<div style={`grid-row: 2; grid-column: ${totalColumns};`} class="self-center">
<EventCard title="Grand Final">
{#if grandFinal}
<EventFightChip fight={grandFinal} group={grandFinal.group ?? defaultGroup} />
{/if}
</EventCard>
</div>
<!-- Losers heading spans all columns -->
<h2 class="font-bold text-center" style="grid-row: 3; grid-column: 1 / {totalColumns};">Losers Bracket</h2>
<!-- Losers stages in row 4, offset by one column to the right -->
{#each losersStages as stage, j}
<div style={`grid-row: 4; grid-column: ${j + 2};`} class="mt-2">
<EventCard title={stageName(stage.length, false)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/key}
{/if}

View File

@@ -1,120 +0,0 @@
<script lang="ts">
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
import type { EleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { FightConnector, fightConnector } from "./connections.svelte.ts";
const { event, config }: { event: ExtendedEvent; config: EleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function buildStages(ev: ExtendedEvent, finalFightId: number): EventFight[][] {
const fightMap = new Map<number, EventFight>(ev.fights.map((f) => [f.id, f]));
const relationsByFight = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = relationsByFight.get(rel.fight) ?? [];
list.push(rel);
relationsByFight.set(rel.fight, list);
}
const finalFight = fightMap.get(finalFightId);
if (!finalFight) return [];
const stages: EventFight[][] = [];
let currentLayer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (currentLayer.length) {
stages.push(currentLayer);
const nextLayer: EventFight[] = [];
for (const fight of currentLayer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (src && !visited.has(src.id)) {
visited.add(src.id);
nextLayer.push(src);
}
}
}
}
currentLayer = nextLayer;
}
stages.reverse();
return stages;
}
function stageName(index: number, fights: EventFight[]): string {
const count = fights.length;
switch (count) {
case 1:
return `Finale`;
case 2:
return "Halbfinale";
case 4:
return "Viertelfinale";
case 8:
return "Achtelfinale";
case 16:
return "Sechzehntelfinale";
default:
return `Runde ${index + 1}`;
}
}
const stages = $derived(buildStages(event, config.finalFight));
const connector = $fightConnector;
onDestroy(() => {
connector.clearAllConnections();
});
function buildConnections() {
if (!connector) return;
connector.clearConnections();
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromEl = document.getElementById(`fight-${rel.fromFight.id}`) as HTMLElement | null;
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
if (fromEl && toEl) {
connector.addConnection(fromEl, toEl, "#9ca3af");
}
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if stages.length === 0}
<p class="text-gray-400 italic">Keine Eliminationsdaten gefunden.</p>
{:else}
<div class="flex gap-12">
{#each stages as stage, index}
<div class="flex flex-col justify-center">
<EventCard title={stageName(index, stage)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/if}

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
title,
children,
unsized = false,
}: {
title: string;
children: Snippet;
unsized?: boolean;
} = $props();
</script>
<div class="flex flex-col gap-1 {unsized ? '' : 'w-72 m-4'}">
<div class="bg-gray-100 text-black font-bold px-2 rounded uppercase">
{title}
</div>
<div class="border border-gray-600 rounded p-2 flex flex-col gap-2 bg-slate-900">
{@render children()}
</div>
</div>

View File

@@ -1,13 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
children,
}: {
children: Snippet;
} = $props();
</script>
<div class="bg-neutral-900 border border-gray-700 rounded-lg overflow-hidden">
{@render children()}
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { EventFight, ResponseGroups } from "@components/types/event";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
import { fightConnector } from "./connections.svelte.ts";
let {
fight,
group,
}: {
fight: EventFight;
group: ResponseGroups;
} = $props();
function getScore(group: ResponseGroups, fight: EventFight, blueTeam: boolean): string {
if (!fight.hasFinished) return "-";
if (fight.ergebnis === 1) {
return blueTeam ? group.pointsPerWin.toString() : group.pointsPerLoss.toString();
} else if (fight.ergebnis === 2) {
return blueTeam ? group.pointsPerLoss.toString() : group.pointsPerWin.toString();
} else {
return group.pointsPerDraw.toString();
}
}
</script>
<EventCardOutline>
<EventTeamChip
team={{
id: -1,
kuerzel: new Date(fight.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
name: new Date(fight.start).toLocaleDateString([]),
color: "-1",
}}
time={true}
/>
<div id={"fight-" + fight.id}>
<EventTeamChip team={fight.blueTeam} score={getScore(group, fight, true)} showWinner={true} isWinner={fight.ergebnis === 1} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-blue" />
<EventTeamChip team={fight.redTeam} score={getScore(group, fight, false)} showWinner={true} isWinner={fight.ergebnis === 2} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-red" />
</div>
</EventCardOutline>

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@type/event.ts";
import type { EventViewConfig } from "./types";
import { onMount } from "svelte";
import { eventRepo } from "@components/repo/event";
import GroupDisplay from "./GroupDisplay.svelte";
import ConnectionRenderer from "./ConnectionRenderer.svelte";
import EleminationDisplay from "./EleminationDisplay.svelte";
import DoubleEleminationDisplay from "./DoubleEleminationDisplay.svelte";
const { event, viewConfig }: { event: ExtendedEvent; viewConfig: EventViewConfig } = $props();
let loadedEvent = $state<ExtendedEvent>(event);
onMount(() => {
loadEvent();
});
async function loadEvent() {
loadedEvent = await $eventRepo.getEvent(event.event.id.toString());
}
let selectedView = $state<string>(Object.keys(viewConfig)[0]);
</script>
<div class="flex gap-4 overflow-x-auto mb-4">
{#each Object.entries(viewConfig) as [name, view]}
<button
class="mb-8 border-gray-700 border rounded-lg p-4 w-60 hover:bg-gray-700 hover:shadow-lg transition-shadow hover:border-gray-500"
class:bg-gray-800={selectedView === name}
onclick={() => (selectedView = name)}
>
<h1 class="text-left">{view.name}</h1>
</button>
{/each}
</div>
{#if selectedView}
{@const view = viewConfig[selectedView]}
<div class="overflow-x-scroll relative">
<ConnectionRenderer />
{#if view.view.type === "GROUP"}
<GroupDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "ELEMINATION"}
<EleminationDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "DOUBLE_ELEMINATION"}
<DoubleEleminationDisplay event={loadedEvent} config={view.view} />
{/if}
</div>
{/if}

View File

@@ -1,122 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import dayjs from "dayjs";
import * as Card from "../ui/card";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
// Categorize events into current, upcoming and past.
const now = dayjs();
const sorted = [...events].sort((a, b) => a.data.event.event.start - b.data.event.event.start);
const currentEvents = sorted
.filter((e) => {
const start = dayjs(e.data.event.event.start);
const end = dayjs(e.data.event.event.end);
return start.isBefore(now) && end.isAfter(now);
})
.sort((a, b) => a.data.event.event.end - b.data.event.event.end);
const currentEvent = currentEvents[0];
const upcomingEvents = sorted.filter((e) => dayjs(e.data.event.event.start).isAfter(now));
const pastEvents = sorted.filter((e) => dayjs(e.data.event.event.end).isBefore(now)).sort((a, b) => b.data.event.event.end - a.data.event.event.end);
</script>
{#if currentEvent}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Aktuelles Event</h2>
<div class="grid grid-cols-1">
<a href={`/events/${currentEvent.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(currentEvent.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{currentEvent.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Läuft seit {dayjs(currentEvent.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
</div>
</div>
{/if}
{#if upcomingEvents.length}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Bevorstehende Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each upcomingEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Startet um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}
{#if pastEvents.length}
<div class="mb-4">
<h2 class="text-xl font-semibold text-white mb-4">Vergangene Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 opacity-70">
{#each pastEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Stattgefunden um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { Calendar } from "lucide-svelte";
import { List } from "lucide-svelte";
import EventList from "./EventList.svelte";
import CalendarView from "./Calendar.svelte";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
let viewMode = $state<"list" | "calendar">("list");
</script>
<div class="flex flex-col gap-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-white">Events</h1>
</div>
<CalendarView {events} />
<EventList {events} />
</div>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import type { Team } from "@type/team.ts";
import { fightConnector } from "./connections.svelte";
import { teamHoverService } from "./team-hover.svelte";
const {
team,
score = "",
time = false,
showWinner = false,
isWinner = false,
noWinner = false,
id,
}: {
team: Team;
score?: string;
time?: boolean;
showWinner?: boolean;
isWinner?: boolean;
noWinner?: boolean;
id?: string;
} = $props();
let hoverService = $teamHoverService;
</script>
<button
class="flex justify-between px-2 w-full team-chip text-left {time ? 'py-1 hover:bg-gray-800' : 'py-3 cursor-pointer'} team-{team.id} {hoverService.currentHover === team.id
? 'bg-gray-800'
: ''} {showWinner ? 'border-l-4' : ''} {showWinner && isWinner ? 'border-l-yellow-500' : 'border-l-gray-950'}"
onmouseenter={() => team.id === -1 || hoverService.setHover(team.id)}
onmouseleave={() => team.id === -1 || hoverService.clearHover()}
{id}
>
<div class="flex">
<div class="w-12 {time ? 'font-bold' : ''}">{team.kuerzel}</div>
<span class={time ? "font-mono" : "font-bold"}>{team.name}</span>
</div>
<div class="{showWinner && isWinner && 'font-bold'} {isWinner ? 'text-yellow-400' : ''} {noWinner ? 'font-bold' : ''}">
{score}
</div>
</button>
<style>
.team-chip:not(:last-child) {
@apply border-b border-b-gray-700;
}
</style>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event.ts";
import type { GroupViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
import EventFightChip from "./EventFightChip.svelte";
const {
event,
config,
}: {
event: ExtendedEvent;
config: GroupViewConfig;
} = $props();
// Groups fights into rounds: a round starts at the first fight's start;
// all fights starting within 10 minutes (600_000 ms) of that are in the same round.
function detectRounds(fights: EventFight[]): EventFight[][] {
if (!fights || fights.length === 0) return [];
const TEN_MIN_MS = 10 * 60 * 1000;
const sorted = [...fights].sort((a, b) => a.start - b.start);
const rounds: EventFight[][] = [];
let currentRound: EventFight[] = [];
let roundStart = sorted[0].start;
for (const fight of sorted) {
if (fight.start - roundStart <= TEN_MIN_MS) {
currentRound.push(fight);
} else {
if (currentRound.length) rounds.push(currentRound);
currentRound = [fight];
roundStart = fight.start;
}
}
if (currentRound.length) rounds.push(currentRound);
return rounds;
}
</script>
{#each config.groups as groupId}
{@const group = event.groups.find((g) => g.id === groupId)!!}
{@const fights = event.fights.filter((f) => f.group?.id === groupId)}
{@const rounds = detectRounds(fights)}
<div class="flex">
<div>
<EventCard title={group.name}>
<EventCardOutline>
{#each Object.entries(group.points ?? {}).sort((v1, v2) => v2[1] - v1[1]) as points}
{@const [teamId, point] = points}
{@const team = event.teams.find((t) => t.id.toString() === teamId)!!}
<EventTeamChip {team} score={point.toString()} />
{/each}
</EventCardOutline>
</EventCard>
</div>
{#each rounds as round, index}
<div>
<EventCard title="Runde {index + 1}">
{#each round as fight}
<EventFightChip {fight} {group} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/each}

View File

@@ -1,54 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
const {
event,
}: {
event: ExtendedEvent;
} = $props();
let teams: Team[] = $state(event.teams);
const colorMap: Record<string, string> = {
"0": "#000000",
"1": "#0000AA",
"2": "#00AA00",
"3": "#00AAAA",
"4": "#AA0000",
"5": "#AA00AA",
"6": "#FFAA00",
"7": "#AAAAAA",
"8": "#555555",
"9": "#5555FF",
a: "#55FF55",
b: "#55FFFF",
c: "#FF5555",
d: "#FF55FF",
e: "#FFFF55",
f: "#FFFFFF",
};
onMount(async () => {
teams = await $eventRepo.listTeams(event.event.id.toString());
});
</script>
<div class="py-2 border-t border-t-gray-600">
<h1 class="text-2xl font-bold mb-4">Angemeldete Teams</h1>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{#each teams as team}
<div class="bg-neutral-800 p-2 rounded-md border border-neutral-700 border-l-4 flex flex-row items-center gap-2" style="border-left-color: {colorMap[team.color] || '#FFFFFF'}">
<span class="text-sm font-mono text-neutral-400 shrink-0 w-8 text-center">{team.kuerzel}</span>
<span class="font-bold truncate" title={team.name}>
{team.name}
</span>
</div>
{/each}
{#if teams.length === 0}
<p class="col-span-full text-center text-neutral-400">Keine Teams angemeldet.</p>
{/if}
</div>
</div>

View File

@@ -1,55 +0,0 @@
import { readonly, writable } from "svelte/store";
class FightConnection {
constructor(
public readonly fromElement: HTMLElement,
public readonly toElement: HTMLElement,
public readonly color: string = "white",
public readonly background: boolean,
public readonly offset: number = 0
) {}
}
export class FightConnector {
private connections: FightConnection[] = $state([]);
get allConnections(): FightConnection[] {
return this.connections;
}
get showedConnections(): FightConnection[] {
const showBackground = this.connections.some((conn) => !conn.background);
return showBackground ? this.connections.filter((conn) => !conn.background) : this.connections;
}
addTeamConnection(teamId: number): void {
const teamElements = document.getElementsByClassName(`team-${teamId}`);
const teamArray = Array.from(teamElements);
teamArray.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.top - rectB.top || rectA.left - rectB.left;
});
for (let i = 1; i < teamElements.length; i++) {
const fromElement = teamElements[i - 1] as HTMLElement;
const toElement = teamElements[i] as HTMLElement;
this.connections.push(new FightConnection(fromElement, toElement, "white", false));
}
}
addConnection(fromElement: HTMLElement, toElement: HTMLElement, color: string = "white", offset: number = 0): void {
this.connections.push(new FightConnection(fromElement, toElement, color, true, offset));
}
clearConnections(): void {
this.connections = this.connections.filter((conn) => conn.background);
}
clearAllConnections(): void {
this.connections = [];
}
}
const fightConnectorInternal = writable(new FightConnector());
export const fightConnector = readonly(fightConnectorInternal);

View File

@@ -1,19 +0,0 @@
import { get, writable } from "svelte/store";
import { fightConnector } from "./connections.svelte";
class TeamHoverService {
public currentHover = $state<number | undefined>(undefined);
private fightConnector = get(fightConnector);
setHover(teamId: number): void {
this.currentHover = teamId;
this.fightConnector.addTeamConnection(teamId);
}
clearHover(): void {
this.currentHover = undefined;
this.fightConnector.clearConnections();
}
}
export const teamHoverService = writable(new TeamHoverService());

View File

@@ -1,34 +0,0 @@
import { z } from "astro:content";
export const GroupViewSchema = z.object({
type: z.literal("GROUP"),
groups: z.array(z.number()),
});
export type GroupViewConfig = z.infer<typeof GroupViewSchema>;
export const EleminationViewSchema = z.object({
type: z.literal("ELEMINATION"),
finalFight: z.number(),
});
export type EleminationViewConfig = z.infer<typeof EleminationViewSchema>;
// Double elimination config: needs final fight (grand final) and entry fights for winners & losers brackets
export const DoubleEleminationViewSchema = z.object({
type: z.literal("DOUBLE_ELEMINATION"),
winnersFinalFight: z.number(), // Final fight of winners bracket (feeds into grand final)
losersFinalFight: z.number(), // Final fight of losers bracket (feeds into grand final)
grandFinalFight: z.number(), // Grand final fight id
});
export type DoubleEleminationViewConfig = z.infer<typeof DoubleEleminationViewSchema>;
export const EventViewConfigSchema = z.record(
z.object({
name: z.string(),
view: z.discriminatedUnion("type", [GroupViewSchema, EleminationViewSchema, DoubleEleminationViewSchema]),
})
);
export type EventViewConfig = z.infer<typeof EventViewConfigSchema>;

View File

@@ -18,38 +18,39 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { RouteDefinition } from "svelte-spa-router"; import type {RouteDefinition} from "svelte-spa-router";
import Router from "svelte-spa-router"; import Router from "svelte-spa-router";
import NavLinks from "@components/moderator/layout/NavLinks.svelte"; import NavLinks from "@components/moderator/layout/NavLinks.svelte";
import {Switch} from "@components/ui/switch";
import {Label} from "@components/ui/label";
import {navigate} from "astro:transitions/client";
import Players from "@components/moderator/pages/players/Players.svelte"; import Players from "@components/moderator/pages/players/Players.svelte";
import Events from "@components/moderator/pages/events/Events.svelte"; import Events from "@components/moderator/pages/events/Events.svelte";
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte"; import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
import Event from "@components/moderator/pages/event/Event.svelte"; import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte";
import AuditLog from "@components/moderator/pages/logs/AuditLog.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = { const routes: RouteDefinition = {
"/": Dashboard, "/": Dashboard,
"/events": Events, "/events": Events,
"/players": Players, "/players": Players,
"/event/:id": Event, "/event/:id": Event
"/event/:id/generate": Generator,
"/pages": Pages,
"/logs": AuditLog,
}; };
</script> </script>
<div class="flex flex-col bg-background min-w-full min-h-screen"> <div class="flex flex-col bg-background min-w-full min-h-screen">
<div class="border-b"> <div class="border-b">
<div class="flex h-16 items-center px-4"> <div class="flex h-16 items-center px-4">
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a> <a href="/" class="text-sm font-bold transition-colors text-primary">
SteamWar
</a>
<NavLinks /> <NavLinks />
<div class="ml-auto flex items-center space-x-4">
<Switch id="new-ui-switch" checked={true} on:click={() => navigate("/admin")} />
<Label for="new-ui-switch">New UI!</Label>
</div>
</div> </div>
</div> </div>
<main class="flex flex-col">
<Tooltip.Provider>
<Router {routes} /> <Router {routes} />
</Tooltip.Provider> </main>
</div> </div>

View File

@@ -1,170 +0,0 @@
<script lang="ts">
import GroupSelector from "./GroupSelector.svelte";
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { fromAbsolute } from "@internationalized/date";
import { Label } from "@components/ui/label";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { gamemodes, maps } from "@components/stores/stores";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDown, Check } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import type { Snippet } from "svelte";
import { Input } from "@components/ui/input";
import TeamSelector from "./TeamSelector.svelte";
import type { EventModel } from "../pages/event/eventmodel.svelte";
let {
fight,
actions,
onSave,
data,
}: {
fight: EventFight | null;
actions: Snippet<[boolean, () => void]>;
onSave: (fight: EventFightEdit) => void;
data: EventModel;
} = $props();
let fightModus = $state(fight?.spielmodus);
let fightMap = $state(fight?.map);
let fightBlueTeam = $state(fight?.blueTeam);
let fightRedTeam = $state(fight?.redTeam);
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(data.event.start, "Europe/Berlin"));
let fightErgebnis = $state(fight?.ergebnis ?? 0);
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
let fightGroup = $state(fight?.group?.id ?? null);
let mapsStore = $derived(maps(fightModus ?? "null"));
let gamemodeSelectOpen = $state(false);
let mapSelectOpen = $state(false);
let dirty = $derived(
fightModus !== fight?.spielmodus ||
fightMap !== fight?.map ||
fightBlueTeam?.id !== fight?.blueTeam?.id ||
fightRedTeam?.id !== fight?.redTeam?.id ||
fightStart.toDate().getTime() !== fight?.start ||
fightErgebnis !== fight?.ergebnis ||
fightSpectatePort !== (fight?.spectatePort?.toString() ?? null) ||
fightGroup !== (fight?.group?.id ?? null)
);
let loading = $state(false);
async function submit() {
loading = true;
try {
await onSave({
spielmodus: fightModus!,
map: fightMap!,
blueTeam: fightBlueTeam!,
redTeam: fightRedTeam!,
start: fightStart?.toDate().getTime(),
ergebnis: fightErgebnis,
spectatePort: fightSpectatePort ? +fightSpectatePort : null,
group: fightGroup,
});
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col gap-2">
<Label for="fight-modus">Modus</Label>
<Popover bind:open={gamemodeSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$gamemodes.find((value) => value === fightModus) || fightModus || "Select a modus type..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Fight Modus..." />
<CommandList>
<CommandEmpty>No fight modus found.</CommandEmpty>
<CommandGroup>
{#each $gamemodes as modus}
<CommandItem
value={modus}
onSelect={() => {
fightModus = modus;
gamemodeSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", modus !== fightModus && "text-transparent")} />
{modus}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-map">Map</Label>
<Popover bind:open={mapSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$mapsStore.find((value) => value === fightMap) || fightMap || "Select a map..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Maps..." />
<CommandList>
<CommandEmpty>No map found.</CommandEmpty>
<CommandGroup>
{#each $mapsStore as map}
<CommandItem
value={map}
onSelect={() => {
fightMap = map;
mapSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", map !== fightMap && "text-transparent")} />
{map}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label>Blue Team</Label>
<TeamSelector bind:selectedTeam={fightBlueTeam} {data} fightId={fight?.id} team="BLUE" />
<Label>Red Team</Label>
<TeamSelector bind:selectedTeam={fightRedTeam} {data} fightId={fight?.id} team="RED" />
<Label>Start</Label>
<DateTimePicker bind:value={fightStart} />
{#if fight !== null}
<Label for="fight-ergebnis">Ergebnis</Label>
<Select type="single" value={fightErgebnis?.toString()} onValueChange={(v) => (fightErgebnis = +v)}>
<SelectTrigger>
{fightErgebnis === 0 ? "Unentschieden" : (fightErgebnis === 1 ? fightBlueTeam?.name : fightRedTeam?.name) + " gewinnt"}
</SelectTrigger>
<SelectContent>
<SelectItem value={"0"}>Unentschieden</SelectItem>
<SelectItem value={"1"}>{fightBlueTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
<SelectItem value={"2"}>{fightRedTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
</SelectContent>
</Select>
{/if}
<Label for="fight-group">Gruppe</Label>
<GroupSelector event={data.event} bind:value={fightGroup} bind:groups={data.groups}></GroupSelector>
<Label for="spectate-port">Spectate Port</Label>
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
</div>
{@render actions(dirty && !loading, submit)}

View File

@@ -1,78 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ResponseGroups, GroupUpdateEdit } from "@type/event";
import { Label } from "@components/ui/label";
import { Input } from "@components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
const {
group,
actions,
onSave,
}: {
group: ResponseGroups | null;
actions: Snippet<[boolean, () => void]>;
onSave: (groupData: GroupUpdateEdit) => void;
} = $props();
let groupName = $state(group?.name ?? "");
let groupType = $state(group?.type ?? "GROUP_STAGE");
let pointsPerWin = $state(group?.pointsPerWin ?? 3);
let pointsPerLoss = $state(group?.pointsPerLoss ?? 0);
let pointsPerDraw = $state(group?.pointsPerDraw ?? 1);
let canSave = $derived(groupName.length > 0 && (groupType === "GROUP_STAGE" || groupType === "ELIMINATION_STAGE") && pointsPerWin !== null && pointsPerLoss !== null && pointsPerDraw !== null);
let dirty = $derived(
groupName !== (group ? group.name : "") ||
groupType !== (group ? group.type : "GROUP_STAGE") ||
pointsPerWin !== (group ? group.pointsPerWin : 3) ||
pointsPerLoss !== (group ? group.pointsPerLoss : 0) ||
pointsPerDraw !== (group ? group.pointsPerDraw : 1)
);
function submit() {
onSave({
name: groupName,
type: groupType,
pointsPerWin: pointsPerWin,
pointsPerLoss: pointsPerLoss,
pointsPerDraw: pointsPerDraw,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="group-name">Name</Label>
<Input id="group-name" bind:value={groupName} placeholder="z.B. Gruppenphase A" />
<Label for="group-type">Typ</Label>
<Select
value={groupType}
type="single"
onValueChange={(v) => {
if (v) groupType = v as "GROUP_STAGE" | "ELIMINATION_STAGE";
}}
>
<SelectTrigger id="group-type" placeholder="Wähle einen Gruppentyp">
{groupType === "GROUP_STAGE" ? "Gruppenphase" : "Eliminierungsphase"}
</SelectTrigger>
<SelectContent>
<SelectItem value="GROUP_STAGE">Gruppenphase</SelectItem>
<SelectItem value="ELIMINATION_STAGE">Eliminierungsphase</SelectItem>
</SelectContent>
</Select>
{#if groupType === "GROUP_STAGE" && group !== null}
<Label for="points-win">Punkte pro Sieg</Label>
<Input id="points-win" type="number" bind:value={pointsPerWin} placeholder="3" />
<Label for="points-loss">Punkte pro Niederlage</Label>
<Input id="points-loss" type="number" bind:value={pointsPerLoss} placeholder="0" />
<Label for="points-draw">Punkte pro Unentschieden</Label>
<Input id="points-draw" type="number" bind:value={pointsPerDraw} placeholder="1" />
{/if}
</div>
{@render actions(group === null ? canSave : dirty, submit)}

View File

@@ -1,103 +0,0 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDownIcon, PlusIcon, CheckIcon, MinusIcon } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "./GroupEdit.svelte";
import { eventRepo } from "@components/repo/event";
let {
event,
groups = $bindable(),
value = $bindable(),
}: {
event: SWEvent;
groups: ResponseGroups[];
value: number | null;
} = $props();
let selectedGroup = $derived(groups.find((group) => group.id === value));
let createOpen = $state(false);
let groupSelectOpen = $state(false);
async function handleGroupSave(group: GroupUpdateEdit) {
let g = await $eventRepo.createGroup(event.id, group);
groups.push(g);
value = g.id;
createOpen = false;
groupSelectOpen = false;
}
</script>
<Dialog bind:open={createOpen}>
<Popover bind:open={groupSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button id="fight-group" variant="outline" class="justify-between" {...props} role="combobox">
{selectedGroup?.name || "Keine Gruppe"}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Gruppe suchen..." />
<CommandList>
<CommandGroup>
<CommandItem value={"new"} onSelect={() => (createOpen = true)}>
<PlusIcon class={"mr-2 size-4"} />
Neue Gruppe
</CommandItem>
<CommandGroup heading="Gruppen">
<CommandItem
value={"none"}
onSelect={() => {
value = null;
groupSelectOpen = false;
}}
>
{#if value === null}
<CheckIcon class={"mr-2 size-4"} />
{:else}
<MinusIcon class={"mr-2 size-4"} />
{/if}
Keine Gruppe
</CommandItem>
{#each groups as group}
<CommandItem
value={group.id.toString()}
onSelect={() => {
value = group.id;
groupSelectOpen = false;
}}
>
<CheckIcon class={cn("mr-2 size-4", value !== group.id && "text-transparent")} />
{group.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
<DialogDescription>Hier kannst du eine neue Gruppe erstellen</DialogDescription>
</DialogHeader>
<GroupEdit group={null} onSave={handleGroupSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>

View File

@@ -1,253 +0,0 @@
<script lang="ts">
import type { ResponseRelation } from "@components/types/event";
import type { Team } from "@components/types/team";
import { Button } from "@components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
import { cn } from "@components/utils";
import { Check, ChevronsUpDown, GitPullRequestArrow, Plus } from "lucide-svelte";
import type { EventModel } from "../pages/event/eventmodel.svelte";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Label } from "@components/ui/label";
import { eventRepo } from "@components/repo/event";
interface Props {
selectedTeam: Team | undefined;
open?: boolean;
team: "BLUE" | "RED";
data: EventModel;
fightId?: number;
onSelect?: (team: Team) => void;
}
let { selectedTeam = $bindable(), data, team, open = $bindable(false), fightId, onSelect }: Props = $props();
const currentRelation = $derived(data.relations.find((r) => r.fight === fightId && r.team === team));
let fromType = $state<"FIGHT" | "GROUP">(currentRelation?.type ?? "FIGHT");
let fromFight = $state<string | undefined>(currentRelation?.fromFight?.id?.toString());
let fromFightData = $derived(data.fights.find((f) => f.id.toString() === fromFight));
let fromGroup = $state<string | undefined>(currentRelation?.fromGroup?.id?.toString());
let fromGroupData = $derived(data.groups.find((g) => g.id.toString() === fromGroup));
let fromPlace = $state<string | undefined>(currentRelation?.fromPlace?.toString());
let relationOpen = $state(false);
async function saveRelation() {
relationOpen = false;
if (currentRelation === undefined) {
await $eventRepo.createRelation(data.event.id, {
fightId: fightId!,
team,
fromType,
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
fromPlace: parseInt(fromPlace!),
});
} else {
await $eventRepo.updateRelation(data.event.id, currentRelation.id, {
from: {
fromType,
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
fromPlace: parseInt(fromPlace!),
},
});
}
data.relations = await $eventRepo.listRelations(data.event.id);
reset();
}
async function clearRelation() {
relationOpen = false;
if (currentRelation !== undefined) {
await $eventRepo.deleteRelation(data.event.id, currentRelation.id);
data.relations = await $eventRepo.listRelations(data.event.id);
}
reset();
}
function reset() {
fromType = currentRelation?.type ?? "FIGHT";
fromFight = currentRelation?.fromFight?.id.toString();
fromGroup = currentRelation?.fromGroup?.id.toString();
fromPlace = currentRelation?.fromPlace.toString();
}
let canSave = $derived(
(fromType !== currentRelation?.type ||
fromFight !== (currentRelation?.fromFight?.id.toString() ?? "") ||
fromGroup !== (currentRelation?.fromGroup?.id.toString() ?? "") ||
fromPlace !== (currentRelation?.fromPlace.toString() ?? "")) &&
((fromType === "FIGHT" && fromFight !== "" && fromPlace !== "") || (fromType === "GROUP" && fromGroup !== "" && fromPlace !== ""))
);
</script>
<div class="flex gap-2">
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{#if selectedTeam?.id === -1}
???
{:else if selectedTeam?.id === 0}
PUB
{:else}
{data.teams.find((v) => v.id === selectedTeam?.id)?.name || selectedTeam?.name || "Select a team..."}
{/if}
{#if currentRelation !== undefined}
({#if currentRelation.type === "FIGHT"}
{currentRelation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von {currentRelation.fromFight?.blueTeam.name} vs {currentRelation.fromFight?.redTeam.name} ({new Date(
currentRelation.fromFight?.start ?? 0
).toLocaleTimeString("de-DE", {
timeStyle: "short",
})})
{:else}
{currentRelation.fromPlace + 1}. Platz von {currentRelation.fromGroup?.name}
{/if})
{/if}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Teams..." />
<CommandList>
<CommandEmpty>No team found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"-1"}
onSelect={() => {
selectedTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
onSelect?.(selectedTeam);
open = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
selectedTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
onSelect?.(selectedTeam);
open = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each data.teams as team}
<CommandItem
value={team.name}
onSelect={() => {
selectedTeam = team;
onSelect?.(selectedTeam);
open = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== selectedTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover bind:open={relationOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Tooltip>
<TooltipTrigger>
<Button {...props} size="icon" variant={currentRelation !== undefined ? "default" : "outline"} disabled={fightId === undefined}>
<GitPullRequestArrow />
</Button>
</TooltipTrigger>
<TooltipContent>Kampfverbindung</TooltipContent>
</Tooltip>
{/snippet}
</PopoverTrigger>
<PopoverContent>
<Tabs bind:value={fromType}>
<TabsList>
<TabsTrigger value="FIGHT">Kampf</TabsTrigger>
<TabsTrigger value="GROUP">Gruppe</TabsTrigger>
</TabsList>
<TabsContent value="FIGHT">
<Label>Kampf</Label>
<Select bind:value={fromFight} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromFightData
? `${new Date(fromFightData.start).toLocaleString("de-DE", { timeStyle: "short" })}: ${fromFightData.blueTeam.kuerzel} vs. ${fromFightData.redTeam.kuerzel}`
: "Kampf auswählen..."}
</SelectTrigger>
<SelectContent>
{#each data.fights.filter((v) => v.id !== fightId) as fight (fight.id)}
<SelectItem value={fight.id.toString()}
>{new Date(fight.start).toLocaleString("de-DE", {
timeStyle: "short",
})}: {fight.blueTeam.kuerzel} vs. {fight.redTeam.kuerzel}</SelectItem
>
{/each}
</SelectContent>
</Select>
<Label>Team</Label>
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromPlace ? (fromPlace === "0" ? "Gewinner" : "Verlierer") : "Platz auswählen..."}
</SelectTrigger>
<SelectContent>
<SelectItem value={"0"}>Gewinner</SelectItem>
<SelectItem value={"1"}>Verlierer</SelectItem>
</SelectContent>
</Select>
</TabsContent>
<TabsContent value="GROUP">
<Label>Gruppe</Label>
<Select bind:value={fromGroup} type="single" disabled={data.groups.length === 0}>
<SelectTrigger>
{fromGroupData ? fromGroupData.name : "Kampf auswählen..."}
</SelectTrigger>
<SelectContent>
{#each data.groups as group (group.id)}
<SelectItem value={group.id.toString()}>{group.name}</SelectItem>
{/each}
</SelectContent>
</Select>
<Label>Platz</Label>
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromPlace ? `${parseInt(fromPlace) + 1}. Platz` : "Platz auswählen..."}
</SelectTrigger>
<SelectContent>
{#each Array(32) as _, i}
<SelectItem value={i.toString()}>{i + 1}. Platz</SelectItem>
{/each}
</SelectContent>
</Select>
</TabsContent>
</Tabs>
<div class="flex justify-end gap-2 mt-2">
<Button onclick={clearRelation} variant="destructive">Löschen</Button>
<Button onclick={saveRelation} disabled={!canSave}>Übernehmen</Button>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -18,14 +18,23 @@
--> -->
<script lang="ts"> <script lang="ts">
import { location } from "svelte-spa-router"; import {location} from "svelte-spa-router";
</script> </script>
<nav class="flex items-center space-x-4 lg:space-x-6 mx-6"> <nav class="flex items-center space-x-4 lg:space-x-6 mx-6">
<a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/"}> Dashboard </a> <a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/"}>
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a> Dashboard
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a> </a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a> <a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/events"}>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a> Events
<a href="#/logs" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/logs"}> Logs </a> </a>
</nav> <a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/players"}>
Players
</a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/pages"}>
Pages
</a>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/schematics"}>
Schematics
</a>
</nav>

View File

@@ -18,11 +18,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import { eventRepo } from "@repo/event.ts"; import {eventRepo} from "@repo/event.ts";
import EventView from "@components/moderator/pages/event/EventView.svelte"; import EventView from "@components/moderator/pages/event/EventView.svelte";
import type { ExtendedEvent } from "@components/types/event";
import { onMount } from "svelte";
import { EventModel } from "./eventmodel.svelte";
interface Props { interface Props {
params: { id: number }; params: { id: number };
@@ -31,21 +28,11 @@
let { params }: Props = $props(); let { params }: Props = $props();
let id = params.id; let id = params.id;
let data: EventModel | undefined = $state(undefined); let event = $eventRepo.getEvent(id.toString());
let loaded = $state(false);
onMount(async () => {
refresh();
});
async function refresh() {
data = new EventModel(await $eventRepo.getEvent(id.toString()));
loaded = true;
}
</script> </script>
{#if loaded} {#await event}
<EventView bind:event={data!!} {refresh} />
{:else}
<p>Loading...</p> <p>Loading...</p>
{/if} {:then data}
<EventView event={data} />
{/await}

View File

@@ -18,35 +18,24 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Input } from "@components/ui/input"; import {Input} from "@components/ui/input";
import { Label } from "@components/ui/label"; import {Label} from "@components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"; import {Popover, PopoverContent, PopoverTrigger} from "@components/ui/popover";
import type { SWEvent } from "@type/event.ts"; import type {SWEvent} from "@type/event.ts"
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte"; import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { fromAbsolute } from "@internationalized/date"; import {fromAbsolute} from "@internationalized/date";
import { Button, buttonVariants } from "@components/ui/button"; import {Button} from "@components/ui/button";
import { ChevronsUpDown } from "lucide-svelte"; import {ChevronsUpDown} from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command"; import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "@components/ui/command";
import { schemTypes } from "@stores/stores.ts"; import {schemTypes} from "@stores/stores.ts";
import Check from "lucide-svelte/icons/check"; import Check from "lucide-svelte/icons/check";
import { cn } from "@components/utils.ts"; import {cn} from "@components/utils.ts";
import { Switch } from "@components/ui/switch"; import {Switch} from "@components/ui/switch";
import { eventRepo } from "@repo/event.ts"; import {eventRepo} from "@repo/event.ts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@components/ui/alert-dialog";
const { event }: { event: SWEvent } = $props(); const { event }: { event: SWEvent } = $props();
let rootEvent: SWEvent = $state(event); let rootEvent: SWEvent = $state(event)
let eventName = $state(rootEvent.name); let eventName = $state(rootEvent.name);
let eventDeadline = $state(fromAbsolute(rootEvent.deadline, "Europe/Berlin")); let eventDeadline = $state(fromAbsolute(rootEvent.deadline, "Europe/Berlin"));
@@ -56,15 +45,13 @@
let eventSchematicType = $state(rootEvent.schemType); let eventSchematicType = $state(rootEvent.schemType);
let eventPublicsOnly = $state(rootEvent.publicSchemsOnly); let eventPublicsOnly = $state(rootEvent.publicSchemsOnly);
let dirty = $derived( let dirty = $derived(eventName !== rootEvent.name ||
eventName !== rootEvent.name || eventDeadline.toDate().getTime() !== rootEvent.deadline ||
eventDeadline.toDate().getTime() !== rootEvent.deadline || eventStart.toDate().getTime() !== rootEvent.start ||
eventStart.toDate().getTime() !== rootEvent.start || eventEnd.toDate().getTime() !== rootEvent.end ||
eventEnd.toDate().getTime() !== rootEvent.end || eventTeamSize !== rootEvent.maxTeamMembers ||
eventTeamSize !== rootEvent.maxTeamMembers || eventSchematicType !== rootEvent.schemType ||
eventSchematicType !== rootEvent.schemType || eventPublicsOnly !== rootEvent.publicSchemsOnly);
eventPublicsOnly !== rootEvent.publicSchemsOnly
);
async function updateEvent() { async function updateEvent() {
rootEvent = await $eventRepo.updateEvent(event.id.toString(), { rootEvent = await $eventRepo.updateEvent(event.id.toString(), {
@@ -75,7 +62,7 @@
maxTeamMembers: eventTeamSize, maxTeamMembers: eventTeamSize,
schemType: eventSchematicType, schemType: eventSchematicType,
publicSchemsOnly: eventPublicsOnly, publicSchemsOnly: eventPublicsOnly,
}); })
} }
</script> </script>
@@ -94,8 +81,13 @@
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox"> <Button
{$schemTypes.find((value) => value.db === eventSchematicType)?.name || eventSchematicType || "Select a schematic type..."} variant="outline"
class="justify-between"
{...props}
role="combobox"
>
{$schemTypes.find(value => value.db === eventSchematicType)?.name || eventSchematicType || "Select a schematic type..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
{/snippet} {/snippet}
@@ -106,23 +98,19 @@
<CommandList> <CommandList>
<CommandEmpty>No schematic type found.</CommandEmpty> <CommandEmpty>No schematic type found.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem
value={"null"}
onSelect={() => {
eventSchematicType = null;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
Keinen
</CommandItem>
{#each $schemTypes as type} {#each $schemTypes as type}
<CommandItem <CommandItem
value={type.db} value={type.db}
onSelect={() => { onSelect={() => {
eventSchematicType = type.db; eventSchematicType = type.db;
}} }}
> >
<Check class={cn("mr-2 size-4", eventSchematicType !== type.db && "text-transparent")} /> <Check
class={cn(
"mr-2 size-4",
eventSchematicType !== type.db && "text-transparent"
)}
/>
{type.name} {type.name}
</CommandItem> </CommandItem>
{/each} {/each}
@@ -134,19 +122,7 @@
<Label for="event-publics">Publics Schematics Only</Label> <Label for="event-publics">Publics Schematics Only</Label>
<Switch id="event-publics" bind:checked={eventPublicsOnly} /> <Switch id="event-publics" bind:checked={eventPublicsOnly} />
<div class="flex flex-row justify-end border-t pt-2 gap-4"> <div class="flex flex-row justify-end border-t pt-2 gap-4">
<AlertDialog> <Button variant="destructive">Delete</Button>
<AlertDialogTrigger class={buttonVariants({ variant: "destructive" })}>Delete</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button disabled={!dirty} onclick={updateEvent}>Update</Button> <Button disabled={!dirty} onclick={updateEvent}>Update</Button>
</div> </div>
</div> </div>

View File

@@ -18,41 +18,26 @@
--> -->
<script lang="ts"> <script lang="ts">
import FightEditRow from "./FightEditRow.svelte"; import type {ExtendedEvent} from "@type/event";
import {createSvelteTable, FlexRender} from "@components/ui/data-table";
import {
type ColumnFiltersState,
getCoreRowModel, getFilteredRowModel,
getPaginationRowModel, getSortedRowModel,
type SortingState,
} from "@tanstack/table-core";
import { columns } from "./columns"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
import type { EventFightEdit } from "@type/event"; let { data }: { data: ExtendedEvent } = $props();
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
import { columns } from "./columns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Checkbox } from "@components/ui/checkbox";
import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarTrigger, MenubarSub, MenubarSubTrigger, MenubarSubContent } from "@components/ui/menubar";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@components/repo/event";
import GroupEditDialog from "./GroupEditDialog.svelte";
import GroupResultsDialog from "./GroupResultsDialog.svelte";
import type { ResponseGroups } from "@type/event";
import { EditIcon, GroupIcon, LinkIcon } from "lucide-svelte";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
import { fightRepo } from "@components/repo/fight";
import type { EventModel } from "./eventmodel.svelte";
let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
let sorting = $state<SortingState>([]); let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]); let columnFilters = $state<ColumnFiltersState>([]);
let selection = $state<RowSelectionState>({});
const table = createSvelteTable({ const table = createSvelteTable({
get data() { get data() {
return data.fights; return data.fights;
}, },
initialState: {
columnOrder: ["auswahl", "begegnung", "group"],
},
state: { state: {
get sorting() { get sorting() {
return sorting; return sorting;
@@ -60,12 +45,6 @@
get columnFilters() { get columnFilters() {
return columnFilters; return columnFilters;
}, },
get grouping() {
return ["group"];
},
get rowSelection() {
return selection;
},
}, },
onSortingChange: (updater) => { onSortingChange: (updater) => {
if (typeof updater === "function") { if (typeof updater === "function") {
@@ -81,163 +60,13 @@
columnFilters = updater; columnFilters = updater;
} }
}, },
onRowSelectionChange: (updater) => {
if (typeof updater === "function") {
selection = updater(selection);
} else {
selection = updater;
}
},
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getGroupedRowModel: getGroupedRowModel(),
groupedColumnMode: "remove",
getRowId: (row) => row.id.toString(),
}); });
let createOpen = $state(false);
let editGroupOpen = $state(false);
let selectedGroup: ResponseGroups | null = $state(null);
let groupResultsOpen = $state(false);
let selectedGroupForResults: ResponseGroups | null = $state(null);
let groupChangeOpen = $state(false);
let groupChangeSelected: number | null = $state(null);
async function handleSave(fight: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fight,
blueTeam: fight.blueTeam.id,
redTeam: fight.redTeam.id,
});
refresh();
createOpen = false;
}
function openGroupEditDialog(group: ResponseGroups) {
selectedGroup = group;
editGroupOpen = true;
}
function openGroupResultsDialog(group: ResponseGroups) {
selectedGroupForResults = group;
groupResultsOpen = true;
}
</script> </script>
<Dialog bind:open={createOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight Erstellen</DialogTitle>
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
</DialogHeader>
<FightEdit fight={null} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
{#if selectedGroup}
<GroupEditDialog bind:open={editGroupOpen} group={selectedGroup} event={data.event} bind:groups={data.groups} />
{/if}
{#if selectedGroupForResults}
<GroupResultsDialog bind:open={groupResultsOpen} group={selectedGroupForResults} teams={data.teams} fights={data.fights} />
{/if}
<Dialog bind:open={groupChangeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Ändern</DialogTitle>
<DialogDescription>Hier kannst du die Gruppe der ausgewählten Kämpfe ändern</DialogDescription>
</DialogHeader>
<GroupSelector event={data.event} bind:groups={data.groups} bind:value={groupChangeSelected} />
<DialogFooter>
<Button
onclick={async () => {
groupChangeOpen = false;
let group = data.groups.find((g) => g.id === groupChangeSelected);
if (group) {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.updateFight(data.event.id, g.id, {
group: group.id,
spielmodus: null,
map: null,
blueTeam: null,
redTeam: null,
start: null,
spectatePort: null,
});
}
refresh();
}
}}>Speichern</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex items-center justify-between">
<Menubar>
<MenubarMenu>
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
<MenubarContent>
<MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
<MenubarItem disabled>Spectate Port Ändern</MenubarItem>
<MenubarItem
onclick={async () => {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.deleteFight(data.event.id, g.id);
}
refresh();
}}>Kämpfe Löschen</MenubarItem
>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Erstellen</MenubarTrigger>
<MenubarContent>
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
<MenubarGroup>
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
<a href="#/event/{data.event.id}/generate">
<MenubarItem>Gruppenphase</MenubarItem>
</a>
</MenubarGroup>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger disabled={!data.groups.length}>Gruppen</MenubarTrigger>
<MenubarContent>
{#each data.groups as group (group.id)}
<MenubarSub>
<MenubarSubTrigger>
{group.name}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem onclick={() => openGroupEditDialog(group)}>Bearbeiten</MenubarItem>
<MenubarItem onclick={() => openGroupResultsDialog(group)}>Gruppen Ergebnisse</MenubarItem>
</MenubarSubContent>
</MenubarSub>
{/each}
</MenubarContent>
</MenubarMenu>
</Menubar>
<Button variant="outline" class="ml-4" onclick={refresh}>Neu laden</Button>
</div>
<Table> <Table>
<TableHeader> <TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
@@ -245,81 +74,34 @@
{#each headerGroup.headers as header (header.id)} {#each headerGroup.headers as header (header.id)}
<TableHead> <TableHead>
{#if !header.isPlaceholder} {#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} /> <FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if} {/if}
</TableHead> </TableHead>
{/each} {/each}
<TableHead></TableHead>
</TableRow> </TableRow>
{/each} {/each}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)} {#each table.getRowModel().rows as row (row.id)}
{#if groupRow.getIsGrouped()} <TableRow data-state={row.getIsSelected() && "selected"}>
{@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))} {#each row.getVisibleCells() as cell (cell.id)}
<TableRow class="font-bold"> <TableCell>
<TableCell colspan={columns.length - 1}> <FlexRender
<Checkbox content={cell.column.columnDef.cell}
checked={groupRow.getIsSelected()} context={cell.getContext()}
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
onCheckedChange={() => groupRow.toggleSelected()}
class="mr-4"
/> />
{group?.name ?? "Keine Gruppe"}
</TableCell> </TableCell>
<TableCell class="text-right">
{#if group}
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group!)}>
<EditIcon />
</Button>
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group!)}>
<GroupIcon />
</Button>
{/if}
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<LinkIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<group-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Punkte Tabelle</DropdownMenuItem
>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<fight-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Kampf Tabelle</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{#each groupRow.subRows as row (row.id)}
<TableRow data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
<TableCell class="text-right">
<FightEditRow fight={row.original} {data} onupdate={(update) => (data._fights = data._fights.map((v) => (v.id === update.id ? update : v)))} {refresh}></FightEditRow>
</TableCell>
</TableRow>
{/each} {/each}
{:else} </TableRow>
<TableRow data-state={groupRow.getIsSelected() && "selected"}>
{#each groupRow.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{/if}
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No results.</TableCell> <TableCell colspan={columns.length} class="h-24 text-center">
No results.
</TableCell>
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -18,13 +18,14 @@
--> -->
<script lang="ts"> <script lang="ts">
import type {ExtendedEvent} from "@type/event.ts";
import EventEdit from "@components/moderator/pages/event/EventEdit.svelte"; import EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
import EventFightList from "@components/moderator/pages/event/EventFightList.svelte"; import EventFightList from "@components/moderator/pages/event/EventFightList.svelte";
import RefereesList from "@components/moderator/pages/event/RefereesList.svelte"; import RefereesList from "@components/moderator/pages/event/RefereesList.svelte";
import TeamTable from "@components/moderator/pages/event/TeamTable.svelte";
import type { EventModel } from "./eventmodel.svelte";
let { event = $bindable(), refresh }: { event: EventModel; refresh: () => void } = $props(); const {
event
}: { event: ExtendedEvent } = $props();
</script> </script>
<div class="flex flex-col m-4 p-4 rounded-md border gap-4"> <div class="flex flex-col m-4 p-4 rounded-md border gap-4">
@@ -34,13 +35,13 @@
<EventEdit event={event.event} /> <EventEdit event={event.event} />
</div> </div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3"> <div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Teams</h2> <h2>Teams</h2>
<TeamTable bind:event />
</div> </div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3"> <div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Referees</h2> <h2>Referees</h2>
<RefereesList {event} /> <RefereesList event={event} />
</div> </div>
</div> </div>
<EventFightList bind:data={event} {refresh} /> <EventFightList data={event} />
</div> </div>

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { Button } from "@components/ui/button";
import { EditIcon, CopyIcon } from "lucide-svelte";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import type { Team } from "@components/types/team";
import { fightRepo } from "@components/repo/fight";
import { eventRepo } from "@components/repo/event";
import type { EventModel } from "./eventmodel.svelte";
let { fight, onupdate, refresh, data }: { fight: EventFight; onupdate: (update: EventFight) => void; refresh: () => void; data: EventModel } = $props();
let editOpen = $state(false);
let duplicateOpen = $state(false);
async function handleSave(fightData: EventFightEdit) {
let f = await $fightRepo.updateFight(data.event.id, fight.id, {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
group: fightData.group ?? -1,
});
onupdate(f);
editOpen = false;
}
async function handlyCopy(fightData: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
});
refresh();
duplicateOpen = false;
}
async function handleDelete() {
await $fightRepo.deleteFight(data.event.id, fight.id);
refresh();
}
</script>
<div>
<Dialog bind:open={editOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<EditIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight bearbeiten</DialogTitle>
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
</DialogHeader>
<FightEdit {fight} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
<Dialog bind:open={duplicateOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<CopyIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight duplizieren</DialogTitle>
<DialogDescription>Hier kannst du die Daten des duplizierten Fights ändern</DialogDescription>
</DialogHeader>
<FightEdit {fight} {data} onSave={handlyCopy}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
</div>

View File

@@ -1,45 +0,0 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "@components/moderator/components/GroupEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@repo/event";
let { group, groups = $bindable(), open = $bindable(), event }: { group: ResponseGroups; groups: ResponseGroups[]; open?: boolean; event: SWEvent } = $props();
async function handleSave(groupData: GroupUpdateEdit) {
if (!group) return;
const updatedGroup = await $eventRepo.updateGroup(event.id.toString(), group.id.toString(), groupData);
groups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
open = false;
}
async function handleDelete() {
if (!group) return;
await $eventRepo.deleteGroup(event.id.toString(), group.id.toString());
groups = groups.filter((g) => g.id !== group.id);
open = false;
}
</script>
{#if group}
<Dialog bind:open>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Bearbeiten: {group.name}</DialogTitle>
<DialogDescription>Hier kannst du die Gruppendetails bearbeiten.</DialogDescription>
</DialogHeader>
<GroupEdit {group} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter class="flex justify-between">
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Abbrechen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</div>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>
{/if}

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseTeam } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Button } from "@components/ui/button";
import type { Team } from "@components/types/team";
let { open = $bindable(), group, teams, fights }: { open?: boolean; group: ResponseGroups; teams: Team[]; fights: EventFight[] } = $props();
</script>
<Dialog bind:open>
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Ergebnisse: {group?.name}</DialogTitle>
<DialogDescription>
Punkte: Sieg: {group?.pointsPerWin}, Unentschieden: {group?.pointsPerDraw}, Niederlage: {group?.pointsPerLoss}
</DialogDescription>
</DialogHeader>
{#if group.points !== null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead class="text-right">Spiele</TableHead>
<TableHead class="text-right">Punkte</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each Object.entries(group.points).toSorted((a, b) => b[1] - a[1]) as [teamIdString, points] (teamIdString)}
{@const teamId = Number(teamIdString)}
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam | undefined}
{@const playedGames = fights.filter((f) => f.hasFinished && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
<TableRow>
<TableCell>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
<TableCell class="text-right">{playedGames}</TableCell>
<TableCell class="text-right font-bold">{points}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-center py-4">Noch keine Ergebnisse für diese Gruppe vorhanden oder keine Spiele zugeordnet.</p>
{/if}
<DialogFooter>
<Button variant="outline" onclick={() => (open = false)}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -18,24 +18,37 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table/index.js";
import { Button } from "@components/ui/button/index.js"; import {
import type { ExtendedEvent } from "@type/event.ts"; Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@components/ui/command/index.js";
import {Popover, PopoverContent, PopoverTrigger} from "@components/ui/popover/index.js";
import {Button} from "@components/ui/button/index.js";
import type {ExtendedEvent} from "@type/event.ts";
import { eventRepo } from "@repo/event"; import { eventRepo } from "@repo/event";
import PlayerSelector from "@components/ui/PlayerSelector.svelte"; import { players } from "@stores/stores"
const { event }: { event: ExtendedEvent } = $props(); const {
event
}: { event: ExtendedEvent } = $props();
let referees = $state(event.referees); let referees = $state(event.event.referees)
async function addReferee(value: string) { async function addReferee(value: string) {
await $eventRepo.updateReferees(event.event.id.toString(), [value]); referees = (await $eventRepo.updateEvent(event.event.id.toString(), {
referees = await $eventRepo.listReferees(event.event.id.toString()); addReferee: [value]
})).referees;
} }
async function removeReferee(value: string) { async function removeReferee(value: string) {
await $eventRepo.deleteReferees(event.event.id.toString(), [value]); referees = (await $eventRepo.updateEvent(event.event.id.toString(), {
referees = await $eventRepo.listReferees(event.event.id.toString()); removeReferee: [value]
})).referees;
} }
</script> </script>
@@ -51,12 +64,29 @@
<TableRow> <TableRow>
<TableCell>{referee.name}</TableCell> <TableCell>{referee.name}</TableCell>
<TableCell> <TableCell>
<Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button> <Button onclick={() => removeReferee(referee.uuid)}>Remove</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>
<TableCaption>
<PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
</TableCaption>
</Table> </Table>
<Popover>
<PopoverTrigger>
<Button>
Add
</Button>
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search players..." />
<CommandList>
<CommandEmpty>No Players found :(</CommandEmpty>
<CommandGroup heading="Players">
{#each $players.filter(v => v.perms.length > 0).filter(v => !referees.some(k => k.uuid === v.uuid)) as player (player.uuid)}
<CommandItem value={player.uuid} onSelect={() => addReferee(player.uuid)}>{player.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>

View File

@@ -1,91 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Button } from "@components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
import { eventRepo } from "@repo/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { teams } from "@components/stores/stores";
import type { EventModel } from "./eventmodel.svelte";
let { event = $bindable() }: { event: EventModel } = $props();
async function addTeam(value: number) {
await $eventRepo.updateTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
async function removeTeam(value: number) {
await $eventRepo.deleteTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
let teamSearch = $state("");
</script>
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead>Name</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each event.teams as t (t.id)}
<TableRow>
<TableCell>{t.kuerzel}</TableCell>
<TableCell>{t.name}</TableCell>
<TableCell>
<Button onclick={() => removeTeam(t.id)} variant="outline" size="sm">{t.name} abmelden</Button>
</TableCell>
</TableRow>
{/each}
{#if event.teams.length === 0}
<TableRow>
<TableCell class="text-center col-span-3">No teams available</TableCell>
</TableRow>
{/if}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Team Anmelden</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={teamSearch} placeholder="Search teams..." />
<CommandList>
<CommandEmpty>No teams found :(</CommandEmpty>
<CommandGroup heading="Teams">
{#each $teams
.filter((v) => v.name.includes(teamSearch))
.filter((v) => !event.teams.some((k) => k.id === v.id))
.filter((v, i) => i < 50) as t (t.id)}
<CommandItem value={t.id.toString()} onSelect={() => addTeam(t.id)} keywords={[t.name, t.kuerzel]}>{t.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table>

View File

@@ -17,88 +17,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Checkbox } from "@components/ui/checkbox"; import type {ColumnDef} from "@tanstack/table-core";
import { renderComponent } from "@components/ui/data-table"; import type {EventFight} from "@type/event.ts";
import type { ColumnDef } from "@tanstack/table-core";
import type { EventFightModel } from "./eventmodel.svelte";
export const columns: ColumnDef<EventFightModel>[] = [ export const columns: ColumnDef<EventFight> = [
{ {
id: "auswahl", accessorFn: (r) => r.blueTeam.name,
header: ({ table }) => { header: "Team Blue",
return renderComponent(Checkbox, {
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onCheckedChange: () => {
if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
const now = new Date();
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.start) > now);
if (rows.length > 0) {
rows.forEach((row) => {
row.toggleSelected();
});
} else {
table.toggleAllRowsSelected(true);
}
} else if (table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
table.toggleAllRowsSelected(true);
} else {
table.toggleAllRowsSelected(false);
}
},
});
},
cell: ({ row }) => {
return renderComponent(Checkbox, {
checked: row.getIsSelected(),
onCheckedChange: row.getToggleSelectedHandler(),
});
},
}, },
{ {
accessorFn: (r) => r.blueTeam.nameWithRelation + " vs " + r.redTeam.nameWithRelation, accessorFn: (r) => r.redTeam.name,
id: "begegnung", header: "Team Red",
header: "Begegnung",
}, },
{ ];
header: "Gruppe",
accessorKey: "group.id",
id: "group",
},
{
header: "Datum",
accessorKey: "start",
id: "start",
cell: ({ row }) => {
return new Date(row.getValue("start")).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
},
},
{
header: "Spielmodus",
accessorKey: "spielmodus",
},
{
header: "Map",
accessorKey: "map",
},
{
header: "Ergebnis",
accessorKey: "ergebnis",
cell: ({ row }) => {
const fight = row.original;
if (!fight.hasFinished) {
return "Noch nicht gespielt";
} else if (fight.ergebnis === 1) {
return fight.blueTeam.name + " hat gewonnen";
} else if (fight.ergebnis === 2) {
return fight.redTeam.name + " hat gewonnen";
} else {
return "Unentschieden";
}
},
},
];

View File

@@ -1,68 +0,0 @@
import type { ResponseUser } from "@components/repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { derived } from "svelte/store";
export class EventModel {
public event: SWEvent = $state({} as SWEvent);
public teams: Array<Team> = $state([]);
public groups: Array<ResponseGroups> = $state([]);
public _fights: Array<EventFight> = $state([]);
public referees: Array<ResponseUser> = $state([]);
public relations: Array<ResponseRelation> = $state([]);
public fights = $derived(this.remapFights(this._fights, this.relations));
constructor(data: ExtendedEvent) {
this.event = data.event;
this.relations = data.relations;
this.teams = data.teams;
this.groups = data.groups;
this._fights = data.fights;
this.referees = data.referees;
}
private remapFights(v: Array<EventFight>, rels: Array<ResponseRelation>) {
return v.map((fight) => {
let f = JSON.parse(JSON.stringify(fight)) as EventFight;
let blueTeamRelation = "";
let redTeamRelation = "";
let relations = rels.filter((relation) => relation.fight === f.id);
relations.forEach((relation) => {
let str = "";
if (relation.type === "FIGHT") {
str = `${relation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von ${relation.fromFight?.blueTeam.name} vs ${relation.fromFight?.redTeam.name} (${new Date(
relation.fromFight?.start ?? 0
).toLocaleTimeString("de-DE", {
timeStyle: "short",
})})`;
} else {
str = `${relation.fromPlace + 1}. Platz von ${relation.fromGroup?.name}`;
}
if (relation.team === "BLUE") {
blueTeamRelation = str;
} else {
redTeamRelation = str;
}
});
return {
...f,
blueTeam: {
...f.blueTeam,
nameWithRelation: blueTeamRelation ? `${f.blueTeam.name} (${blueTeamRelation})` : f.blueTeam.name,
},
redTeam: {
...f.redTeam,
nameWithRelation: redTeamRelation ? `${f.redTeam.name} (${redTeamRelation})` : f.redTeam.name,
},
};
});
}
}
export type EventFightModel = (typeof EventModel.prototype.fights)[number];

View File

@@ -20,136 +20,12 @@
<script lang="ts"> <script lang="ts">
import { eventRepo } from "@repo/event.ts"; import { eventRepo } from "@repo/event.ts";
import EventCard from "@components/moderator/components/EventCard.svelte"; import EventCard from "@components/moderator/components/EventCard.svelte";
import { Button } from "@components/ui/button/index.js";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog/index.js";
import { Input } from "@components/ui/input/index.js";
import { Label } from "@components/ui/label/index.js";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { PlusIcon } from "lucide-svelte";
import dayjs from "dayjs";
import { fromAbsolute, now, ZonedDateTime } from "@internationalized/date";
let eventsFuture = $state($eventRepo.listEvents()); let eventsFuture = $state($eventRepo.listEvents());
let millis = Date.now(); let millis = Date.now();
let createOpen = $state(false);
let newEventName = $state("");
let newEventStart: ZonedDateTime = $state(now("Europe/Berlin"));
let newEventEnd: ZonedDateTime = $state(
now("Europe/Berlin").add({
days: 1,
})
);
let isSubmitting = $state(false);
let errorMsg = $state("");
function resetFormFields() {
newEventName = "";
newEventStart = now("Europe/Berlin");
newEventEnd = now("Europe/Berlin").add({
days: 1,
});
errorMsg = "";
isSubmitting = false;
}
$effect(() => {
if (createOpen) {
resetFormFields();
}
});
const canSubmit = $derived(
newEventName.trim() !== "" &&
newEventStart &&
newEventEnd &&
dayjs(newEventStart.toDate()).isValid() &&
dayjs(newEventEnd.toDate()).isValid() &&
newEventStart.toDate() < newEventEnd.toDate() &&
!isSubmitting
);
async function submitCreateEvent() {
if (!canSubmit) return;
isSubmitting = true;
errorMsg = "";
const payload = {
name: newEventName.trim(),
start: dayjs(newEventStart.toDate()),
end: dayjs(newEventEnd.toDate()),
};
try {
await $eventRepo.createEvent(payload);
eventsFuture = $eventRepo.listEvents(); // Refresh the list
createOpen = false;
} catch (e: any) {
errorMsg = e.message || "Failed to create event. Please try again.";
console.error("Failed to create event:", e);
} finally {
isSubmitting = false;
}
}
</script> </script>
<div class="p-4 min-h-screen"> <div class="p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-semibold">Events</h1>
<Dialog bind:open={createOpen}>
<DialogTrigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<PlusIcon class="mr-2" />
Create Event
</Button>
{/snippet}
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Event</DialogTitle>
<DialogDescription>Fill in the details for the new event. Click create when you're done.</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventName" class="text-right">Name</Label>
<Input id="eventName" bind:value={newEventName} class="col-span-3" placeholder="Event Name" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventStart" class="text-right">Start</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventStart} />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventEnd" class="text-right">End</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventEnd} />
</div>
</div>
{#if errorMsg}
<p class="col-span-4 text-sm text-red-600 dark:text-red-500 text-center">{errorMsg}</p>
{/if}
</div>
<DialogFooter>
<DialogClose>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</DialogClose>
<Button onclick={submitCreateEvent} disabled={!canSubmit}>
{#if isSubmitting}
Creating...
{:else}
Create Event
{/if}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{#await eventsFuture} {#await eventsFuture}
<p>Loading...</p> <p>Loading...</p>
{:then events} {:then events}
@@ -169,5 +45,7 @@
</a> </a>
{/each} {/each}
</div> </div>
{:catch e}
{/await} {/await}
</div> </div>

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
import SingleEliminationGenerator from "./gens/elimination/SingleEliminationGenerator.svelte";
import DoubleEliminationGenerator from "./gens/elimination/DoubleEliminationGenerator.svelte";
let {
data,
}: {
data: ExtendedEvent;
} = $props();
</script>
<div class="m-4">
<Tabs value="group">
<TabsList class="mb-4">
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
<TabsTrigger value="double">Double Elimination</TabsTrigger>
</TabsList>
<TabsContent value="group">
<GroupPhaseGenerator {data} />
</TabsContent>
<TabsContent value="ko">
<SingleEliminationGenerator {data} />
</TabsContent>
<TabsContent value="double">
<DoubleEliminationGenerator {data} />
</TabsContent>
</Tabs>
</div>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { eventRepo } from "@components/repo/event";
import FightsGenerator from "./FightsGenerator.svelte";
interface Props {
params: { id: number };
}
let { params }: Props = $props();
let id = params.id;
let future = $eventRepo.getEvent(id.toString());
</script>
{#await future}
<p>Loading...</p>
{:then event}
<FightsGenerator data={event} />
{:catch error}
<p class="text-red-500">Error loading event: {error.message}</p>
{/await}

View File

@@ -1,515 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// Seed model (reuse from single elimination)
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
}
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const ni = index + dir;
if (ni < 0 || ni >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(ni, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "TEAM", id: -1 }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30);
let startDelay = $state(30);
let gamemode = $state("");
let map = $state("");
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Build winners bracket rounds (same as single elimination seeding)
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildWinnersRounds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
if ((n & (n - 1)) !== 0) return []; // power of two required
const rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
let prevWinners = round.map((f) => f.blue);
while (prevWinners.length > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) next.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
rounds.push(next);
prevWinners = next.map((f) => f.blue);
}
return rounds;
}
let winnersRounds = $derived(buildWinnersRounds(seedSlots));
// Losers bracket structure: each losers round takes losers from previous winners round or previous losers round.
// Simplified pairing: For each winners round except final, collect losers and pair them sequentially; then advance until one remains for losers final.
function buildLosersTemplate(wRounds: BracketRoundPreview[]): BracketRoundPreview[] {
const losersRounds: BracketRoundPreview[] = [];
if (wRounds.length < 2) return losersRounds;
// Round 1 losers (from winners round 1)
const firstLosersPairs: BracketRoundPreview = [];
wRounds[0].forEach((f) => firstLosersPairs.push({ blue: f.blue, red: f.red })); // placeholders (will label as losers)
losersRounds.push(firstLosersPairs);
// Subsequent losers rounds shrink similarly
let remaining = firstLosersPairs.length; // number of fights that feed losers next stage
while (remaining > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < remaining; i += 2) next.push(firstLosersPairs[i]); // placeholder reuse
losersRounds.push(next);
remaining = next.length;
}
return losersRounds;
}
let losersRounds = $derived(buildLosersTemplate(winnersRounds));
let generateDisabled = $derived(gamemode !== "" && map !== "" && winnersRounds.length > 0 && seedSlots.length >= 4);
// Type helpers
function slotLabel(slot: SeedSlot): string {
if (slot.kind === "TEAM") return teams.get(slot.id)?.name ?? "???";
if (slot.kind === "GROUP") {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
async function generateDouble() {
if (!generateDisabled) return;
const eventId = data.event.id;
// Create two groups: winners & losers + grand final group (optional combine winners)
const winnersGroup = await $eventRepo.createGroup(eventId, { name: "Winners", type: "ELIMINATION_STAGE" });
const losersGroup = await $eventRepo.createGroup(eventId, { name: "Losers", type: "ELIMINATION_STAGE" });
const finalGroup = await $eventRepo.createGroup(eventId, { name: "Final", type: "ELIMINATION_STAGE" });
function fallbackTeamId(slot: SeedSlot): number {
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
return data.teams[0].id;
}
const winnersFightIdsByRound: number[][] = [];
for (let r = 0; r < winnersRounds.length; r++) {
const round = winnersRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const f = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(round[i].blue),
redTeam: fallbackTeamId(round[i].red),
group: winnersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
// Attach relations for GROUP/FIGHT seeds
const pair = round[i];
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(f.id);
}
winnersFightIdsByRound.push(ids);
}
// Progression in winners bracket
for (let r = 1; r < winnersFightIdsByRound.length; r++) {
const prev = winnersFightIdsByRound[r - 1];
const curr = winnersFightIdsByRound[r];
for (let i = 0; i < curr.length; i++) {
const target = curr[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
await $eventRepo.createRelation(eventId, { fightId: target, team: "BLUE", fromType: "FIGHT", fromId: srcA, fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: target, team: "RED", fromType: "FIGHT", fromId: srcB, fromPlace: 0 });
}
}
// Losers bracket (canonical pattern):
// L1: losers of WBR1 paired; then for r=2..(k-1):
// Major: winners of previous L vs losers of WBRr
// Minor: winners of that major paired (except after last WBR where we go to LB final vs WB final loser)
const losersFightIdsByRound: number[][] = [];
let losersRoundIndex = 0;
const k = winnersFightIdsByRound.length; // number of winners rounds
// L1 from WBR1 losers
{
const wb1 = winnersFightIdsByRound[0];
const ids: number[] = [];
for (let i = 0; i < wb1.length; i += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (i / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: wb1[i], fromPlace: 1 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: wb1[i + 1], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// For each subsequent winners round except the final
for (let wr = 1; wr < k - 1; wr++) {
const prevLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
// Major: winners of prevLBRound vs losers of current WBR (wr)
{
const ids: number[] = [];
for (let j = 0; j < winnersFightIdsByRound[wr].length; j++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * j })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: prevLBRound[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFightIdsByRound[wr][j], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// Minor: pair winners of last LBRound among themselves (if more than 1)
{
const last = losersFightIdsByRound[losersFightIdsByRound.length - 1];
if (last.length > 1) {
const ids: number[] = [];
for (let j = 0; j < last.length; j += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (j / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: last[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: last[j + 1], fromPlace: 0 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
}
}
// Final losers round: winners of last LBRound vs loser of Winners Final (last WBR)
const winnersFinal = winnersFightIdsByRound[k - 1][0];
const lastLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
let losersFinal: number | undefined = undefined;
if (lastLBRound && lastLBRound.length >= 1) {
let finalMap2 = map;
if (finalMap2 === "%random%" && selectableMaps.length > 0) finalMap2 = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap2,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: lastLBRound[lastLBRound.length - 1], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 1 });
losersFinal = lf.id;
losersFightIdsByRound.push([lf.id]);
losersRoundIndex++;
}
// Grand Final
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const grandFinal = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: finalGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "BLUE", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 0 });
if (losersFinal !== undefined) await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "RED", fromType: "FIGHT", fromId: losersFinal, fromPlace: 0 });
await replace("#/event/" + eventId);
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Double Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 4}
<p class="text-gray-400">Mindestens vier Seeds benötigt.</p>
{:else if winnersRounds.length === 0}
<p class="text-yellow-400">Seedanzahl muss eine Zweierpotenz sein. Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid lg:grid-cols-3 gap-6">
<div class="space-y-4">
<Label>Seeds</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
<div class="space-y-2">
<Label>Hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
{#if data.groups.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
</div>
{/if}
{#if data.fights.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
</div>
{/if}
</div>
</div>
</div>
<div>
<Label>Konfiguration</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}<SelectItem value={gm.value}>{gm.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}<SelectItem value={mp.value}>{mp.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-6">
<div>
<Label>Winners Bracket Vorschau</Label>
{#if winnersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each winnersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">W Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<div>
<Label>Losers Bracket (vereinfachte Vorschau)</Label>
{#if losersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each losersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">L Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
Verlierer Paar {i + 1} (aus W Runde {r + 1})
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateDouble} aria-label="Double Bracket generieren">
<Plus />
</Button>
</Card>
<!-- minimal styles only -->

View File

@@ -1,364 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// --- Seeding model: support teams, group results, unknown placeholders ---
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedUnknownSlot {
kind: "UNKNOWN";
uid: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
} // 0 winner, 1 loser
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedUnknownSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
let unknownCounter = 1;
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const newIndex = index + dir;
if (newIndex < 0 || newIndex >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(newIndex, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "UNKNOWN", uid: unknownCounter++ }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
// Fight seed selection
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config inputs
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30); // minutes per round
let startDelay = $state(30); // seconds between fights inside round
let gamemode = $state("");
let map = $state("");
// Gamemode / Map selection stores
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Derived: bracket rounds preview
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildBracketSeeds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
// Require power of two for now; simplest implementation
if ((n & (n - 1)) !== 0) return [];
let rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
// Higher rounds placeholders using first team from each prior pairing as seed representative
let prevWinners = round.map((fight) => fight.blue);
while (prevWinners.length > 1) {
const nextRound: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) {
nextRound.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
}
rounds.push(nextRound);
prevWinners = nextRound.map((f) => f.blue);
}
return rounds;
}
let bracketRounds = $derived(buildBracketSeeds(seedSlots));
let generateDisabled = $derived(gamemode !== "" && map !== "" && bracketRounds.length > 0 && seedSlots.length >= 2);
async function generateBracket() {
if (!generateDisabled) return;
const eventId = data.event.id;
// create elimination group
const group = await $eventRepo.createGroup(eventId, { name: "Elimination", type: "ELIMINATION_STAGE" });
// Create fights round by round & keep ids for relation wiring
const fightIdsByRound: number[][] = [];
function fallbackTeamId(slot: SeedSlot): number {
// If this seed is a relation (GROUP or FIGHT), use -1 as requested
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
// UNKNOWN stays as a concrete placeholder team or -1? Keep concrete team to avoid backend errors.
return data.teams[0].id;
}
for (let r = 0; r < bracketRounds.length; r++) {
const round = bracketRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
const pair = round[i];
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const fight = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(pair.blue),
redTeam: fallbackTeamId(pair.red),
group: group.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(fight.id);
}
fightIdsByRound.push(ids);
}
// Wire relations: for each fight in rounds >0, reference winners of two source fights from previous round
for (let r = 1; r < fightIdsByRound.length; r++) {
const prev = fightIdsByRound[r - 1];
const current = fightIdsByRound[r];
for (let i = 0; i < current.length; i++) {
const targetFightId = current[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
// Winner assumed place 1
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "BLUE",
fromType: "FIGHT",
fromId: srcA,
fromPlace: 1,
});
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "RED",
fromType: "FIGHT",
fromId: srcB,
fromPlace: 1,
});
}
}
// Redirect back to event view
await replace("#/event/" + eventId);
}
// Helpers for template rendering with TS type guards
function isTeam(slot: SeedSlot): slot is SeedTeamSlot {
return slot.kind === "TEAM";
}
function isGroup(slot: SeedSlot): slot is SeedGroupSlot {
return slot.kind === "GROUP";
}
function slotLabel(slot: SeedSlot): string {
if (isTeam(slot)) return teams.get(slot.id)?.name ?? "Team";
if (isGroup(slot)) {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Single Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 2}
<p class="text-gray-400">Mindestens zwei Seeds benötigt.</p>
{:else if bracketRounds.length === 0}
<p class="text-yellow-400">Anzahl der Seeds muss eine Zweierpotenz sein (2,4,8,16,...). Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-4">
<div>
<Label>Seeds / Reihenfolge</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
</div>
<div class="space-y-2">
<Label>Seed hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.groups.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.fights.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
{/if}
</div>
</div>
<p class="text-xs text-gray-500">Gruppen- oder Kampfplätze erzeugen Relationen beim Generieren. ??? bleibt Platzhalter.</p>
</div>
</div>
<div>
<Label>Startzeit</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}
<SelectItem value={gm.value}>{gm.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}
<SelectItem value={mp.value}>{mp.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="mt-6">
<Label>Vorschau</Label>
{#if bracketRounds.length > 0}
<div class="flex gap-8 overflow-x-auto mt-2">
{#each bracketRounds as round, r}
<div>
<h3 class="font-semibold mb-2">Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-sm">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} &nbsp;vs.&nbsp; {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
<Plus />
</Button>
</Card>
<!-- no component-scoped styles needed -->

View File

@@ -1,306 +0,0 @@
<script lang="ts">
import DragAcceptor from "@components/admin/pages/generate/DragAcceptor.svelte";
import TeamChip from "@components/admin/pages/generate/TeamChip.svelte";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Dialog } from "@components/ui/dialog";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute, fromDate, parseDateTime, parseDuration } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let {
data,
}: {
data: ExtendedEvent;
} = $props();
let teams = $derived(new Map<number, Team>(data.teams.map((team) => [team.id, team])));
let groups: number[][] = $state([]);
let teamsNotInGroup = $derived(data.teams.filter((team) => !groups.flat().includes(team.id)));
function dragToNewGroup(event: DragEvent) {
event.preventDefault();
let teamId = parseInt(event.dataTransfer!.getData("team"));
groups = [...groups.map((value) => value.filter((value1) => value1 != teamId)), [teamId]].filter((value) => value.length > 0);
}
function teamDragStart(ev: DragEvent, team: Team) {
ev.dataTransfer!.setData("team", team.id.toString());
}
let resetDragOver = $state(false);
function resetDragOverEvent(ev: DragEvent) {
resetDragOver = true;
ev.preventDefault();
}
function dropReset(ev: DragEvent) {
ev.preventDefault();
let teamId = parseInt(ev.dataTransfer!.getData("team"));
groups = groups.map((group) => group.filter((team) => team !== teamId)).filter((group) => group.length > 0);
resetDragOver = false;
}
function dropGroup(ev: DragEvent, groupIndex: number) {
ev.preventDefault();
let teamId = parseInt(ev.dataTransfer!.getData("team"));
groups = groups.map((group, i) => (i === groupIndex ? [...group.filter((value) => value != teamId), teamId] : group.filter((value) => value != teamId))).filter((group) => group.length > 0);
}
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let gamemode = $state("");
let map = $state("");
let selectableGamemodes = $derived(
$gamemodes
.map((gamemode) => {
return {
name: gamemode,
value: gamemode,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived(
$mapsStore
.map((map) => {
return {
name: map,
value: map,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let roundTime = $state(30);
let startDelay = $state(30);
let showAutoGrouping = $state(false);
let groupCount = $state(Math.floor(data.teams.length / 2));
function createGroups() {
let teams = data.teams.map((team) => team.id).sort(() => Math.random() - 0.5);
groups = [];
for (let i = 0; i < groupCount; i++) {
groups.push([]);
}
while (teams.length > 0) {
groups[teams.length % groupCount].push(teams.pop() as number);
}
showAutoGrouping = false;
groups = groups.filter((group) => group.length > 0);
}
function generateGroups(groups: number[][]): number[][][][] {
const groupFights: number[][][][] = [];
groups.forEach((group) => {
let round = group.length + (group.length % 2) - 1;
let groupFight = [];
for (let i = 0; i < round; i++) {
let availableTeams = [...group];
if (group.length % 2 === 1) {
availableTeams = availableTeams.filter((team, index) => index !== i);
}
let roundFights = [];
while (availableTeams.length > 0) {
let team1 = availableTeams.pop() as number;
let team2 = availableTeams.at(i % availableTeams.length) as number;
availableTeams = availableTeams.filter((team) => team !== team2);
let fight = [team1, team2];
fight.sort(() => Math.random() - 0.5);
roundFights.push(fight);
}
groupFight.push(roundFights);
}
groupFights.push(groupFight);
});
return groupFights;
}
let groupsFights = $derived(generateGroups(groups));
let generateDisabled = $derived(groupsFights.length > 0 && groupsFights.every((value) => value.every((value1) => value1.length > 0)) && gamemode !== "" && map !== "");
async function generateFights() {
groupsFights.forEach((group, i) => {
$eventRepo
.createGroup(data.event.id, {
name: "Gruppe " + (i + 1),
type: "GROUP_STAGE",
})
.then((v) => {
group.forEach((round, j) => {
round.forEach(async (fight, k) => {
const blueTeam = teams.get(fight[0])!;
const redTeam = teams.get(fight[1])!;
let karte = map;
if (karte === "%random%") {
karte = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
}
await $fightRepo.createFight(data.event.id, {
blueTeam: blueTeam.id,
redTeam: redTeam.id,
group: v.id,
map: karte,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({
minutes: roundTime * j,
})
.add({
seconds: startDelay * (k + i * round.length),
})
.toDate()
),
});
});
});
});
});
await replace("#/event/" + data.event.id);
}
</script>
<div class="flex justify-between">
<Card
id="reseter"
class="flex w-fit p-2 border border-gray-700 rounded h-20 pt-6 relative {resetDragOver ? 'border-white' : ''}"
ondragover={resetDragOverEvent}
ondragleave={() => (resetDragOver = false)}
ondrop={dropReset}
role="group"
>
{#each teamsNotInGroup as team (team.id)}
<TeamChip {team} ondragstart={(ev) => teamDragStart(ev, team)} />
{/each}
</Card>
<div class="flex items-center mr-4">
<Button onclick={() => (showAutoGrouping = true)}>Automatische Gruppen</Button>
</div>
</div>
<div class="flex mt-4 gap-4 border-b border-gray-700 pb-4">
{#each groups as group, i (i)}
<DragAcceptor ondrop={(ev) => dropGroup(ev, i)}>
<h1>Gruppe {i + 1} ({group.length})</h1>
{#each group as teamId (teamId)}
<TeamChip team={teams.get(teamId)!} ondragstart={(ev) => teamDragStart(ev, teams.get(teamId)!)} />
{/each}
</DragAcceptor>
{/each}
<DragAcceptor ondrop={dragToNewGroup}>
<h1>Neue Gruppe</h1>
</DragAcceptor>
</div>
<div class="border-b mt-4 border-gray-700 pb-4">
<Label for="event-end">Startzeit</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-2">
<Label for="event-roundtime">Rundenzeit: {roundTime}m</Label>
<Slider id="event-roundtime" type="single" bind:value={roundTime} step={1} min={5} max={60} />
</div>
<div class="mt-2">
<Label for="event-member">Startverzögerung: {startDelay}</Label>
<Slider id="event-member" type="single" bind:value={startDelay} step={1} min={0} max={30} />
</div>
<div class="mt-2">
<Label for="fight-gamemode">Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger id="fight-gamemode">{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gamemodeOption}
<SelectItem value={gamemodeOption.value}>{gamemodeOption.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="mt-2">
<Label for="fight-maps">Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger id="fight-maps">{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mapOption}
<SelectItem value={mapOption.value}>{mapOption.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="text-center mx-2">
{#each groupsFights as fightsGroup, i}
<div>
<h1 class="text-4xl">Gruppe: {i + 1}</h1>
{#each fightsGroup as fightsRound, j}
<div class="border-b border-gray-700">
<h1 class="text-2xl">Runde: {j + 1}</h1>
{#each fightsRound as fightTeams, k}
<div class="text-left p-4">
<span class="p-2 border border-gray-700 rounded"
>{new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(
startTime
.copy()
.add({
minutes: roundTime * j,
seconds: startDelay * (k + i * fightsRound.length),
})
.toDate()
)}</span
>
{teams.get(fightTeams[0])!.name} vs. {teams.get(fightTeams[1])!.name}
</div>
{/each}
</div>
{/each}
</div>
{/each}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateFights}>
<Plus />
</Button>
<style lang="scss">
:global(#reseter::before) {
content: "Reset";
position: absolute;
top: 0;
color: white;
}
:global(#reseter) {
min-width: 14rem;
}
</style>

View File

@@ -1,191 +0,0 @@
<script lang="ts">
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { auditLog } from "@components/repo/auditlog";
import { now, ZonedDateTime } from "@internationalized/date";
import { AuditLogEntrySchema, type AuditLogEntry } from "@components/types/auditlog";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Check } from "lucide-svelte";
import { cn } from "@components/utils";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let actionText = $state("");
let serverText = $state("");
let fullText = $state("");
let actors = $state<number[]>([]);
let actionTypes = $state<string[]>([]);
let timeGreater = $state<ZonedDateTime>(now("Europe/Berlin").subtract({ months: 1 }));
let timeLess = $state<ZonedDateTime>(now("Europe/Berlin"));
let serverOwner = $state<number[]>([]);
let velocity = $state(false);
let sorting = $state("DESC");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<AuditLogEntry[]>([]);
let rows = $state(0);
$effect(() => {
$auditLog
.get(
actionText || undefined,
serverText || undefined,
fullText || undefined,
actors.length > 0 ? actors : undefined,
actionTypes.length > 0 ? actionTypes : undefined,
timeGreater ? timeGreater.toDate().getTime() : undefined,
timeLess ? timeLess.toDate().getTime() : undefined,
serverOwner.length > 0 ? serverOwner : undefined,
velocity,
pagination.pageIndex,
pagination.pageSize,
sorting || undefined
)
.then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
let playerSearch = $state("");
let ownerSearch = $state("");
</script>
<div class="p-4">
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
<Input
class="w-48 mr-2"
placeholder="Suchen..."
value={fullText}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
/>
<Select type="multiple" value={actionTypes} onValueChange={(e) => (actionTypes = e)}>
<SelectTrigger class="w-48 mr-2" placeholder="Aktionstypen auswählen...">Aktionstypen ({actionTypes.length})</SelectTrigger>
<SelectContent>
{#each ["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"] as option}
<SelectItem value={option}>{option}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="mr-2">
<PlayerSelector bind:value={actors} multiple placeholder="Spieler Filter" />
</div>
<div class="mr-2">
<PlayerSelector bind:value={serverOwner} multiple placeholder="Server Owner" />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeGreater} />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeLess} />
</div>
<Select type="single" value={sorting} onValueChange={(e) => (sorting = e)}>
<SelectTrigger class="w-48 mr-2">{sorting === "ASC" ? "Aufsteigend" : "Absteigend"}</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">Aufsteigend</SelectItem>
<SelectItem value="DESC">Absteigend</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">Keine Einträge gefunden.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -1,35 +0,0 @@
import type { AuditLogEntry } from "@components/types/auditlog";
import type { ColumnDef } from "@tanstack/table-core";
export const columns: ColumnDef<AuditLogEntry>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "time",
header: "Time",
cell: (info) => new Date(info.getValue<number>()).toLocaleString(),
},
{
accessorKey: "server",
header: "Server",
},
{
accessorKey: "serverOwner",
header: "Server Owner",
cell: (info) => info.getValue<string | null>() || "N/A",
},
{
accessorKey: "actor",
header: "Spieler",
},
{
accessorKey: "actionType",
header: "Action Type",
},
{
accessorKey: "actionText",
header: "Action Text",
},
];

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { Separator } from "@components/ui/separator";
import { manager, OpenEditPage } from "./page.svelte";
import { File, X } from "lucide-svelte";
import { onMount } from "svelte";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import { json } from "@codemirror/lang-json";
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark";
import FrontmatterEditor from "./FrontmatterEditor.svelte";
import { slide } from "svelte/transition";
import { Button } from "@components/ui/button";
let codemirrorParent: HTMLElement | undefined = $state();
let easyMdeParent: HTMLElement | undefined = $state();
let easyMdeWrapper: HTMLElement | undefined = $state();
let easyMde: EasyMDE | null = $state(null);
let view: EditorView | null = $state(null);
$effect(() => {
switch (manager.selectedPage?.fileType) {
case "md":
case "mdx":
easyMdeWrapper?.classList.remove("hidden");
codemirrorParent?.classList.add("hidden");
break;
case "json":
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.remove("hidden");
break;
default:
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.add("hidden");
}
});
function updatePage(page: OpenEditPage | undefined) {
if (page) {
easyMde?.value(page.content || "");
view?.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: page.content || "" },
});
}
}
$effect(() => updatePage(manager.selectedPage));
onMount(() => {
view = new EditorView({
doc: manager.selectedPage?.content || "",
parent: codemirrorParent,
extensions: [basicSetup, json(), materialDark],
});
easyMde = new EasyMDE({
element: easyMdeParent,
spellChecker: false,
initialValue: manager.selectedPage?.content || "",
});
easyMde.codemirror.on("change", () => {
if (manager.selectedPage?.content !== easyMde?.value()) {
manager.selectedPage!.dirty = true;
}
manager.selectedPage!.content = easyMde?.value() || "";
});
});
</script>
<div class="flex flex-col h-full w-full">
<div class="h-8 flex">
{#each manager.pages as tab, index}
{@const isActive = manager.openPageIndex === index}
<button
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
? 'text-primary bg-neutral-900'
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
onclick={() => (manager.openPageIndex = index)}
>
<File class="h-4 w-4 mr-2" />
{tab.pageTitle}
<span
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
manager.closePage(index);
}}><X /></span
>
</button>
{/each}
</div>
<Separator />
<div class="flex-1 flex flex-col">
{#if manager.selectedPage}
<div class="flex items-center justify-end p-2">
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button>
</div>
<div class="flex gap-2 items-center">
{#if manager.selectedPage.path.startsWith("src/content/announcements/")}
<div class="border-b flex-1" transition:slide>
<FrontmatterEditor />
</div>
{/if}
</div>
{/if}
<div class="flex-1">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
</div>
</div>
</div>

View File

@@ -1,122 +0,0 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { manager } from "./page.svelte";
import { slide } from "svelte/transition";
</script>
<details class="group">
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800">
<span class="font-medium">Frontmatter</span>
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-3 border-t bg-neutral-900">
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]}
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800">
<div class="flex items-center gap-2">
<input
type="text"
value={key}
onchange={(e) => {
const newKey = (e.target as HTMLInputElement).value;
if (newKey !== key) {
manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key];
delete manager.selectedPage?.frontmatter[key];
manager.selectedPage!.dirty = true;
}
}}
class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900"
placeholder="Key"
/>
<span>:</span>
{#if Array.isArray(value)}
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span>
{:else if value instanceof Date || key === "created"}
<input
type="date"
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""}
onchange={(e) => {
const dateValue = (e.target as HTMLInputElement).value;
manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : "";
manager.selectedPage!.dirty = true;
}}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
/>
{:else}
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Value"
/>
{/if}
<button
onclick={() => {
delete manager.selectedPage!.frontmatter[key];
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-4 h-4" />
</button>
</div>
{#if Array.isArray(value)}
<div class="ml-4 space-y-1">
{#each value as item, index}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-6">[{index}]</span>
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key][index]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Array item"
/>
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].splice(index, 1);
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].push("");
manager.selectedPage!.dirty = true;
}}
class="text-xs text-blue-500 hover:text-blue-700 ml-8"
>
+ Add item
</button>
</div>
{/if}
</div>
{/each}
<div class="flex gap-2">
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_key_${Object.keys(manager.selectedPage!.frontmatter).length}`] = "";
manager.selectedPage!.dirty = true;
}}
class="text-sm text-blue-500 hover:text-blue-700"
>
+ Add field
</button>
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_array_${Object.keys(manager.selectedPage!.frontmatter).length}`] = [];
manager.selectedPage!.dirty = true;
}}
class="text-sm text-green-500 hover:text-green-700"
>
+ Add array
</button>
</div>
</div>
</details>

View File

@@ -1,155 +0,0 @@
<script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { cn } from "@components/utils";
import { pageRepo } from "@components/repo/page";
let branchSelectOpen = $state(false);
let imageSelectOpen = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}>
<div class="overflow-y-scroll">
<div class="flex p-2 gap-2">
<Popover bind:open={branchSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{manager.branch}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Branches..." />
<CommandList>
<CommandEmpty>No Branches Found.</CommandEmpty>
<CommandGroup>
{#each manager.branches as branch}
<CommandItem
value={branch}
onSelect={() => {
if (manager.anyUnsavedChanges()) {
if (!confirm("You have unsaved changes. Are you sure you want to switch branches?")) {
return;
}
}
manager.branch = branch;
manager.pages = [];
branchSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", branch !== manager.branch && "text-transparent")} />
{branch}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
<RefreshCw />
</Button>
<Popover bind:open={imageSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button size="icon" variant="outline" {...props}>
<FileImage />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
{#await manager.imagesLoad}
<p>Loading images...</p>
{:then images}
<div class="flex flex-col gap-2">
<div class="p-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target?.result?.toString().split(",")[1];
if (base64) {
await $pageRepo.createImage(file.name, base64, manager.branch);
manager.reloadImages();
}
};
reader.readAsDataURL(file);
}}
/>
<Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" />
Upload Image
</Button>
</div>
<div class="grid grid-cols-4 gap-2 p-2">
{#each images as image}
<button
onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
const path = [...Array(backs).fill(".."), image.path.replace("src/", "")].join("/");
navigator.clipboard.writeText(path);
imageSelectOpen = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
</button>
{/each}
</div>
</div>
{/await}
</PopoverContent>
</Popover>
<Button
size="icon"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus />
</Button>
</div>
<Separator />
{#await manager.pagesLoad}
<p>Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
</ResizablePane>
<ResizableHandle />
<ResizablePane defaultSize={80}>
<EditorWithTabs />
</ResizablePane>
</ResizablePaneGroup>
</div>

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import type { DirTree } from "./page.svelte";
import PagesList from "./PagesList.svelte";
import { slide } from "svelte/transition";
import Button from "@components/ui/button/button.svelte";
import { manager } from "./page.svelte";
const { page, depth = 0, path }: { page: DirTree; depth?: number; path: string } = $props();
let open = $state(false);
let newPage = $state(false);
let newPageName = $state("");
let newPageInput: HTMLInputElement | undefined = $state();
function startNewPageCreate(e: Event) {
e.stopPropagation();
newPage = true;
newPageName = "";
open = true;
setTimeout(() => {
newPageInput?.focus();
}, 1);
}
function createNewPage(e: Event) {
e.preventDefault();
e.stopPropagation();
if (newPageName.trim() === "") {
alert("Page name cannot be empty");
return;
}
if (!newPageName.match(/^[a-zA-Z0-9_\-\.]+$/)) {
alert("Invalid page name. Only alphanumeric characters, underscores, dashes, and dots are allowed.");
return;
}
if (!newPageName.endsWith(".json") && !newPageName.endsWith(".md") && !newPageName.endsWith(".mdx")) {
newPageName += ".md";
}
manager
.createPage(path + newPageName, newPageName)
.then(() => {
newPage = false;
newPageName = "";
})
.catch((error) => {
alert("Error creating page: " + error.message);
});
}
</script>
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
<div class="flex flex-row items-center">
{#if open}
<ChevronDown class="w-6 h-6" />
{:else}
<ChevronRight class="w-6 h-6" />
{/if}
<Folder class="mr-2 w-4 h-4" />
{page.name}/
</div>
<div class="flex-row items-center hidden group-hover:flex">
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
<FilePlus class="w-3 h-3" />
</Button>
</div>
</button>
{#if open}
<div transition:slide={{ duration: 200, axis: "y" }}>
<div>
{#if newPage}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
{#if newPageName.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
<form onsubmit={createNewPage}>
<input
type="text"
bind:value={newPageName}
bind:this={newPageInput}
onblur={() => (newPage = false)}
placeholder="New page name"
class="flex-grow bg-transparent border-none outline-none text-white"
/>
</form>
</button>
{/if}
{#each Object.values(page.dirs) as subPage (subPage.name)}
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
{/each}
{#each Object.values(page.files) as file (file.id)}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
{#if file.name.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
{file.name}
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -1,226 +0,0 @@
import { base64ToBytes } from "@components/admin/util";
import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store";
import yaml from "js-yaml";
export class OpenEditPage {
public content: string = "";
public frontmatter: { [key: string]: string | string[] | Date } = $state({});
public dirty: boolean = $state(false);
public readonly fileType: string;
public constructor(
private manager: PageManager,
public readonly pageId: number,
public readonly pageTitle: string,
public readonly sha: string,
public readonly originalContent: string,
public readonly path: string
) {
this.fileType = this.path.split(".").pop() || "md";
this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent);
}
public async save(): Promise<void> {
if (!this.dirty) {
return;
}
let contentToSave = "";
if (this.frontmatter) {
contentToSave += "---\n";
contentToSave += yaml.dump(this.frontmatter);
contentToSave += "---\n\n";
}
contentToSave += this.content;
await get(pageRepo).updatePage(this.pageId, contentToSave, this.sha, prompt("Was hast du geändert?", `Updated ${this.pageTitle}`) ?? `Updated ${this.pageTitle}`, this.manager.branch);
this.dirty = false;
this.manager.reloadImages();
}
public focus(): boolean {
let index = this.manager.pages.indexOf(this);
if (index === this.manager.openPageIndex) {
return true;
}
this.manager.openPageIndex = this.manager.pages.indexOf(this);
return false;
}
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
const lines = content.split("\n");
let inFrontmatter = false;
const frontmatterLines: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
if (inFrontmatter) {
break; // End of frontmatter
}
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
frontmatterLines.push(line);
}
}
if (frontmatterLines.length === 0) {
return {};
}
try {
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
} catch (error) {
console.error("Failed to parse YAML frontmatter:", error);
return {};
}
}
private removeFrontmatter(content: string): string {
const lines = content.split("\n");
let inFrontmatter = false;
const result: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (!inFrontmatter) {
result.push(line);
}
}
return result.join("\n").trim();
}
}
export interface DirTree {
name: string;
dirs: { [key: string]: DirTree };
files: { [key: string]: ListPage };
}
export class PageManager {
public reloadImages() {
this.updater = this.updater + 1;
}
public branch: string = $state("master");
public pages: OpenEditPage[] = $state([]);
public branches: string[] = $state([]);
constructor() {
this.reloadBranches();
}
public reloadBranches() {
get(pageRepo)
.getBranches()
.then((branches) => {
this.branches = branches;
});
}
private updater = $state(0);
public openPageIndex: number = $state(-1);
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater)));
public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
private _t<T>(n: number): (v: T) => T {
return (v: T) => v;
}
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
private convertToTree(pages: PageList): DirTree {
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
pages.forEach((page) => {
const pathParts = page.path.split("/").filter((part) => part !== "");
let current = tree;
// Navigate/create directory structure
for (let i = 0; i < pathParts.length - 1; i++) {
const dir = pathParts[i];
if (!current.dirs[dir]) {
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
}
current = current.dirs[dir];
}
// Add file to the final directory
const fileName = pathParts[pathParts.length - 1];
current.files[fileName] = page;
});
return tree;
}
public async openPage(pageId: number) {
const existingPage = this.existingPage(pageId);
if (existingPage) {
existingPage.focus();
return;
}
let r = await get(pageRepo).getPage(pageId, this.branch);
if (!r) {
return;
}
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
this.pages.push(newPage);
newPage.focus();
}
public existingPage(pageId: number): OpenEditPage | undefined {
return this.pages.find((page) => page.pageId === pageId);
}
public closePage(index: number) {
if (index < 0 || index >= this.pages.length) {
return;
}
const page = this.pages[index];
if (page.dirty) {
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
return;
}
}
this.pages.splice(index, 1);
if (this.openPageIndex >= index) {
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
}
if (this.openPageIndex < 0 && this.pages.length > 0) {
this.openPageIndex = 0;
}
if (this.pages.length === 0) {
this.openPageIndex = -1;
}
}
public async createPage(path: string, newPageName: string): Promise<void> {
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
this.branch = this.branch;
}
public anyUnsavedChanges() {
return this.pages.some((page) => page.dirty);
}
}
export const manager = $state(new PageManager());

View File

@@ -18,27 +18,24 @@
--> -->
<script lang="ts"> <script lang="ts">
import { permissions } from "@stores/stores.ts"; import {permissions, players} from "@stores/stores.ts";
import { Select, SelectContent, SelectItem } from "@components/ui/select"; import {Select, SelectContent, SelectItem} from "@components/ui/select";
import { SelectTrigger } from "@components/ui/select/index.js"; import {SelectTrigger} from "@components/ui/select/index.js";
import { permsRepo } from "@repo/perms.ts"; import {permsRepo} from "@repo/perms.ts";
const { perms, uuid }: { perms: string[]; uuid: string } = $props(); const {
perms, uuid
}: { perms: string[], uuid: string } = $props();
let value = $state(perms); let value = $state(perms);
let prevValue = $state(perms); let prevValue = $state(perms);
$effect(() => {
value = perms;
prevValue = perms;
});
function onChange(change: string[]) { function onChange(change: string[]) {
$permissions.perms.forEach((perm) => { $permissions.perms.forEach(perm => {
if (prevValue.includes(perm) && !change.includes(perm)) { if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm); $permsRepo.removePerm(uuid, perm)
} else if (!prevValue.includes(perm) && change.includes(perm)) { } else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm); $permsRepo.addPerm(uuid, perm)
} }
}); });
@@ -56,4 +53,4 @@
<SelectItem value={permission}>{permission}</SelectItem> <SelectItem value={permission}>{permission}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -17,132 +17,16 @@
- along with this program. If not, see <https://www.gnu.org/licenses/>. - along with this program. If not, see <https://www.gnu.org/licenses/>.
--> -->
<script lang="ts"> <script>
import { createSvelteTable, FlexRender } from "@components/ui/data-table"; import Table from "@components/moderator/pages/players/Table.svelte";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
let debounceTimer: NodeJS.Timeout; import {dataRepo} from "@repo/data";
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let search = $state(""); let playersFuture = $state($dataRepo.getPlayers())
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<Player[]>([]);
let rows = $state(0);
$effect(() => {
$dataRepo.queryPlayers(search || undefined, undefined, undefined, pagination.pageSize, pagination.pageIndex, true, false).then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
</script> </script>
<div class="p-4"> {#await playersFuture}
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col"> <p>Loading...</p>
<Input {:then players}
class="w-48 mr-2" <Table data={players} />
placeholder="Search players..." {/await}
value={search}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
/>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No players found.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -18,18 +18,16 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select"; import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
import { permissions } from "@stores/stores.ts"; import {permissions} from "@stores/stores.ts";
import { permsRepo } from "@repo/perms.ts"; import {permsRepo} from "@repo/perms.ts";
const { prefix, uuid }: { prefix: string; uuid: string } = $props(); const {
prefix, uuid
}: { prefix: string, uuid: string } = $props();
let value = $state(prefix); let value = $state(prefix);
$effect(() => {
value = prefix;
});
function onChange(change: string) { function onChange(change: string) {
$permsRepo.setPrefix(uuid, change); $permsRepo.setPrefix(uuid, change);
@@ -46,4 +44,4 @@
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem> <SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -0,0 +1,174 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
type ColumnFiltersState,
getCoreRowModel, getFilteredRowModel,
getPaginationRowModel, getSortedRowModel,
type PaginationState,
type SortingState,
} from "@tanstack/table-core";
import {
createSvelteTable,
FlexRender,
} from "@components/ui/data-table/index";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
import {Button} from "@components/ui/button";
import {Input} from "@components/ui/input";
import {Select} from "@components/ui/select";
import {SelectContent, SelectItem, SelectTrigger} from "@components/ui/select/index.js";
import type {Player} from "@type/data";
import { columns } from "./columns";
let { data }: { data: Player[] } = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 25 });
let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]);
const table = createSvelteTable({
get data() {
return data;
},
state: {
get pagination() {
return pagination;
},
get sorting() {
return sorting;
},
get columnFilters() {
return columnFilters;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onSortingChange: (updater) => {
if (typeof updater === "function") {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
</script>
<div class="rounded-md border m-4">
<div class="flex items-center p-4 border-b">
<Input
placeholder="Filter Players..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onchange={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
oninput={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
class="max-w-sm"
/>
<div class="flex items-center px-4">
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => pagination = { pageSize: +e, pageIndex: 0 }}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">
No results.
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="flex items-center justify-end space-x-2 p-4 border-t">
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<span>{pagination.pageIndex + 1}/{table.getPageCount()}</span>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>

View File

@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { ColumnDef } from "@tanstack/table-core"; import type {ColumnDef} from "@tanstack/table-core";
import type { Player } from "@type/data.ts"; import type {Player} from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table"; import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte"; import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte"; import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
@@ -36,20 +36,25 @@ export const columns: ColumnDef<Player[]> = [
accessorKey: "prefix", accessorKey: "prefix",
header: "Prefix", header: "Prefix",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent(PrefixDropdown, { return renderComponent(
prefix: row.getValue("prefix"), PrefixDropdown, {
uuid: row.getValue("uuid"), prefix: row.getValue("prefix"),
}); uuid: row.getValue("uuid"),
},
);
}, },
}, },
{ {
accessorKey: "perms", accessorKey: "perms",
header: "Permissions", header: "Permissions",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent(PermissionsDropdown, { return renderComponent(
perms: row.getValue("perms"), PermissionsDropdown,
uuid: row.getValue("uuid"), {
}); perms: row.getValue("perms"),
uuid: row.getValue("uuid"),
},
);
}, },
}, },
]; ];

View File

@@ -1,40 +0,0 @@
import { derived } from "svelte/store";
import { fetchWithToken, tokenStore } from "./repo";
import { PagedAutidLogSchema } from "@components/types/auditlog";
export class AuditLogRepo {
async get(
actionText: string | undefined,
serverText: string | undefined,
fullText: string | undefined,
actor: number[] | undefined,
actionType: string[] | undefined,
timeFrom: number | undefined,
timeTo: number | undefined,
serverOwner: number[] | undefined,
velocity: boolean | undefined,
page: number,
pageSize: number,
sorting: string | undefined
) {
const params = new URLSearchParams();
if (actionText) params.append("actionText", actionText);
if (serverText) params.append("serverText", serverText);
if (fullText) params.append("fullText", fullText);
if (actor) actor.forEach((a) => params.append("actor", a.toString()));
if (actionType) actionType.forEach((a) => params.append("actionType", a));
if (timeFrom) params.append("timeGreater", timeFrom.toString());
if (timeTo) params.append("timeLess", timeTo.toString());
if (serverOwner) serverOwner.forEach((s) => params.append("serverOwner", s.toString()));
if (velocity !== undefined) params.append("velocity", velocity.toString());
params.append("page", page.toString());
params.append("limit", pageSize.toString());
if (sorting) params.append("sorting", sorting);
return await fetchWithToken("", `/auditlog?${params.toString()}`)
.then((value) => value.json())
.then((data) => PagedAutidLogSchema.parse(data));
}
}
export const auditLog = derived(tokenStore, ($token) => new AuditLogRepo());

View File

@@ -17,34 +17,49 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { readable, writable } from "svelte/store"; import {readable, writable} from "svelte/store";
import { ResponseUserSchema } from "@components/types/data"; import dayjs, {type Dayjs} from "dayjs";
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
export class AuthV2Repo { export class AuthV2Repo {
private accessToken: string | undefined;
private accessTokenExpires: Dayjs | undefined;
private refreshToken: string | undefined;
private refreshTokenExpires: Dayjs | undefined;
constructor() { constructor() {
this.request("/data/me").then((value) => { if (typeof localStorage === "undefined") {
if (value.ok) { return;
loggedIn.set(true); }
} else {
loggedIn.set(false); this.accessToken = localStorage.getItem("sw-access-token") ?? undefined;
} if (this.accessToken) {
}); this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? "");
}
this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined;
if (this.refreshToken) {
loggedIn.set(true);
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
}
} }
async login(name: string, password: string) { async login(name: string, password: string) {
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
throw new Error("Already logged in");
}
try { try {
await this.request("/auth", { const login = await this.request("/auth", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
name, name,
password, password,
keepLoggedIn: true, keepLoggedIn: true,
}), }),
}) }).then(value => value.json()).then(value => AuthTokenSchema.parse(value));
.then((value) => value.json())
.then((value) => ResponseUserSchema.parse(value));
loggedIn.set(true); this.setLoginState(login);
return true; return true;
} catch (e) { } catch (e) {
@@ -52,43 +67,118 @@ export class AuthV2Repo {
} }
} }
async loginDiscord(token: string) {
try {
await this.request("/auth/discord", {
method: "POST",
body: token,
headers: {
"Content-Type": "text/plain",
},
})
.then((value) => value.json())
.then((value) => ResponseUserSchema.parse(value));
loggedIn.set(true);
return true;
} catch (e) {
return false;
}
}
async logout() { async logout() {
if (this.accessToken === undefined) {
return;
}
await this.request("/auth", { await this.request("/auth", {
method: "DELETE", method: "DELETE",
}); });
this.resetAccessToken();
this.resetRefreshToken();
}
private setLoginState(tokens: AuthToken) {
this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires));
this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires));
loggedIn.set(true);
}
private setAccessToken(token: string, expires: Dayjs) {
this.accessToken = token;
this.accessTokenExpires = expires;
localStorage.setItem("sw-access-token", token);
localStorage.setItem("sw-access-token-expires", expires.toString());
}
private resetAccessToken() {
if (this.accessToken === undefined) {
return;
}
this.accessToken = undefined;
this.accessTokenExpires = undefined;
localStorage.removeItem("sw-access-token");
localStorage.removeItem("sw-access-token-expires");
}
private setRefreshToken(token: string, expires: Dayjs) {
this.refreshToken = token;
this.refreshTokenExpires = expires;
localStorage.setItem("sw-refresh-token", token);
localStorage.setItem("sw-refresh-token-expires", expires.toString());
}
private resetRefreshToken() {
if (this.refreshToken === undefined) {
return;
}
this.refreshToken = undefined;
this.refreshTokenExpires = undefined;
localStorage.removeItem("sw-refresh-token");
localStorage.removeItem("sw-refresh-token-expires");
loggedIn.set(false); loggedIn.set(false);
} }
async request(url: string, params: RequestInit = {}) { private async refresh() {
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, { if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
...params, this.resetRefreshToken();
credentials: "include", this.resetAccessToken();
return;
}
const response = await this.requestWithToken(this.refreshToken!, "/auth", {
method: "PUT",
}).then(value => {
if (value.status === 401) {
this.resetRefreshToken();
this.resetAccessToken();
return undefined;
}
return value.json();
}).then(value => AuthTokenSchema.parse(value));
this.setLoginState(response);
}
async request(url: string, params: RequestInit = {}, retryCount: number = 0) {
if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
await this.refresh();
}
return this.requestWithToken(this.accessToken ?? "", url, params, retryCount);
}
private async requestWithToken(token: string, url: string, params: RequestInit = {}, retryCount: number = 0): Promise<Response> {
if (retryCount >= 3) {
throw new Error("Too many retries");
}
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
headers: { headers: {
"Content-Type": "application/json", ...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
...params.headers, "Content-Type": "application/json", ...params.headers,
}, },
}); })
.then(async value => {
if (value.status === 401 && url !== "/auth") {
try {
await this.refresh();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) { /* empty */ }
return this.request(url, params, retryCount + 1);
}
return value;
});
} }
} }
export const loggedIn = writable(false); export const loggedIn = writable(false);
export const authV2Repo = readable(new AuthV2Repo()); export const authV2Repo = readable(new AuthV2Repo());

View File

@@ -17,56 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Player, PlayerList, Server } from "@type/data.ts"; import type {Player, Server} from "@type/data.ts";
import { PlayerListSchema, PlayerSchema, ServerSchema } from "@type/data.ts"; import {PlayerSchema, ServerSchema} from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts"; import {fetchWithToken, tokenStore} from "./repo.ts";
import { derived, get } from "svelte/store"; import {derived, get} from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts";
export class DataRepo { export class DataRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async getServer(): Promise<Server> { public async getServer(): Promise<Server> {
return await fetchWithToken(this.token, "/data/server") return await fetchWithToken(this.token, "/data/server").then(value => value.json()).then(ServerSchema.parse);
.then((value) => value.json())
.then(ServerSchema.parse);
} }
public async getMe(): Promise<Player> { public async getMe(): Promise<Player> {
return await fetchWithToken(this.token, "/data/me") return await fetchWithToken(this.token, "/data/me").then(value => value.json()).then(PlayerSchema.parse);
.then((value) => value.json())
.then(PlayerSchema.parse);
} }
public async queryPlayers( public async getPlayers(): Promise<Player[]> {
name: string | undefined, return await fetchWithToken(get(tokenStore), "/data/admin/users").then(value => value.json()).then(PlayerSchema.array().parse);
uuid: string | undefined,
team: number[] | undefined,
limit: number | undefined,
page: number | undefined,
includePerms: boolean | undefined,
includeId: boolean | undefined
): Promise<PlayerList> {
let query = new URLSearchParams();
if (name) query.append("name", name);
if (uuid) query.append("uuid", uuid);
if (team) team.forEach((t) => query.append("team", t.toString()));
if (limit) query.append("limit", limit.toString());
if (page) query.append("page", page.toString());
if (includePerms !== undefined) query.append("includePerms", includePerms.toString());
if (includeId !== undefined) query.append("includeId", includeId.toString());
return await fetchWithToken(this.token, "/data/admin/users?" + query.toString())
.then((value) => value.json())
.then(PlayerListSchema.parse);
}
public async getTeams(): Promise<Team[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/teams")
.then((value) => value.json())
.then(TeamSchema.array().parse);
} }
} }
export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token)); export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token));

View File

@@ -17,26 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event"; import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
import { fetchWithToken, tokenStore } from "./repo"; import {fetchWithToken, tokenStore} from "./repo";
import { import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
ExtendedEventSchema, import {z} from "zod";
ShortEventSchema, import type {Dayjs} from "dayjs";
SWEventSchema, import {derived} from "svelte/store";
EventFightSchema,
ResponseGroupsSchema,
ResponseRelationSchema,
ResponseTeamSchema,
CreateEventGroupSchema,
UpdateEventGroupSchema,
CreateEventRelationSchema,
UpdateEventRelationSchema,
} from "@type/event.js";
import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEventRelation } from "@type/event.js";
import { z } from "zod";
import type { Dayjs } from "dayjs";
import { derived } from "svelte/store";
import { ResponseUserSchema } from "@components/types/data";
export interface CreateEvent { export interface CreateEvent {
name: string; name: string;
@@ -56,25 +42,19 @@ export interface UpdateEvent {
removeReferee?: string[] | null; removeReferee?: string[] | null;
} }
export interface ResponseUser {
name: string;
uuid: string;
prefix: string;
perms: string[];
}
export class EventRepo { export class EventRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async listEvents(): Promise<ShortEvent[]> { public async listEvents(): Promise<ShortEvent[]> {
return await fetchWithToken(this.token, "/events") return await fetchWithToken(this.token, "/events")
.then((value) => value.json()) .then(value => value.json())
.then((value) => z.array(ShortEventSchema).parse(value)); .then(value => z.array(ShortEventSchema).parse(value));
} }
public async getEvent(id: string): Promise<ExtendedEvent> { public async getEvent(id: string): Promise<ExtendedEvent> {
return await fetchWithToken(this.token, `/events/${id}`) return await fetchWithToken(this.token, `/events/${id}`)
.then((value) => value.json()) .then(value => value.json())
.then(ExtendedEventSchema.parse); .then(ExtendedEventSchema.parse);
} }
@@ -86,8 +66,7 @@ export class EventRepo {
start: +event.start, start: +event.start,
end: +event.end, end: +event.end,
}), }),
}) }).then(value => value.json())
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@@ -108,8 +87,7 @@ export class EventRepo {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) }).then(value => value.json())
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@@ -120,154 +98,6 @@ export class EventRepo {
return res.ok; return res.ok;
} }
// Fights
public async listFights(eventId: string): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then((value) => value.json())
.then((value) => z.array(EventFightSchema).parse(value));
}
public async createFight(eventId: string, fight: any): Promise<EventFight> {
delete fight.ergebnis;
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST",
body: JSON.stringify(fight),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(EventFightSchema.parse);
}
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "DELETE",
});
return res.ok;
}
// Groups
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
.then((value) => value.json())
.then((value) => z.array(ResponseGroupsSchema).parse(value));
}
public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
CreateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
method: "POST",
body: JSON.stringify({
name: group.name,
type: group.type,
}),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async getGroup(eventId: string, groupId: string): Promise<ResponseGroups> {
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`)
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async updateGroup(eventId: string, groupId: string, group: UpdateEventGroup): Promise<ResponseGroups> {
UpdateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "PUT",
body: JSON.stringify(group),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async deleteGroup(eventId: string, groupId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "DELETE",
});
return res.ok;
}
// Relations
public async listRelations(eventId: number): Promise<ResponseRelation[]> {
return await fetchWithToken(this.token, `/events/${eventId}/relations`)
.then((value) => value.json())
.then((value) => z.array(ResponseRelationSchema).parse(value));
}
public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
CreateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
method: "POST",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async getRelation(eventId: string, relationId: string): Promise<ResponseRelation> {
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`)
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
UpdateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "PUT",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "DELETE",
});
return res.ok;
}
// Teams
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
.then((value) => value.json())
.then((value) => z.array(ResponseTeamSchema).parse(value));
}
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "PUT",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "DELETE",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
// Referees
public async listReferees(eventId: string): Promise<ResponseUser[]> {
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
.then((value) => value.json())
.then((value) => z.array(ResponseUserSchema).parse(value));
}
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "PUT",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "DELETE",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
} }
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token)); export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));

View File

@@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { EventFight } from "@type/event.js"; import type {EventFight} from "@type/event.js";
import { fetchWithToken, tokenStore } from "./repo"; import {fetchWithToken, tokenStore} from "./repo";
import { z } from "zod"; import {z} from "zod";
import { EventFightSchema } from "@type/event.js"; import {EventFightSchema} from "@type/event.js";
import type { Dayjs } from "dayjs"; import type {Dayjs} from "dayjs";
import { derived } from "svelte/store"; import {derived} from "svelte/store";
export interface CreateFight { export interface CreateFight {
spielmodus: string; spielmodus: string;
@@ -31,7 +31,7 @@ export interface CreateFight {
redTeam: number; redTeam: number;
start: Dayjs; start: Dayjs;
spectatePort: number | null; spectatePort: number | null;
group: number | null; group: string | null;
} }
export interface UpdateFight { export interface UpdateFight {
@@ -39,24 +39,26 @@ export interface UpdateFight {
map: string | null; map: string | null;
blueTeam: number | null; blueTeam: number | null;
redTeam: number | null; redTeam: number | null;
start: number | null; start: Dayjs | null;
spectatePort: number | null; spectatePort: number | null;
group: number | null; group: string | null;
} }
export class FightRepo { export class FightRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async listFights(eventId: number): Promise<EventFight[]> { public async listFights(eventId: number): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`) return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then((value) => value.json()) .then(value => value.json())
.then((value) => z.array(EventFightSchema).parse(value)); .then(value => z.array(EventFightSchema).parse(value));
} }
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> { public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`, { return await fetchWithToken(this.token, "/fights", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
event: eventId,
spielmodus: fight.spielmodus, spielmodus: fight.spielmodus,
map: fight.map, map: fight.map,
blueTeam: fight.blueTeam, blueTeam: fight.blueTeam,
@@ -65,25 +67,28 @@ export class FightRepo {
spectatePort: fight.spectatePort, spectatePort: fight.spectatePort,
group: fight.group, group: fight.group,
}), }),
}) }).then(value => value.json())
.then((value) => value.json())
.then(EventFightSchema.parse); .then(EventFightSchema.parse);
} }
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> { public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, { return await fetchWithToken(this.token, `/fights/${fightId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
...fight, spielmodus: fight.spielmodus,
map: fight.map,
blueTeam: fight.blueTeam,
redTeam: fight.redTeam,
start: fight.start?.valueOf(), start: fight.start?.valueOf(),
spectatePort: fight.spectatePort,
group: fight.group,
}), }),
}) }).then(value => value.json())
.then((value) => value.json())
.then(EventFightSchema.parse); .then(EventFightSchema.parse);
} }
public async deleteFight(eventId: number, fightId: number): Promise<void> { public async deleteFight(fightId: number): Promise<void> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, { const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
method: "DELETE", method: "DELETE",
}); });

View File

@@ -17,26 +17,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Page, PageList } from "@type/page.ts"; import type {Page, PageList} from "@type/page.ts";
import { fetchWithToken, tokenStore } from "./repo.ts"; import {fetchWithToken, tokenStore} from "./repo.ts";
import { PageListSchema, PageSchema } from "@type/page.ts"; import {PageListSchema, PageSchema} from "@type/page.ts";
import { bytesToBase64 } from "../admin/util.ts"; import {bytesToBase64} from "../admin/util.ts";
import { z } from "zod"; import {z} from "zod";
import { derived } from "svelte/store"; import {derived} from "svelte/store";
export class PageRepo { export class PageRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async listPages(branch: string = "master"): Promise<PageList> { public async listPages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page?branch=${branch}`) return await fetchWithToken(this.token, `/page?branch=${branch}`)
.then((value) => value.json()) .then(value => value.json())
.then(PageListSchema.parse) .then(PageListSchema.parse)
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") }))); .then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")})));
} }
public async getPage(id: number, branch: string = "master"): Promise<Page> { public async getPage(id: number, branch: string = "master"): Promise<Page> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`) return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then((value) => value.json()) .then(value => value.json())
.then(PageSchema.parse); .then(PageSchema.parse);
} }
@@ -45,57 +46,42 @@ export class PageRepo {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)), content: bytesToBase64(new TextEncoder().encode(content)),
sha, sha, message,
message,
}), }),
}); });
} }
public async getBranches(): Promise<string[]> { public async getBranches(): Promise<string[]> {
return await fetchWithToken(this.token, "/page/branch") return await fetchWithToken(this.token, "/page/branch")
.then((value) => value.json()) .then(value => value.json())
.then((value) => z.array(z.string()).parse(value)); .then(value => z.array(z.string()).parse(value));
} }
public async createBranch(branch: string): Promise<void> { public async createBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) }); await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})});
} }
public async deleteBranch(branch: string): Promise<void> { public async deleteBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) }); await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})});
} }
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> { public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) }); await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})});
} }
public async merge(branch: string, message: string): Promise<void> { public async merge(branch: string, message: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch/merge", { await fetchWithToken(this.token, "/page/branch/merge", {
method: "POST", method: "POST",
body: JSON.stringify({ branch, message }), body: JSON.stringify({branch, message}),
}); });
} }
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> { public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, { await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify({ message, sha }), body: JSON.stringify({message, sha}),
});
}
public async listImages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
.then((value) => value.json())
.then(PageListSchema.parse)
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
}
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
method: "POST",
body: JSON.stringify({ name, data }),
}); });
} }
} }
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token)); export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));

View File

@@ -17,31 +17,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { readonly, writable } from "svelte/store"; import {readonly, writable} from "svelte/store";
import type { Readable, Subscriber, Unsubscriber } from "svelte/store"; import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
export interface Cached<T> extends Readable<T> { export interface Cached<T> extends Readable<T> {
reload: () => void; reload: () => void;
future: Promise<T>;
} }
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> { export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
const store = writable<T>(normal); const store = writable<T>(normal);
const future = new Promise<T>((resolve) => {
let f = false;
store.subscribe((value) => {
if (f) {
resolve(value);
} else {
f = true;
}
});
});
let first = true; let first = true;
const reload = () => { const reload = () => {
init().then((data) => { init().then(data => {
store.set(data); store.set(data);
}); });
}; };
@@ -56,7 +45,6 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
return store.subscribe(run, invalidate); return store.subscribe(run, invalidate);
}, },
reload, reload,
future,
}; };
} }
@@ -70,7 +58,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
let first = true; let first = true;
const reload = () => { const reload = () => {
init(arg).then((data) => { init(arg).then(data => {
store.set(data); store.set(data);
}); });
}; };

View File

@@ -17,33 +17,33 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Player, SchematicType } from "@type/data"; import type {Player, SchematicType} from "@type/data";
import { PlayerSchema } from "@type/data.ts"; import {PlayerSchema} from "@type/data.ts";
import { cached, cachedFamily } from "./cached"; import {cached, cachedFamily} from "./cached";
import type { Team } from "@type/team.ts"; import type {Team} from "@type/team.ts";
import { TeamSchema } from "@type/team"; import {TeamSchema} from "@type/team";
import { derived, get, writable } from "svelte/store"; import {derived, get, writable} from "svelte/store";
import { z } from "zod"; import {z} from "zod";
import { fetchWithToken, tokenStore } from "@repo/repo.ts"; import {fetchWithToken, tokenStore} from "@repo/repo.ts";
import { pageRepo } from "@repo/page.ts"; import {pageRepo} from "@repo/page.ts";
import { dataRepo } from "@repo/data.ts"; import {dataRepo} from "@repo/data.ts";
import { permsRepo } from "@repo/perms.ts"; import {permsRepo} from "@repo/perms.ts";
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json())); export const schemTypes = cached<SchematicType[]>([], () =>
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
.then(res => res.json()));
export const teams = cached<Team[]>([], async () => { export const players = cached<Player[]>([], async () => {
return get(dataRepo).getTeams(); const res = await fetchWithToken(get(tokenStore), "/data/admin/users");
return z.array(PlayerSchema).parse(await res.json());
}); });
export const permissions = cached( export const permissions = cached({
{ perms: [],
perms: [], prefixes: {},
prefixes: {}, }, async () => {
}, return get(permsRepo).listPerms();
async () => { });
return get(permsRepo).listPerms();
}
);
export const gamemodes = cached<string[]>([], async () => { export const gamemodes = cached<string[]>([], async () => {
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes"); const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
@@ -51,7 +51,7 @@ export const gamemodes = cached<string[]>([], async () => {
}); });
export const maps = cachedFamily<string, string[]>([], async (gamemode) => { export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
if ((await gamemodes.future).every((value) => value !== gamemode)) return []; if (get(gamemodes).every(value => value !== gamemode)) return [];
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`); const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
if (!res.ok) { if (!res.ok) {
@@ -66,12 +66,17 @@ export const groups = cached<string[]>([], async () => {
return z.array(z.string()).parse(await res.json()); return z.array(z.string()).parse(await res.json());
}); });
export const teams = cached<Team[]>([], async () => {
const res = await fetchWithToken(get(tokenStore), "/team");
return z.array(TeamSchema).parse(await res.json());
});
export const branches = cached<string[]>([], async () => { export const branches = cached<string[]>([], async () => {
const res = await get(pageRepo).getBranches(); const res = await get(pageRepo).getBranches();
return z.array(z.string()).parse(res); return z.array(z.string()).parse(res);
}); });
export const server = derived(dataRepo, ($dataRepo) => $dataRepo.getServer()); export const server = derived(dataRepo, $dataRepo => $dataRepo.getServer());
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640); export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);

View File

@@ -1,19 +0,0 @@
import { z } from "zod";
export const AuditLogEntrySchema = z.object({
id: z.number(),
time: z.number(),
server: z.string(),
serverOwner: z.string().nullable(),
actor: z.string(),
actionType: z.enum(["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"]),
actionText: z.string(),
});
export const PagedAutidLogSchema = z.object({
entries: z.array(AuditLogEntrySchema),
rows: z.number(),
});
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
export type PagedAuditLog = z.infer<typeof PagedAutidLogSchema>;

View File

@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const SchematicTypeSchema = z.object({ export const SchematicTypeSchema = z.object({
name: z.string(), name: z.string(),
@@ -29,20 +29,12 @@ export type SchematicType = z.infer<typeof SchematicTypeSchema>;
export const PlayerSchema = z.object({ export const PlayerSchema = z.object({
name: z.string(), name: z.string(),
uuid: z.string(), uuid: z.string(),
prefix: z.string().nullable(), prefix: z.string(),
perms: z.array(z.string()).nullable(), perms: z.array(z.string()),
id: z.number().nullable(),
}); });
export type Player = z.infer<typeof PlayerSchema>; export type Player = z.infer<typeof PlayerSchema>;
export const PlayerListSchema = z.object({
entries: z.array(PlayerSchema),
rows: z.number(),
});
export type PlayerList = z.infer<typeof PlayerListSchema>;
export const ServerSchema = z.object({ export const ServerSchema = z.object({
description: z.any(), description: z.any(),
players: z.object({ players: z.object({
@@ -65,12 +57,3 @@ export const ResponseErrorSchema = z.object({
}); });
export type ResponseError = z.infer<typeof ResponseErrorSchema>; export type ResponseError = z.infer<typeof ResponseErrorSchema>;
export const ResponseUserSchema = z.object({
name: z.string(),
uuid: z.string(),
prefix: z.string(),
perms: z.array(z.string()),
});
export type ResponseUser = z.infer<typeof ResponseUserSchema>;

View File

@@ -17,58 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
import { TeamSchema } from "./team.js"; import {TeamSchema} from "./team.js";
import { PlayerSchema, ResponseUserSchema } from "./data.js"; import {PlayerSchema} from "./data.js";
export const ResponseGroupsSchema = z.object({
id: z.number(),
name: z.string(),
pointsPerWin: z.number(),
pointsPerLoss: z.number(),
pointsPerDraw: z.number(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
points: z.record(z.string(), z.number()).nullable(),
});
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: ResponseGroupsSchema.nullable(),
hasFinished: z.boolean(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const EventFightEditSchema = EventFightSchema.omit({
id: true,
group: true,
hasFinished: true,
}).extend({
group: z.number().nullable(),
});
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
export const ResponseRelationSchema = z.object({
id: z.number(),
fight: z.number(),
team: z.enum(["RED", "BLUE"]),
type: z.enum(["FIGHT", "GROUP"]),
fromFight: EventFightSchema.optional(),
fromGroup: ResponseGroupsSchema.optional(),
fromPlace: z.number(),
});
export type ResponseRelation = z.infer<typeof ResponseRelationSchema>;
export const ShortEventSchema = z.object({ export const ShortEventSchema = z.object({
id: z.number(), id: z.number(),
@@ -84,69 +35,29 @@ export const SWEventSchema = ShortEventSchema.extend({
maxTeamMembers: z.number(), maxTeamMembers: z.number(),
schemType: z.string().nullable(), schemType: z.string().nullable(),
publicSchemsOnly: z.boolean(), publicSchemsOnly: z.boolean(),
referees: z.array(PlayerSchema),
}); });
export type SWEvent = z.infer<typeof SWEventSchema>; export type SWEvent = z.infer<typeof SWEventSchema>;
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: z.string().nullable(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const ExtendedEventSchema = z.object({ export const ExtendedEventSchema = z.object({
event: SWEventSchema, event: SWEventSchema,
teams: z.array(TeamSchema), teams: z.array(TeamSchema),
groups: z.array(ResponseGroupsSchema),
fights: z.array(EventFightSchema), fights: z.array(EventFightSchema),
referees: z.array(ResponseUserSchema),
relations: z.array(ResponseRelationSchema),
}); });
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>; export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
export const ResponseTeamSchema = z.object({
id: z.number(),
name: z.string(),
kuerzel: z.string(),
color: z.string(),
});
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
export const CreateEventGroupSchema = z.object({
name: z.string(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
});
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
export const UpdateEventGroupSchema = z.object({
name: z.string().nullable().optional(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
pointsPerWin: z.number().nullable().optional(),
pointsPerLoss: z.number().nullable().optional(),
pointsPerDraw: z.number().nullable().optional(),
});
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
export const GroupEditSchema = ResponseGroupsSchema.omit({
id: true,
points: true,
});
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
export const CreateEventRelationSchema = z.object({
fightId: z.number(),
team: z.enum(["RED", "BLUE"]),
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
export const UpdateFromRelationSchema = z.object({
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
export const UpdateEventRelationSchema = z.object({
team: z.enum(["RED", "BLUE"]).nullable().optional(),
from: UpdateFromRelationSchema.nullable().optional(),
});
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;

View File

@@ -17,11 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const PrefixSchema = z.object({ export const PrefixSchema = z.object({
name: z.string().startsWith("PREFIX_"), name: z.string().startsWith("PREFIX_"),
colorCode: z.string().startsWith("§"), colorCode: z.string().length(2).startsWith("§"),
chatPrefix: z.string(), chatPrefix: z.string(),
}); });

View File

@@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const TeamSchema = z.object({ export const TeamSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
kuerzel: z.string().min(1).max(16), kuerzel: z.string().min(1).max(4),
color: z.string().max(1), color: z.string().max(1),
}); });

View File

@@ -1,122 +0,0 @@
<script lang="ts">
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown } from "lucide-svelte";
import { cn } from "@components/utils";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
let {
value = $bindable(null),
multiple = false,
placeholder = "Select player...",
onSelect,
}: {
value?: number | number[] | null;
multiple?: boolean;
placeholder?: string;
onSelect?: (player: Player) => void;
} = $props();
let open = $state(false);
let search = $state("");
let players: Player[] = $state([]);
let loading = $state(false);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 50, 0, false, true);
players = res.entries;
} finally {
loading = false;
}
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
function handleSelect(player: Player) {
if (onSelect) {
onSelect(player);
}
if (multiple) {
if (Array.isArray(value)) {
if (value.includes(player.id!)) {
value = value.filter((v) => v !== player.id);
} else {
value = [...value, player.id!];
}
} else {
value = [player.id!];
}
} else {
if (value === player.id) {
value = null; // Deselect
} else {
value = player.id;
open = false;
}
}
}
function isSelected(id: number) {
if (multiple) {
return Array.isArray(value) && value.includes(id);
}
return value === id;
}
let triggerLabel = $derived.by(() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
return `${placeholder} (${value.length})`;
}
return placeholder;
} else {
// We might need to fetch the selected player's name if it's not in the current list
// For now, let's just show the placeholder or "Selected"
// Ideally we would have a way to resolve the name from the UUID if it's not in `players`
// But `players` only contains search results.
// If we want to show the name, we might need to fetch it or pass it in.
// Given the context of AuditLog, it shows "Spieler Filter (count)".
// Given RefereesList, it's a button "Hinzufügen".
return placeholder;
}
});
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class={cn("justify-between", Array.isArray(value) && !value?.length && "text-muted-foreground")} {...props} role="combobox" aria-expanded={open}>
{triggerLabel}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={search} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No players found.</CommandEmpty>
<CommandGroup>
{#each players as player (player.uuid)}
<CommandItem value={player.id?.toString()} onSelect={() => handleSelect(player)}>
<Check class={cn("mr-2 size-4", isSelected(player.id!) ? "opacity-100" : "opacity-0")} />
{player.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>

View File

@@ -3,11 +3,19 @@
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.ActionProps;
ref = $bindable(null), type $$Events = AlertDialogPrimitive.ActionEvents;
class: className,
...restProps let className: $$Props["class"] = undefined;
}: AlertDialogPrimitive.ActionProps = $props(); export { className as class };
</script> </script>
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} /> <AlertDialogPrimitive.Action
class={cn(buttonVariants(), className)}
{...$$restProps}
on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Action>

View File

@@ -3,15 +3,19 @@
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.CancelProps;
ref = $bindable(null), type $$Events = AlertDialogPrimitive.CancelEvents;
class: className,
...restProps let className: $$Props["class"] = undefined;
}: AlertDialogPrimitive.CancelProps = $props(); export { className as class };
</script> </script>
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
bind:ref
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)} class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...restProps} {...$$restProps}
/> on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Cancel>

View File

@@ -1,26 +1,28 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte"; import * as AlertDialog from "./index.js";
import { cn } from "$lib/components/utils.js"; import { cn, flyAndScale } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.ContentProps;
ref = $bindable(null),
class: className, export let transition: $$Props["transition"] = flyAndScale;
portalProps, export let transitionConfig: $$Props["transitionConfig"] = undefined;
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & { let className: $$Props["class"] = undefined;
portalProps?: AlertDialogPrimitive.PortalProps; export { className as class };
} = $props();
</script> </script>
<AlertDialogPrimitive.Portal {...portalProps}> <AlertDialog.Portal>
<AlertDialogOverlay /> <AlertDialog.Overlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
bind:ref {transition}
{transitionConfig}
class={cn( class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg", "bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
className className
)} )}
{...restProps} {...$$restProps}
/> >
</AlertDialogPrimitive.Portal> <slot />
</AlertDialogPrimitive.Content>
</AlertDialog.Portal>

View File

@@ -2,15 +2,15 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.DescriptionProps;
ref = $bindable(null),
class: className, let className: $$Props["class"] = undefined;
...restProps export { className as class };
}: AlertDialogPrimitive.DescriptionProps = $props();
</script> </script>
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
bind:ref
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...$$restProps}
/> >
<slot />
</AlertDialogPrimitive.Description>

View File

@@ -1,20 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = HTMLAttributes<HTMLDivElement>;
ref = $bindable(null),
class: className, let className: $$Props["class"] = undefined;
children, export { className as class };
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...restProps} {...$$restProps}
> >
{@render children?.()} <slot />
</div> </div>

View File

@@ -1,20 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = HTMLAttributes<HTMLDivElement>;
ref = $bindable(null),
class: className, let className: $$Props["class"] = undefined;
children, export { className as class };
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div class={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...$$restProps}>
bind:this={ref} <slot />
class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div> </div>

View File

@@ -1,19 +1,21 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.OverlayProps;
ref = $bindable(null),
class: className, let className: $$Props["class"] = undefined;
...restProps export let transition: $$Props["transition"] = fade;
}: AlertDialogPrimitive.OverlayProps = $props(); export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
};
export { className as class };
</script> </script>
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
bind:ref {transition}
class={cn( {transitionConfig}
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80", class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm ", className)}
className {...$$restProps}
)}
{...restProps}
/> />

View File

@@ -2,17 +2,13 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = AlertDialogPrimitive.TitleProps;
ref = $bindable(null),
class: className, let className: $$Props["class"] = undefined;
level = 3, export let level: $$Props["level"] = "h3";
...restProps export { className as class };
}: AlertDialogPrimitive.TitleProps = $props();
</script> </script>
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title class={cn("text-lg font-semibold", className)} {level} {...$$restProps}>
bind:ref <slot />
class={cn("text-lg font-semibold", className)} </AlertDialogPrimitive.Title>
{level}
{...restProps}
/>

View File

@@ -1,7 +1,9 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Title from "./alert-dialog-title.svelte"; import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte"; import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte"; import Cancel from "./alert-dialog-cancel.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Footer from "./alert-dialog-footer.svelte"; import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte"; import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte"; import Overlay from "./alert-dialog-overlay.svelte";
@@ -10,7 +12,6 @@ import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root; const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger; const Trigger = AlertDialogPrimitive.Trigger;
const Portal = AlertDialogPrimitive.Portal;
export { export {
Root, Root,

View File

@@ -1,35 +1,35 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { Checkbox as CheckboxPrimitive } from "bits-ui";
import Check from "@lucide/svelte/icons/check"; import Check from "lucide-svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus"; import Minus from "lucide-svelte/icons/minus";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
let { type $$Props = CheckboxPrimitive.Props;
ref = $bindable(null), type $$Events = CheckboxPrimitive.Events;
checked = $bindable(false),
indeterminate = $bindable(false), let className: $$Props["class"] = undefined;
class: className, export let checked: $$Props["checked"] = false;
...restProps export { className as class };
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script> </script>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
bind:ref
class={cn( class={cn(
"border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content size-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50", "border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
className className
)} )}
bind:checked bind:checked
bind:indeterminate {...$$restProps}
{...restProps} on:click
> >
{#snippet children({ checked, indeterminate })} <CheckboxPrimitive.Indicator
<div class="flex size-4 items-center justify-center text-current"> class={cn("flex h-4 w-4 items-center justify-center text-current")}
{#if indeterminate} let:isChecked
<Minus class="size-3.5" /> let:isIndeterminate
{:else} >
<Check class={cn("size-3.5", !checked && "text-transparent")} /> {#if isChecked}
{/if} <Check class="h-3.5 w-3.5" />
</div> {:else if isIndeterminate}
{/snippet} <Minus class="h-3.5 w-3.5" />
{/if}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>

View File

@@ -24,15 +24,14 @@
import { ScrollArea } from "$lib/components/ui/scroll-area"; import { ScrollArea } from "$lib/components/ui/scroll-area";
import { CalendarIcon } from "lucide-svelte"; import { CalendarIcon } from "lucide-svelte";
import { cn } from "@components/utils"; import { cn } from "@components/utils";
import { fromDate, type ZonedDateTime } from "@internationalized/date"; import type {ZonedDateTime} from "@internationalized/date";
import Input from "../input/input.svelte";
let { let {
value = $bindable(), value = $bindable(),
onChange, onChange
}: { }: {
value: ZonedDateTime; value: ZonedDateTime
onChange?: ((date: ZonedDateTime | undefined) => void) | undefined; onChange?: ((date: ZonedDateTime | undefined) => void) | undefined
} = $props(); } = $props();
let isOpen = $state(false); let isOpen = $state(false);
@@ -64,7 +63,13 @@
<Popover bind:open={isOpen}> <Popover bind:open={isOpen}>
<PopoverTrigger> <PopoverTrigger>
<Button variant="outline" class={cn("w-full justify-start text-left font-normal", !value && "text-muted-foreground")}> <Button
variant="outline"
class={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
{#if value} {#if value}
{new Intl.DateTimeFormat("de-DE", { {new Intl.DateTimeFormat("de-DE", {
@@ -81,14 +86,23 @@
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0"> <PopoverContent class="w-auto p-0">
<Input type="datetime-local" value={value.toString().slice(0, 16)} onchange={(e) => handleDateSelect(fromDate(e.target.valueAsDate, "Europe/Berlin"))} />
<div class="sm:flex"> <div class="sm:flex">
<Calendar mode="single" bind:value onValueChange={(date) => handleDateSelect(date)} initialFocus /> <Calendar
mode="single"
bind:value
onValueChange={(date) => handleDateSelect(date)}
initialFocus
/>
<div class="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x"> <div class="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x">
<ScrollArea class="w-64 sm:w-auto"> <ScrollArea class="w-64 sm:w-auto">
<div class="flex sm:flex-col p-2"> <div class="flex sm:flex-col p-2">
{#each [...hours].reverse() as hour} {#each [...hours].reverse() as hour}
<Button size="icon" variant={value && value.hour === hour ? "default" : "ghost"} class="sm:w-full shrink-0 aspect-square" onclick={() => handleTimeChange("hour", hour)}> <Button
size="icon"
variant={value && value.hour === hour ? "default" : "ghost"}
class="sm:w-full shrink-0 aspect-square"
onclick={() => handleTimeChange("hour", hour)}
>
{hour} {hour}
</Button> </Button>
{/each} {/each}
@@ -99,12 +113,12 @@
<div class="flex sm:flex-col p-2"> <div class="flex sm:flex-col p-2">
{#each Array.from({ length: 60 }, (_, i) => i) as minute} {#each Array.from({ length: 60 }, (_, i) => i) as minute}
<Button <Button
size="icon" size="icon"
variant={value && value.minute === minute ? "default" : "ghost"} variant={value && value.minute === minute ? "default" : "ghost"}
class="sm:w-full shrink-0 aspect-square" class="sm:w-full shrink-0 aspect-square"
onclick={() => handleTimeChange("minute", minute)} onclick={() => handleTimeChange("minute", minute)}
> >
{minute.toString().padStart(2, "0")} {minute.toString().padStart(2, '0')}
</Button> </Button>
{/each} {/each}
</div> </div>

View File

@@ -1,6 +1,29 @@
import Root from "./input.svelte"; import Root from "./input.svelte";
export {
Root, export type FormInputEvent<T extends Event = Event> = T & {
// currentTarget: EventTarget & HTMLInputElement;
Root as Input, };
export type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>;
};
export {
Root,
//
Root as Input,
}; };

View File

@@ -1,39 +1,42 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements"; import type { HTMLInputAttributes } from "svelte/elements";
import { cn } from "@components/utils"; import type { InputEvents } from "./index.js";
import { type WithElementRef } from "bits-ui"; import { cn } from "$lib/components/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<Omit<HTMLInputAttributes, "type"> & ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })>; type $$Props = HTMLInputAttributes;
let { ref = $bindable(null), value = $bindable(), type, files = $bindable(), class: className, ...restProps }: Props = $props(); type $$Events = InputEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
</script> </script>
{#if type === "file"} <input
<input class={cn(
bind:this={ref} "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
data-slot="input" className
class={cn( )}
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", bind:value
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", {readonly}
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", on:blur
className on:change
)} on:click
type="file" on:focus
bind:files on:focusin
bind:value on:focusout
{...restProps} on:keydown
/> on:keypress
{:else} on:keyup
<input on:mouseover
bind:this={ref} on:mouseenter
data-slot="input" on:mouseleave
class={cn( on:mousemove
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", on:paste
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", on:input
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", on:wheel|passive
className {...$$restProps}
)} />
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -1,9 +1,10 @@
import { Menubar as MenubarPrimitive } from "bits-ui"; import { Menubar as MenubarPrimitive } from "bits-ui";
import Root from "./menubar.svelte"; import Root from "./menubar.svelte";
import CheckboxItem from "./menubar-checkbox-item.svelte"; import CheckboxItem from "./menubar-checkbox-item.svelte";
import Content from "./menubar-content.svelte"; import Content from "./menubar-content.svelte";
import Item from "./menubar-item.svelte"; import Item from "./menubar-item.svelte";
import GroupHeading from "./menubar-group-heading.svelte"; import Label from "./menubar-label.svelte";
import RadioItem from "./menubar-radio-item.svelte"; import RadioItem from "./menubar-radio-item.svelte";
import Separator from "./menubar-separator.svelte"; import Separator from "./menubar-separator.svelte";
import Shortcut from "./menubar-shortcut.svelte"; import Shortcut from "./menubar-shortcut.svelte";
@@ -21,7 +22,7 @@ export {
CheckboxItem, CheckboxItem,
Content, Content,
Item, Item,
GroupHeading, Label,
RadioItem, RadioItem,
Separator, Separator,
Shortcut, Shortcut,
@@ -37,7 +38,7 @@ export {
CheckboxItem as MenubarCheckboxItem, CheckboxItem as MenubarCheckboxItem,
Content as MenubarContent, Content as MenubarContent,
Item as MenubarItem, Item as MenubarItem,
GroupHeading as MenubarGroupHeading, Label as MenubarLabel,
RadioItem as MenubarRadioItem, RadioItem as MenubarRadioItem,
Separator as MenubarSeparator, Separator as MenubarSeparator,
Shortcut as MenubarShortcut, Shortcut as MenubarShortcut,

View File

@@ -1,40 +1,35 @@
<script lang="ts"> <script lang="ts">
import { Menubar as MenubarPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { Menubar as MenubarPrimitive } from "bits-ui";
import Check from "@lucide/svelte/icons/check"; import Check from "lucide-svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
import type { Snippet } from "svelte";
let { type $$Props = MenubarPrimitive.CheckboxItemProps;
ref = $bindable(null), type $$Events = MenubarPrimitive.CheckboxItemEvents;
class: className,
checked = $bindable(false), let className: $$Props["class"] = undefined;
indeterminate = $bindable(false), export let checked: $$Props["checked"] = false;
children: childrenProp, export { className as class };
...restProps
}: WithoutChildrenOrChild<MenubarPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script> </script>
<MenubarPrimitive.CheckboxItem <MenubarPrimitive.CheckboxItem
bind:ref
bind:checked bind:checked
bind:indeterminate
class={cn( class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className
)} )}
{...restProps} on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
on:pointerdown
{...$$restProps}
> >
{#snippet children({ checked, indeterminate })} <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span class="absolute left-2 flex size-3.5 items-center justify-center"> <MenubarPrimitive.CheckboxIndicator>
{#if indeterminate} <Check class="h-4 w-4" />
<Minus class="size-4" /> </MenubarPrimitive.CheckboxIndicator>
{:else} </span>
<Check class={cn("size-4", !checked && "text-transparent")} /> <slot />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</MenubarPrimitive.CheckboxItem> </MenubarPrimitive.CheckboxItem>

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