234 Commits

Author SHA1 Message Date
f95cf6cbfa Fix pro-wargear.md
Some checks failed
SteamWarCI Build failed
2025-10-30 16:06:13 +01:00
972b8da9e6 Enhance EventFight handling by adding conditional relation names and improving group button visibility
Some checks failed
SteamWarCI Build failed
2025-10-30 12:06:44 +01:00
cb41356351 Fix date in 2025-halloween.md
All checks were successful
SteamWarCI Build successful
2025-10-27 19:32:08 +01:00
276dc56627 Add 2025-halloween.md
All checks were successful
SteamWarCI Build successful
2025-10-27 13:21:07 +01:00
0edec9cdf0 Add 2025-halloween.md
All checks were successful
SteamWarCI Build successful
2025-10-27 11:03:24 +01:00
4703fde5a3 src/content/downloads/advancedscripts.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-10-07 23:09:54 +02:00
954a8cc318 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 18:29:49 +02:00
1229edbf51 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 18:16:46 +02:00
00bce50a49 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 18:06:44 +02:00
5a44f2160c Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 17:55:25 +02:00
9b65d5d730 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 17:33:42 +02:00
8397aace8d Update Eventplan MWGL2025 2025-09-28 17:33:36 +02:00
c2b0bcc54e Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 17:21:46 +02:00
5c48f0cb85 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 17:04:14 +02:00
d30cceaad0 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:52:46 +02:00
41be843be4 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:47:39 +02:00
3768788f32 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:34:31 +02:00
7e6f953e44 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:32:28 +02:00
cad3a795a7 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:20:02 +02:00
48e8165417 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-28 16:10:34 +02:00
b11534490d Refactor EventFight handling to include team relation names and update type definitions
All checks were successful
SteamWarCI Build successful
2025-09-28 14:11:58 +02:00
c0f4a852b5 Refactor event handling and introduce TeamSelector component for improved fight management
All checks were successful
SteamWarCI Build successful
2025-09-28 10:26:08 +02:00
54d49cca5b Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:41:56 +02:00
831ea3af11 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:14:21 +02:00
b6a0692c50 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:13:48 +02:00
01394953d4 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:12:09 +02:00
c515b19e74 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:09:57 +02:00
98199cc9a0 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 19:08:31 +02:00
3f61564067 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:53:59 +02:00
7b0f18f65d Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:40:17 +02:00
4ac5d2d2b2 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:37:20 +02:00
8fd3e04116 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:10:17 +02:00
3180ad1263 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:09:56 +02:00
f689415b98 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 18:08:59 +02:00
894d0f8a05 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 17:56:24 +02:00
16d377e3e4 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 17:46:19 +02:00
1b2a05c204 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 17:11:35 +02:00
04969e79c3 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 16:45:08 +02:00
a949237334 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 16:09:41 +02:00
01a59d6de4 Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 13:48:50 +02:00
3daeb8b62d Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 12:45:30 +02:00
aa72de70ef Update Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 12:06:12 +02:00
324025dd57 Add Eventplan MWGL2025
All checks were successful
SteamWarCI Build successful
2025-09-27 12:02:51 +02:00
41b847b3e4 Refactor colorCode validation in PrefixSchema to allow any length string starting with "§"
All checks were successful
SteamWarCI Build successful
2025-09-16 18:07:39 +02:00
a3b4a6d0c2 Refactor event and fight repositories to use numeric IDs for groups; update datetime picker input handling; add new generator components for event fights and group phases.
All checks were successful
SteamWarCI Build successful
2025-09-16 18:03:29 +02:00
5f12a0cc7a Update kuerzel max length in TeamSchema to 16 characters
All checks were successful
SteamWarCI Build successful
2025-08-13 21:06:35 +02:00
7166575806 Fix section number for cannon count in WarShip rules
All checks were successful
SteamWarCI Build successful
2025-08-13 20:58:50 +02:00
0055e9fb9c Update WarShip rules to clarify restrictions on protective materials
All checks were successful
SteamWarCI Build successful
2025-08-13 20:55:49 +02:00
fc5a209638 Refactor WarShip rules for clarity and structure; added section numbers and improved definitions.
All checks were successful
SteamWarCI Build successful
2025-08-13 20:54:07 +02:00
c7cdc19102 Fix typo in WarGear Event announcement text
All checks were successful
SteamWarCI Build successful
2025-08-12 20:57:09 +02:00
c6bbe8c9c8 Add team size information to WarGear Event announcement
All checks were successful
SteamWarCI Build successful
2025-08-11 22:46:26 +02:00
1cec1b917e Add note about new Schematic type for WarGear Event
All checks were successful
SteamWarCI Build successful
2025-08-11 22:45:40 +02:00
13805c7f3f Add WarGear Event announcement for November 2025
All checks were successful
SteamWarCI Build successful
2025-08-11 22:41:15 +02:00
da668c574a Updated mwgl.md
All checks were successful
SteamWarCI Build successful
2025-07-28 13:42:19 +02:00
2aab86573a Add Image generated-image(8).png
All checks were successful
SteamWarCI Build successful
2025-07-28 13:41:51 +02:00
5d7eb3b8fb Merge pull request 'Merge branch mwgl' (#16) from mwgl into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #16
2025-07-28 13:02:14 +02:00
6933af1554 Updated mwgl.md
All checks were successful
SteamWarCI Build successful
2025-07-28 13:01:56 +02:00
e607ea1343 Updated mwgl.md
All checks were successful
SteamWarCI Build successful
2025-07-28 13:00:51 +02:00
b0ae4e978e Create page announcements/de/mwgl.md
All checks were successful
SteamWarCI Build successful
2025-07-28 12:57:57 +02:00
8fe273f3e0 Add Open-Source section to documentation
All checks were successful
SteamWarCI Build successful
2025-07-10 17:57:42 +02:00
1b48cbe1f4 Update edit link base URL to point to the master branch
All checks were successful
SteamWarCI Build successful
2025-07-10 13:51:09 +02:00
7276552ed1 Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
All checks were successful
SteamWarCI Build successful
2025-07-10 13:49:15 +02:00
a2ef92aaad Add Docs 2025-07-10 13:49:00 +02:00
8b85cd0729 src/content/modes/spacecraft.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-06-29 22:51:13 +02:00
2d024cf64b Create page modes/spacecraft.json
Some checks failed
SteamWarCI Build failed
2025-06-29 22:49:58 +02:00
13d76d0a97 Updated SC-Eventplan.md
All checks were successful
SteamWarCI Build successful
2025-06-29 20:34:55 +02:00
e65fadb65c Updated SC-Eventplan.md
All checks were successful
SteamWarCI Build successful
2025-06-29 20:32:23 +02:00
6b4693b7f1 Updated SC-Eventplan.md
All checks were successful
SteamWarCI Build successful
2025-06-29 20:29:54 +02:00
92282006fe Add Image SpaceCraftWinners3.png
All checks were successful
SteamWarCI Build successful
2025-06-29 20:29:16 +02:00
5457632598 Fix formatting of teamPoints calculation in GroupTable component for improved readability
All checks were successful
SteamWarCI Build successful
2025-06-29 19:47:35 +02:00
bed134f8e0 Fix group points retrieval in GroupTable component to ensure correct mapping of event groups
All checks were successful
SteamWarCI Build successful
2025-06-29 19:45:18 +02:00
353a415990 Refactor GroupTable component to use $props for event, group, and rows; simplify teamPoints calculation with derived state
All checks were successful
SteamWarCI Build successful
2025-06-29 19:41:43 +02:00
3c6d0f8528 Fix
All checks were successful
SteamWarCI Build successful
2025-06-29 14:00:09 +02:00
887235dc86 Enhance caching mechanism by adding future promise to cached function and updating maps retrieval logic to use it
All checks were successful
SteamWarCI Build successful
2025-06-29 13:54:03 +02:00
a99a066f0d Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
All checks were successful
SteamWarCI Build successful
2025-06-29 11:24:07 +02:00
e5e3c15b07 Add refresh functionality and duplicate fight feature in FightEditRow component 2025-06-29 11:23:51 +02:00
fb74689c39 Updated SC-Eventplan.md
All checks were successful
SteamWarCI Build successful
2025-06-29 09:15:11 +02:00
18b1f97a84 src/content/announcements/de/SC-Eventplan.md aktualisiert
All checks were successful
SteamWarCI Build successful
2025-06-29 03:07:37 +02:00
53b81db2c4 Updated SC-Eventplan.md
Some checks failed
SteamWarCI Build failed
2025-06-29 02:44:17 +02:00
2314b4c5b5 Updated SC-Eventplan.md
Some checks failed
SteamWarCI Build failed
2025-06-28 17:53:43 +02:00
6a81936f77 Updated SC-Eventplan.md
Some checks failed
SteamWarCI Build failed
2025-06-28 17:32:32 +02:00
a128de3213 Updated SC-Eventplan.md
Some checks failed
SteamWarCI Build failed
2025-06-28 17:31:58 +02:00
6df661f885 Create page announcements/de/SC-Eventplan.md
Some checks failed
SteamWarCI Build failed
2025-06-28 17:21:11 +02:00
a32d84ed86 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-27 16:12:51 +02:00
e60cebc9a3 Updated sw-arcade-fightplan.md
All checks were successful
SteamWarCI Build successful
2025-06-27 00:31:58 +02:00
3576d5e034 Refactor save method to remove base64 encoding for page content
All checks were successful
SteamWarCI Build successful
2025-06-27 00:31:08 +02:00
d5c7d8fc27 Updated sw-arcade-fightplan.md
Some checks failed
SteamWarCI Build failed
2025-06-27 00:29:26 +02:00
ce895e9297 Add default value to prompt for change description in page update
All checks were successful
SteamWarCI Build successful
2025-06-27 00:28:37 +02:00
7c83ad0937 Add prompt for change description in page update
All checks were successful
SteamWarCI Build successful
2025-06-27 00:28:16 +02:00
5e0a9d89b3 Fixing
All checks were successful
SteamWarCI Build successful
2025-06-27 00:24:13 +02:00
2a8b98ce5b Update copyright year to 2025 in table.css
All checks were successful
SteamWarCI Build successful
2025-06-26 23:57:55 +02:00
427818d6bf Fixing
Some checks failed
SteamWarCI Build failed
2025-06-26 23:56:45 +02:00
8424c14ca9 Remove unused import of ExtendedEvent from TeamTable.svelte
Some checks failed
SteamWarCI Build failed
2025-06-26 23:54:21 +02:00
602a7e1453 Remove unused import of Team from TeamTable.svelte
Some checks failed
SteamWarCI Build failed
2025-06-26 23:53:03 +02:00
9f31c5ff0c Remove unused import of Team from EventFightList.svelte
Some checks failed
SteamWarCI Build failed
2025-06-26 23:50:28 +02:00
8a41b98c58 Remove unused import of ExtendedEvent from EventFightList.svelte
Some checks failed
SteamWarCI Build failed
2025-06-26 23:49:09 +02:00
9fc5c500f5 Merge pull request 'Event Brackets' (#11) from event-brackets into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #11
2025-06-26 23:40:59 +02:00
bc879d7cad Add login.page to de.json
All checks were successful
SteamWarCI Build successful
2025-06-26 15:02:08 +02:00
96f0019dc1 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-22 16:55:31 +02:00
7418b608ab Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-21 23:33:31 +02:00
3802b9bc26 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-13 14:21:16 +02:00
03effd2fd2 src/content/downloads/advancedscripts.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-06-12 20:35:23 +02:00
a4669a897b Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-06 19:20:33 +02:00
bd1c4f7f45 feat: Refactor event management components and introduce EventModel for better state handling
All checks were successful
SteamWarCI Build successful
2025-06-04 11:33:11 +02:00
eac0d5592d src/i18n/common/de.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-06-03 14:11:22 +02:00
bd9aea8f35 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-03 09:53:14 +02:00
6e715cee07 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-03 07:11:00 +02:00
4147a1d243 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-02 23:46:46 +02:00
46dba2a6f9 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-02 23:44:12 +02:00
3d8ad3a129 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-02 23:40:31 +02:00
7d50a4db12 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-06-02 23:30:42 +02:00
df389b3acf Merge remote-tracking branch 'origin/master' into event-brackets
All checks were successful
SteamWarCI Build successful
2025-06-01 15:40:33 +02:00
4ecb5fa024 Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
All checks were successful
SteamWarCI Build successful
2025-05-31 21:02:48 +02:00
27f0b962c1 feat: Enhance article styling with code and link formatting 2025-05-31 21:02:24 +02:00
e37583329c src/content/pages/de/verhaltensrichtlienien.md aktualisiert
All checks were successful
SteamWarCI Build successful
2025-05-31 20:55:31 +02:00
20b7a32b1b package.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-05-31 20:54:29 +02:00
dd7d701c48 Fix formatting
Some checks failed
SteamWarCI Build failed
2025-05-31 09:56:01 +02:00
3173b537bc Fix 'letztes update'
Some checks failed
SteamWarCI Build failed
2025-05-31 09:53:36 +02:00
5e2e4e2281 Fix some verhaltensrichtlienien.md not copied over from the old version.
Some checks failed
SteamWarCI Build failed
2025-05-31 09:50:52 +02:00
da3699167b feat: Add frontmatter editor and enhance page management with YAML support; update dependencies and improve UI interactions
Some checks failed
SteamWarCI Build failed
2025-05-29 12:35:58 +02:00
10ff84d410 Add Image left.png
Some checks failed
SteamWarCI Build failed
2025-05-29 00:43:22 +02:00
7d75453be5 Refactor FightTable and GroupTable components to use numeric group identifiers; enhance event handling in FightEdit and EventFightList; add new Pages management UI with editor tabs; improve event data handling and display logic; update event types to include hasFinished status; optimize announcement page rendering and structure.
Some checks failed
SteamWarCI Build failed
2025-05-28 12:30:05 +02:00
86bfaf4683 Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-05-26 10:56:46 +02:00
f9212649ad Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-05-26 10:55:26 +02:00
4972ebf9bb Update spacheship-event-ankündigung-und-regelwerk.md
All checks were successful
SteamWarCI Build successful
2025-05-25 19:19:47 +02:00
d5a2fc20e8 src/content/announcements/de/spacheship-event-ankündigung-und-regelwerk.md aktualisiert
All checks were successful
SteamWarCI Build successful
2025-05-25 17:31:44 +02:00
27c5698ac8 tsconfig.json aktualisiert
All checks were successful
SteamWarCI Build successful
2025-05-25 17:27:55 +02:00
fa5f25f37e Merge pull request 'Merge branch spacheship-event-ankündigung-und-regelwerk' (#15) from spacheship-event-ankündigung-und-regelwerk into master
Some checks failed
SteamWarCI Build failed
Reviewed-on: #15
2025-05-25 17:26:03 +02:00
260b7b24c4 src/content/announcements/de/spacheship-event-ankündigung-und-regelwerk.md aktualisiert
All checks were successful
SteamWarCI Build successful
2025-05-25 17:25:56 +02:00
4aea0c7fea Update spacheship-event-ankündigung-und-regelwerk-2.md
Some checks failed
SteamWarCI Build failed
2025-05-25 17:20:45 +02:00
314ff3e7c3 Create page announcements/de/spacheship-event-ankündigung-und-regelwerk.md
Some checks failed
SteamWarCI Build failed
2025-05-25 14:47:46 +02:00
0205108d2d Implement code changes to enhance functionality and improve performance
All checks were successful
SteamWarCI Build successful
2025-05-23 14:25:29 +02:00
2bf3beb044 feat: Implement group management features with dialogs for editing and displaying group results, enhance event creation with a form, and update team and referee management UI
All checks were successful
SteamWarCI Build successful
2025-05-23 14:23:33 +02:00
b440456687 Merge branch 'event-brackets' of https://git.steamwar.de/SteamWar/Website into event-brackets
All checks were successful
SteamWarCI Build successful
2025-05-22 19:42:17 +02:00
5277c9a3fc feat: Enhance event management with FightEdit and GroupEdit components, including improved data handling and new functionalities 2025-05-22 19:41:49 +02:00
2f2c1be958 Merge pull request 'Fix micro rw' (#13) from fix-micro-rw into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #13
Reviewed-by: Chaoscaot <max@chaoscaot.de>
2025-05-21 23:39:31 +02:00
41c7df0d68 Fix micro rw
All checks were successful
SteamWarCI Build successful
2025-05-21 23:37:08 +02:00
cedf641039 Update sw-arcade-fightplan.md
All checks were successful
SteamWarCI Build successful
2025-05-18 13:41:00 +02:00
d9bdc636e3 Merge pull request 'Merge branch sw-arcade-fightplan' (#12) from sw-arcade-fightplan into master
All checks were successful
SteamWarCI Build successful
Reviewed-on: #12
2025-05-14 19:44:22 +02:00
c8d05cb268 Update sw-arcade-fightplan.md
All checks were successful
SteamWarCI Build successful
2025-05-14 19:43:02 +02:00
cb2564c9ce src/content/announcements/de/sw-arcade-fightplan.md aktualisiert
Some checks failed
SteamWarCI Build failed
2025-05-14 19:37:05 +02:00
80caf8fe6d Create page announcements/de/sw-arcade-fightplan
All checks were successful
SteamWarCI Build successful
2025-05-14 19:36:07 +02:00
c4f8824115 Merge branch 'master' into event-brackets
All checks were successful
SteamWarCI Build successful
2025-05-11 10:06:22 +02:00
1da279bb24 feat: Add FightEdit and GroupEdit components for enhanced event management
All checks were successful
SteamWarCI Build successful
2025-05-10 22:22:12 +02:00
fd3d621fd5 Update warship.md
All checks were successful
SteamWarCI Build successful
2025-05-10 21:44:18 +02:00
7d67ad0950 Refactor stores and types for improved data handling and schema definitions
All checks were successful
SteamWarCI Build successful
- Consolidated player fetching logic in stores.ts to utilize dataRepo.
- Introduced teams fetching logic in stores.ts.
- Updated permissions structure in stores.ts for better clarity.
- Enhanced data schemas in data.ts with new ResponseUser and ResponseTeam schemas.
- Expanded event-related schemas in event.ts to include groups, relations, and event creation/update structures.
- Improved code formatting for consistency and readability across files.
2025-05-08 21:47:36 +02:00
6377799e1b style: Improve code formatting and readability across multiple components
All checks were successful
SteamWarCI Build successful
2025-05-07 14:33:48 +02:00
b3598e1ee1 style: Improve code formatting and readability in FightStatistics component
All checks were successful
SteamWarCI Build successful
2025-05-06 13:42:49 +02:00
b9db5be858 style: Improve formatting and readability of WarShip rules
All checks were successful
SteamWarCI Build successful
2025-04-22 23:34:13 +02:00
3e54934806 feat: Enable autoDarkMode in Basic layout for admin new page
All checks were successful
SteamWarCI Build successful
2025-04-18 12:46:21 +02:00
98638f94fc feat: Add autoDarkMode support to Basic layout and update admin index
All checks were successful
SteamWarCI Build successful
2025-04-18 12:43:09 +02:00
4da8fe50c0 feat: Refactor EventEdit and EventFightList components for improved UI and functionality
All checks were successful
SteamWarCI Build successful
- Enhanced EventEdit component with AlertDialog for delete confirmation.
- Added Menubar component to EventFightList for batch editing options.
- Updated alert-dialog components to streamline props and improve reactivity.
- Refactored menubar components for better structure and usability.
- Improved accessibility and code readability across various components.
2025-04-16 12:55:10 +02:00
7757978668 refactor: clean up imports and improve player search functionality in RefereesList
All checks were successful
SteamWarCI Build successful
2025-04-16 00:17:10 +02:00
9eea0b2b3f feat: enhance EventFightList with grouping and selection features
All checks were successful
SteamWarCI Build successful
- Added grouping functionality to the EventFightList component, allowing fights to be grouped by their associated group.
- Implemented row selection with checkboxes for both individual fights and groups, enabling bulk selection.
- Updated columns definition to include a checkbox for selecting all rows and individual row selection checkboxes.
- Modified the checkbox component to support indeterminate state and improved styling.
- Enhanced date formatting for fight start times in the table.
2025-04-15 16:28:19 +02:00
063638d016 Add TeamTable component and improve EventView layout
All checks were successful
SteamWarCI Build successful
2025-04-14 23:31:19 +02:00
f5a778d9b4 Trigger Rebuild
All checks were successful
SteamWarCI Build successful
2025-04-14 18:21:26 +02:00
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
78e1a7b726 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-04-12 21:29:10 +02:00
cd485e8dda Update sw-arcade.md
All checks were successful
SteamWarCI Build successful
2025-04-06 23:07:10 +02:00
182c402c7e Update sw-arcade.md
Some checks failed
SteamWarCI Build failed
2025-04-06 23:05:35 +02:00
098f5b9270 Create page announcements/de/sw-arcade.md
Some checks failed
SteamWarCI Build failed
2025-04-06 23:04:24 +02:00
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
9c8c02f679 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-31 19:43:24 +02:00
3b5fdc57c0 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-25 06:40:14 +01:00
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
17460772e9 Addapted script side for new example hotkey script
All checks were successful
SteamWarCI Build successful
2025-03-21 18:54:26 +01:00
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
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
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
09dc28b6da Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-02 15:07:14 +01:00
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
d7d20e4347 Update wgs25-kampfplan.md
All checks were successful
SteamWarCI Build successful
2025-03-01 10:50:17 +01:00
43bd8f4a7c Update wgs25-kampfplan.md
Some checks failed
SteamWarCI Build failed
2025-03-01 10:49:03 +01:00
18e8627b54 Update wgs25-kampfplan.md
Some checks failed
SteamWarCI Build failed
2025-03-01 10:42:19 +01:00
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
86b479fb28 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 18:34:53 +01:00
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
cfede8f299 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 09:47:30 +01:00
597153ed39 Update missilewars-iii-eventplan.md
All checks were successful
SteamWarCI Build successful
2025-02-23 07:43:22 +01:00
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
367 changed files with 11611 additions and 8300 deletions

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ pnpm-debug.log*
/src/env.d.ts
/src/pages/en/
/.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 tailwind from "@astrojs/tailwind";
import configureI18n from "./astro-i18n.adapter";
@ -7,7 +7,8 @@ import sitemap from "@astrojs/sitemap";
import robotsTxt from "astro-robots-txt";
import path from "node:path";
import mdx from "@astrojs/mdx";
import pagefind from "astro-pagefind";
import starlight from "@astrojs/starlight";
// https://astro.build/config
export default defineConfig({
@ -19,15 +20,40 @@ export default defineConfig({
site: "https://steamwar.de",
integrations: [
svelte(),
tailwind({
configFile: "./tailwind.config.cjs",
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({
configFile: "./tailwind.config.js",
applyBaseStyles: false,
}),
pagefind(),
configureI18n(),
sitemap({
i18n: {
defaultLocale: "en", locales: {
en: "en-US", de: "de-DE",
defaultLocale: "en",
locales: {
en: "en-US",
de: "de-DE",
},
},
}),
@ -51,7 +77,7 @@ export default defineConfig({
{ userAgent: "omgili", disallow: "/" },
{ userAgent: "OmigliBot", disallow: "/" },
{ userAgent: "PerplexityBot", disallow: "/" },
{ userAgent: "Timpibot", disallow: "/" }
{ userAgent: "Timpibot", disallow: "/" },
],
}),
mdx(),
@ -68,6 +94,7 @@ export default defineConfig({
"@layouts": path.resolve("./src/layouts"),
"@repo": path.resolve("./src/components/repo"),
"@stores": path.resolve("./src/components/stores"),
$lib: path.resolve("./src"),
},
},
},

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src\\styles\\app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/components/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}

View File

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

7460
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 406 KiB

21
public/site.webmanifest Normal file
View File

@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -18,13 +18,13 @@
-->
<script lang="ts">
import {twMerge} from "tailwind-merge";
import {onMount} from "svelte";
import { twMerge } from "tailwind-merge";
import { onMount } from "svelte";
let cardElement: HTMLDivElement = $state();
function rotateElement(event: MouseEvent) {
if(!hoverEffect) return;
if (!hoverEffect) return;
const x = event.clientX;
const y = event.clientY;
@ -36,23 +36,23 @@
const rotateX = (centerY - y) / 20;
const rotateY = -(centerX - x) / 20;
cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`);
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`);
}
function resetElement() {
cardElement.style.setProperty('--rotate-x', "0");
cardElement.style.setProperty('--rotate-y', "0");
cardElement.style.setProperty("--rotate-x", "0");
cardElement.style.setProperty("--rotate-y", "0");
}
interface Props {
hoverEffect?: boolean;
extraClasses?: string;
children?: import('svelte').Snippet;
children?: import("svelte").Snippet;
}
let { hoverEffect = true, extraClasses = '', children }: Props = $props();
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))
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));
</script>
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
@ -63,14 +63,14 @@
div {
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6);
:global(h1) {
@apply text-xl font-bold mt-4;
}
: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;
}
}

View File

@ -20,6 +20,7 @@
<script lang="ts">
import {t} from "astro-i18n";
import {statsRepo} from "@repo/stats.ts";
import "@styles/table.css"
interface Props {
@ -64,7 +65,3 @@
<p>{error.message}</p>
{/await}
<style>
@import "../styles/table.css";
</style>

View File

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

View File

@ -79,6 +79,8 @@
})
},
options: {
maintainAspectRatio: false,
scales: {
x: {
type: "time",
@ -105,5 +107,5 @@
</script>
<div>
<canvas bind:this={canvas}></canvas>
<canvas height="500" bind:this={canvas}></canvas>
</div>

View File

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

View File

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

View File

@ -31,8 +31,7 @@
let error: string = $state("");
async function login() {
let {tokenStore} = await import("./repo/repo.ts");
let {authRepo} = await import("./repo/auth.ts");
let {authV2Repo} = await import("./repo/authv2.ts");
if (username === "" || pw === "") {
pw = "";
error = t("login.error");
@ -40,15 +39,14 @@
}
try {
let auth = await get(authRepo).login(username, pw);
if (auth == undefined) {
let auth = await get(authV2Repo).login(username, pw);
if (!auth) {
pw = "";
error = t("login.error");
return;
}
tokenStore.set(auth);
navigate(l("/dashboard"));
await navigate(l("/dashboard"));
} catch (e: any) {
pw = "";
error = t("login.error");

View File

@ -19,39 +19,54 @@
<script lang="ts">
import "../styles/button.css";
import {CaretDownOutline, SearchOutline} from "flowbite-svelte-icons";
import {t} from "astro-i18n";
import {l} from "../util/util";
import {onMount} from "svelte";
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons";
import { t } from "astro-i18n";
import { l } from "../util/util";
import { onMount } from "svelte";
import { loggedIn } from "@repo/authv2.ts";
interface Props {
logo?: import('svelte').Snippet;
logo?: import("svelte").Snippet;
}
let { logo }: Props = $props();
let navbar: HTMLDivElement = $state();
let navbar = $state<HTMLElement>();
let searchOpen = $state(false);
let accountBtn = $state<HTMLAnchorElement>();
$effect(() => {
if ($loggedIn) {
accountBtn!.href = l("/dashboard");
} else {
accountBtn!.href = l("/login");
}
});
onMount(() => {
handleScroll();
})
});
function handleScroll() {
if (window.scrollY > 0) {
navbar.classList.add("before:scale-y-100");
navbar!.classList.add("before:scale-y-100");
} else {
navbar.classList.remove("before:scale-y-100");
navbar!.classList.remove("before:scale-y-100");
}
}
</script>
<svelte:window onscroll={handleScroll}/>
<svelte:window onscroll={handleScroll} />
<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}>
<nav
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">
<a class="flex items-center" href={l("/")}>
{@render logo?.()}
<span class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block">
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block">
{t("navbar.title")}
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span>
</span>
@ -62,7 +77,7 @@
<a href={l("/")}>
<span class="btn__text">{t("navbar.links.home.title")}</span>
</a>
<CaretDownOutline class="ml-2 mt-auto"/>
<CaretDownOutline class="ml-2 mt-auto" />
</button>
<div>
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
@ -76,7 +91,7 @@
<a rel="prefetch" href={l("/rules")}>
<span class="btn__text">{t("navbar.links.rules.title")}</span>
</a>
<CaretDownOutline class="ml-2 mt-auto"/>
<CaretDownOutline class="ml-2 mt-auto" />
</button>
<div>
<a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a>
@ -85,11 +100,11 @@
<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>
<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/microwargear")}
class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
<a href={l("/rules/megawargear")} 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>
<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>
</div>
</div>
<!-- TODO: Add help center
@ -106,7 +121,7 @@
</div>
</div>
-->
<a class="btn" href={l("/login")}>
<a class="btn" href={l("/login")} bind:this={accountBtn}>
<span class="btn__text">{t("navbar.links.account")}</span>
</a>
<!--

View File

@ -1,49 +1,59 @@
---
import {CollectionEntry} from "astro:content";
import {l} from "../util/util";
import {astroI18n} from "astro-i18n";
import {Image} from "astro:assets";
import type { CollectionEntry } from "astro:content";
import { l } from "../util/util";
import { astroI18n } from "astro-i18n";
import { Image } from "astro:assets";
import TagComponent from "./TagComponent.astro";
import P from "./P.astro";
import Card from "@components/Card.svelte";
interface Props {
post: CollectionEntry<"announcements">
post: CollectionEntry<"announcements">;
}
const { post, slim }: {
post: CollectionEntry<"announcements">,
slim: boolean,
const {
post,
slim,
}: {
post: CollectionEntry<"announcements">;
slim: boolean;
} = Astro.props as Props;
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
---
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
<div class={`flex flex-row ${slim ? "":"p-4"}`}>
{post.data.image != null
? (
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}>
<div class={`flex flex-row ${slim ? "" : "p-4"}`}>
{
post.data.image != null ? (
<a href={postUrl}>
<div class="flex-shrink-0 pr-2">
<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" />
<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"
/>
</div>
</a>
)
: null}
) : null
}
<div>
<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>
<P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
<P class="text-gray-500"
>{
Intl.DateTimeFormat(astroI18n.locale, {
day: "numeric",
month: "long",
year: "numeric",
}).format(post.data.created)}</P>
}).format(post.data.created)
}</P
>
<P>{post.data.description}</P>
</a>
<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>

View File

@ -22,38 +22,40 @@
import wrap from "svelte-spa-router/wrap";
import Router, {replace} from "svelte-spa-router";
import {get} from "svelte/store";
import {tokenStore} from "@repo/repo";
import {loggedIn} from "@repo/authv2.ts";
const routes: RouteDefinition = {
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(tokenStore) != ""}),
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(loggedIn)}),
"/perms": wrap({
asyncComponent: () => import("./pages/Perms.svelte"),
conditions: detail => get(tokenStore) != ""
conditions: detail => get(loggedIn)
}),
"/login": wrap({
asyncComponent: () => import("./pages/Login.svelte"),
conditions: detail => get(tokenStore) == ""
conditions: detail => !get(loggedIn)
}),
"/event/:id": wrap({
asyncComponent: () => import("./pages/Event.svelte"),
conditions: detail => get(tokenStore) != ""
conditions: detail => get(loggedIn)
}),
"/event/:id/generate": wrap({
asyncComponent: () => import("./pages/Generate.svelte"),
conditions: detail => get(tokenStore) != ""
conditions: detail => get(loggedIn)
}),
"/edit": wrap({
asyncComponent: () => import("./pages/Edit.svelte"),
conditions: detail => get(tokenStore) != ""
conditions: detail => get(loggedIn)
}),
"/display/:event": wrap({
asyncComponent: () => import("./pages/Display.svelte"),
conditions: detail => get(tokenStore) != ""
conditions: detail => get(loggedIn)
}),
"*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")})
};
function conditionsFailed(event: ConditionsFailedEvent) {
console.log(event)
if (event.detail.location === "/login") {
replace("/");
} else {

View File

@ -38,6 +38,7 @@
</NavBrand>
<NavHamburger onclick={toggle}/>
<NavUl {hidden}>
<NavLi href="/admin/new">New UI</NavLi>
<NavLi href="#/edit">Edit Pages</NavLi>
<NavLi href="#/perms">Permissions</NavLi>
</NavUl>

View File

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

View File

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

View File

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

View File

@ -43,7 +43,5 @@
maximumFractionDigits: 2
}).format(data.playtime)})}h</p>
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
{#if user.perms.includes("CHECK")}
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
{/if}
{/await}

View File

@ -21,7 +21,7 @@
import {createEventDispatcher} from "svelte";
import {schemRepo} from "@repo/schem.ts";
import SWModal from "@components/styled/SWModal.svelte";
import {t} from "astro-i18n"
import {t} from "astro-i18n";
const dispatch = createEventDispatcher();
@ -29,11 +29,13 @@
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
let {open = $bindable(false)}: Props = $props();
async function upload() {
async function upload(e: Event) {
e.stopPropagation();
if (uploadFile == null) {
return
error = "dashboard.schematic.errors.noFile";
return;
}
let file = uploadFile[0];
@ -42,33 +44,46 @@
let type = name.split(".").pop();
if (type !== "schem" && type !== "schematic") {
return
error = "dashboard.schematic.errors.invalidEnding";
return;
}
let content = await file.arrayBuffer();
// @ts-ignore
let b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(content)));
let response = await $schemRepo.uploadSchematic(name, b64);
try {
await $schemRepo.uploadSchematic(name, b64);
open = false;
value = "";
dispatch("reset")
dispatch("reset");
} catch (e) {
error = "dashboard.schematic.errors.upload";
}
}
function reset(e: Event) {
e.stopPropagation();
open = false
value = "";
}
let uploadFile: FileList | null = $state(null);
let value = $state("");
let error = $state(null)
</script>
<SWModal title={t("dashboard.schematic.title")} bind:open>
<form>
<input type="file" bind:files={uploadFile} bind:value />
<label for="schem-upload">{t("dashboard.schematic.title")}</label>
<input type="file" id="schem-upload" bind:files={uploadFile} class="overflow-ellipsis" bind:value accept=".schem, .schematic"/>
{#if error !== null}
<p class="text-red-400">{t(error)}</p>
{/if}
</form>
{#snippet footer()}
<button class="btn !ml-auto" onclick={upload}>{t("dashboard.schematic.upload")}</button>
<button class="btn btn-gray" onclick={() => open = false}>{t("dashboard.schematic.cancel")}</button>
<button class="btn" onclick={upload}>{t("dashboard.schematic.upload")}</button>
<button class="btn btn-gray" onclick={reset}>{t("dashboard.schematic.cancel")}</button>
{/snippet}
</SWModal>

View File

@ -18,13 +18,13 @@
-->
<script lang="ts">
import {t} from "astro-i18n";
import type {Player} from "@type/data.ts";
import {l} from "@utils/util.ts";
import { t } from "astro-i18n";
import type { Player } from "@type/data.ts";
import { l } from "@utils/util.ts";
import Statistics from "./Statistics.svelte";
import {authRepo} from "@repo/auth.ts";
import {tokenStore} from "@repo/repo.ts";
import { authV2Repo } from "@repo/authv2.ts";
import Card from "@components/Card.svelte";
import { navigate } from "astro:transitions/client";
interface Props {
user: Player;
@ -33,9 +33,8 @@
let { user }: Props = $props();
async function logout() {
await $authRepo.logout()
tokenStore.set("")
window.location.href = l("/login")
await $authV2Repo.logout();
await navigate(l("/login"));
}
</script>
@ -44,19 +43,25 @@
<Card>
<figure>
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
<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" />
<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"
/>
</figure>
</Card>
<div class="flex flex-wrap">
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
{#if user.perms.includes("MODERATION")}
<a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a>
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a>
{/if}
</div>
</div>
<div>
<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>
<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>
<Statistics {user} />
</div>
</div>

View File

@ -0,0 +1,55 @@
<!--
- 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 { RouteDefinition } from "svelte-spa-router";
import Router from "svelte-spa-router";
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
import Players from "@components/moderator/pages/players/Players.svelte";
import Events from "@components/moderator/pages/events/Events.svelte";
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.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 Schematics from "@components/moderator/pages/schems/Schematics.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = {
"/": Dashboard,
"/events": Events,
"/players": Players,
"/event/:id": Event,
"/event/:id/generate": Generator,
"/pages": Pages,
"/schems": Schematics,
};
</script>
<div class="flex flex-col bg-background min-w-full min-h-screen">
<div class="border-b">
<div class="flex h-16 items-center px-4">
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
<NavLinks />
</div>
</div>
<Tooltip.Provider>
<Router {routes} />
</Tooltip.Provider>
</div>

View File

@ -0,0 +1,42 @@
<!--
- 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 {ShortEvent} from "@type/event.ts";
import {Card, CardContent, CardHeader, CardTitle} from "@components/ui/card";
let { event }: { event: ShortEvent } = $props();
let sameDate = $derived(new Intl.DateTimeFormat().format(event.start) === new Intl.DateTimeFormat().format(event.end));
</script>
<Card>
<CardHeader>
<CardTitle>{event.name}</CardTitle>
</CardHeader>
<CardContent>
{#if !sameDate}
<p>Startet: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>Endet: {new Intl.DateTimeFormat().format(event.end)}</p>
{:else}
<p>Am: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>&nbsp;</p>
{/if}
</CardContent>
</Card>

View File

@ -0,0 +1,170 @@
<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

@ -0,0 +1,78 @@
<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

@ -0,0 +1,103 @@
<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

@ -0,0 +1,253 @@
<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

@ -0,0 +1,30 @@
<!--
- 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 { location } from "svelte-spa-router";
</script>
<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="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a>
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
</nav>

View File

@ -0,0 +1,22 @@
<!--
- 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/>.
-->
<div class="p-4">
<h1 class="font-bold text-xl">SteamWar Dashboard</h1>
</div>

View File

@ -0,0 +1,51 @@
<!--
- 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 { eventRepo } from "@repo/event.ts";
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 {
params: { id: number };
}
let { params }: Props = $props();
let id = params.id;
let data: EventModel | undefined = $state(undefined);
let loaded = $state(false);
onMount(async () => {
refresh();
});
async function refresh() {
data = new EventModel(await $eventRepo.getEvent(id.toString()));
loaded = true;
}
</script>
{#if loaded}
<EventView bind:event={data!!} {refresh} />
{:else}
<p>Loading...</p>
{/if}

View File

@ -0,0 +1,152 @@
<!--
- 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 { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import type { SWEvent } from "@type/event.ts";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { fromAbsolute } from "@internationalized/date";
import { Button, buttonVariants } from "@components/ui/button";
import { ChevronsUpDown } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { schemTypes } from "@stores/stores.ts";
import Check from "lucide-svelte/icons/check";
import { cn } from "@components/utils.ts";
import { Switch } from "@components/ui/switch";
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();
let rootEvent: SWEvent = $state(event);
let eventName = $state(rootEvent.name);
let eventDeadline = $state(fromAbsolute(rootEvent.deadline, "Europe/Berlin"));
let eventStart = $state(fromAbsolute(rootEvent.start, "Europe/Berlin"));
let eventEnd = $state(fromAbsolute(rootEvent.end, "Europe/Berlin"));
let eventTeamSize = $state(rootEvent.maxTeamMembers);
let eventSchematicType = $state(rootEvent.schemType);
let eventPublicsOnly = $state(rootEvent.publicSchemsOnly);
let dirty = $derived(
eventName !== rootEvent.name ||
eventDeadline.toDate().getTime() !== rootEvent.deadline ||
eventStart.toDate().getTime() !== rootEvent.start ||
eventEnd.toDate().getTime() !== rootEvent.end ||
eventTeamSize !== rootEvent.maxTeamMembers ||
eventSchematicType !== rootEvent.schemType ||
eventPublicsOnly !== rootEvent.publicSchemsOnly
);
async function updateEvent() {
rootEvent = await $eventRepo.updateEvent(event.id.toString(), {
name: eventName,
deadline: eventDeadline.toDate().getTime(),
start: eventStart.toDate().getTime(),
end: eventEnd.toDate().getTime(),
maxTeamMembers: eventTeamSize,
schemType: eventSchematicType,
publicSchemsOnly: eventPublicsOnly,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="event-name">Name</Label>
<Input id="event-name" bind:value={eventName} />
<Label>Deadline</Label>
<DateTimePicker bind:value={eventDeadline} />
<Label>Start</Label>
<DateTimePicker bind:value={eventStart} />
<Label>End</Label>
<DateTimePicker bind:value={eventEnd} />
<Label for="event-size">Teamsize</Label>
<Input id="event-size" bind:value={eventTeamSize} type="number" />
<Label>Schematic Type</Label>
<Popover>
<PopoverTrigger>
{#snippet child({ props })}
<Button 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" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search schematic types..." />
<CommandList>
<CommandEmpty>No schematic type found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"null"}
onSelect={() => {
eventSchematicType = null;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
Keinen
</CommandItem>
{#each $schemTypes as type}
<CommandItem
value={type.db}
onSelect={() => {
eventSchematicType = type.db;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== type.db && "text-transparent")} />
{type.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="event-publics">Publics Schematics Only</Label>
<Switch id="event-publics" bind:checked={eventPublicsOnly} />
<div class="flex flex-row justify-end border-t pt-2 gap-4">
<AlertDialog>
<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>
</div>
</div>

View File

@ -0,0 +1,315 @@
<!--
- 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 FightEditRow from "./FightEditRow.svelte";
import type { EventFightEdit } from "@type/event";
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 columnFilters = $state<ColumnFiltersState>([]);
let selection = $state<RowSelectionState>({});
const table = createSvelteTable({
get data() {
return data.fights;
},
initialState: {
columnOrder: ["auswahl", "begegnung", "group"],
},
state: {
get sorting() {
return sorting;
},
get columnFilters() {
return columnFilters;
},
get grouping() {
return ["group"];
},
get rowSelection() {
return selection;
},
},
onSortingChange: (updater) => {
if (typeof updater === "function") {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
onRowSelectionChange: (updater) => {
if (typeof updater === "function") {
selection = updater(selection);
} else {
selection = updater;
}
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
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>
<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>
</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>
<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}
<TableHead></TableHead>
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)}
{#if groupRow.getIsGrouped()}
{@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))}
<TableRow class="font-bold">
<TableCell colspan={columns.length - 1}>
<Checkbox
checked={groupRow.getIsSelected()}
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
onCheckedChange={() => groupRow.toggleSelected()}
class="mr-4"
/>
{group?.name ?? "Keine Gruppe"}
</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}
{:else}
<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}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No results.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>

View File

@ -0,0 +1,46 @@
<!--
- 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 EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
import EventFightList from "@components/moderator/pages/event/EventFightList.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();
</script>
<div class="flex flex-col m-4 p-4 rounded-md border gap-4">
<div class="flex flex-col md:flex-row">
<div class="md:w-1/3">
<h1 class="text-2xl font-bold mb-4">{event.event.name}</h1>
<EventEdit event={event.event} />
</div>
<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>
<TeamTable bind:event />
</div>
<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>
<RefereesList {event} />
</div>
</div>
<EventFightList bind:data={event} {refresh} />
</div>

View File

@ -0,0 +1,84 @@
<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;
}
</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 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

@ -0,0 +1,45 @@
<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

@ -0,0 +1,48 @@
<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

@ -0,0 +1,86 @@
<!--
- 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 { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { 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 { players } from "@stores/stores";
const { event }: { event: ExtendedEvent } = $props();
let referees = $state(event.referees);
async function addReferee(value: string) {
await $eventRepo.updateReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
async function removeReferee(value: string) {
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
let playerSearch = $state("");
</script>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each referees as referee (referee.uuid)}
<TableRow>
<TableCell>{referee.name}</TableCell>
<TableCell>
<Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Hinzufügen</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={playerSearch} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No Players found :(</CommandEmpty>
<CommandGroup heading="Players">
{#each $players
.filter((v) => v.name.toLowerCase().includes(playerSearch.toLowerCase()))
.filter((v, i) => i < 50)
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table>

View File

@ -0,0 +1,91 @@
<!--
- 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

@ -0,0 +1,104 @@
/*
* 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/>.
*/
import { Checkbox } from "@components/ui/checkbox";
import { renderComponent } from "@components/ui/data-table";
import type { ColumnDef } from "@tanstack/table-core";
import type { EventFightModel } from "./eventmodel.svelte";
export const columns: ColumnDef<EventFightModel>[] = [
{
id: "auswahl",
header: ({ table }) => {
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,
id: "begegnung",
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

@ -0,0 +1,68 @@
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

@ -0,0 +1,173 @@
<!--
- 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 { eventRepo } from "@repo/event.ts";
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 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>
<div class="p-4 min-h-screen">
<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}
<p>Loading...</p>
{:then events}
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Upcoming</h1>
<div class="grid gap-4 p-4 border-b" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each events.filter((e) => e.start > millis) as event (event.id)}
<a href="#/event/{event.id}">
<EventCard {event} />
</a>
{/each}
</div>
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Past</h1>
<div class="grid gap-4 p-4" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each events.filter((e) => e.start < millis).reverse() as event (event.id)}
<a href="#/event/{event.id}">
<EventCard {event} />
</a>
{/each}
</div>
{/await}
</div>

View File

@ -0,0 +1,22 @@
<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";
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>
</TabsList>
<TabsContent value="group">
<GroupPhaseGenerator {data} />
</TabsContent>
</Tabs>
</div>

View File

@ -0,0 +1,22 @@
<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

@ -0,0 +1,306 @@
<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

@ -0,0 +1,116 @@
<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

@ -0,0 +1,122 @@
<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

@ -0,0 +1,155 @@
<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

@ -0,0 +1,116 @@
<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

@ -0,0 +1,226 @@
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

@ -0,0 +1,56 @@
<!--
- 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 {permissions, players} from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts";
const {
perms, uuid
}: { perms: string[], uuid: string } = $props();
let value = $state(perms);
let prevValue = $state(perms);
function onChange(change: string[]) {
$permissions.perms.forEach(perm => {
if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm)
} else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm)
}
});
prevValue = change;
value = change;
}
</script>
<Select type="multiple" bind:value onValueChange={onChange}>
<SelectTrigger>
{value.length} Permissions
</SelectTrigger>
<SelectContent>
{#each $permissions.perms as permission (permission)}
<SelectItem value={permission}>{permission}</SelectItem>
{/each}
</SelectContent>
</Select>

View File

@ -0,0 +1,32 @@
<!--
- 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>
import Table from "@components/moderator/pages/players/Table.svelte";
import {dataRepo} from "@repo/data";
let playersFuture = $state($dataRepo.getPlayers())
</script>
{#await playersFuture}
<p>Loading...</p>
{:then players}
<Table data={players} />
{/await}

View File

@ -0,0 +1,47 @@
<!--
- 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 {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
import {permissions} from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts";
const {
prefix, uuid
}: { prefix: string, uuid: string } = $props();
let value = $state(prefix);
function onChange(change: string) {
$permsRepo.setPrefix(uuid, change);
value = $permissions.prefixes[change].chatPrefix;
}
</script>
<Select type="single" bind:value onValueChange={onChange}>
<SelectTrigger>
{value === "" ? "None" : value}
</SelectTrigger>
<SelectContent>
{#each Object.entries($permissions.prefixes) as prefix (prefix[1].name)}
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
{/each}
</SelectContent>
</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

@ -0,0 +1,60 @@
/*
* 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/>.
*/
import type {ColumnDef} from "@tanstack/table-core";
import type {Player} from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
export const columns: ColumnDef<Player[]> = [
{
accessorKey: "uuid",
header: "UUID",
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "prefix",
header: "Prefix",
cell: ({ row }) => {
return renderComponent(
PrefixDropdown, {
prefix: row.getValue("prefix"),
uuid: row.getValue("uuid"),
},
);
},
},
{
accessorKey: "perms",
header: "Permissions",
cell: ({ row }) => {
return renderComponent(
PermissionsDropdown,
{
perms: row.getValue("perms"),
uuid: row.getValue("uuid"),
},
);
},
},
];

View File

@ -1,44 +0,0 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2023 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/>.
*/
import {fetchWithToken, tokenStore} from "./repo.ts";
import {derived} from "svelte/store";
export class AuthRepo {
constructor(private token: string) {
}
public async login(username: string, password: string): Promise<string> {
return await fetchWithToken(this.token, "/auth/login", {
body: JSON.stringify({
username,
password,
}),
method: "POST",
}).then(value => value.json()).then(value => value.token);
}
public async logout(): Promise<void> {
await fetchWithToken(this.token, "/auth/tokens/logout", {
method: "POST",
});
}
}
export const authRepo = derived(tokenStore, ($token) => new AuthRepo($token));

View File

@ -0,0 +1,184 @@
/*
* 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/>.
*/
import {readable, writable} from "svelte/store";
import dayjs, {type Dayjs} from "dayjs";
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
export class AuthV2Repo {
private accessToken: string | undefined;
private accessTokenExpires: Dayjs | undefined;
private refreshToken: string | undefined;
private refreshTokenExpires: Dayjs | undefined;
constructor() {
if (typeof localStorage === "undefined") {
return;
}
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) {
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
throw new Error("Already logged in");
}
try {
const login = await this.request("/auth", {
method: "POST",
body: JSON.stringify({
name,
password,
keepLoggedIn: true,
}),
}).then(value => value.json()).then(value => AuthTokenSchema.parse(value));
this.setLoginState(login);
return true;
} catch (e) {
return false;
}
}
async logout() {
if (this.accessToken === undefined) {
return;
}
await this.request("/auth", {
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);
}
private async refresh() {
if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
this.resetRefreshToken();
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: {
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
"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 authV2Repo = readable(new AuthV2Repo());

View File

@ -17,21 +17,37 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Player, Server} from "@type/data.ts";
import {PlayerSchema, ServerSchema} from "@type/data.ts";
import {fetchWithToken, tokenStore} from "./repo.ts";
import {derived} from "svelte/store";
import type { Player, Server } from "@type/data.ts";
import { PlayerSchema, ServerSchema } from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts";
import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts";
export class DataRepo {
constructor(private token: string) {
}
constructor(private token: string) {}
public async getServer(): Promise<Server> {
return await fetchWithToken(this.token, "/data/server").then(value => value.json()).then(ServerSchema.parse);
return await fetchWithToken(this.token, "/data/server")
.then((value) => value.json())
.then(ServerSchema.parse);
}
public async getMe(): Promise<Player> {
return await fetchWithToken(this.token, "/data/me").then(value => value.json()).then(PlayerSchema.parse);
return await fetchWithToken(this.token, "/data/me")
.then((value) => value.json())
.then(PlayerSchema.parse);
}
public async getPlayers(): Promise<Player[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/users")
.then((value) => value.json())
.then(PlayerSchema.array().parse);
}
public async getTeams(): Promise<Team[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/teams")
.then((value) => value.json())
.then(TeamSchema.array().parse);
}
}

View File

@ -17,12 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
import {fetchWithToken, tokenStore} from "./repo";
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
import {z} from "zod";
import type {Dayjs} from "dayjs";
import {derived} from "svelte/store";
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
import { fetchWithToken, tokenStore } from "./repo";
import {
ExtendedEventSchema,
ShortEventSchema,
SWEventSchema,
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 {
name: string;
@ -31,30 +45,36 @@ export interface CreateEvent {
}
export interface UpdateEvent {
name: string | null;
start: Dayjs | null;
end: Dayjs | null;
deadline: Dayjs | null;
maxTeamMembers: number | null;
schemType: string | null;
publicSchemsOnly: boolean | null;
addReferee: string[] | null;
removeReferee: string[] | null;
name?: string | null;
start?: Dayjs | number | null;
end?: Dayjs | number | null;
deadline?: Dayjs | number | null;
maxTeamMembers?: number | null;
schemType?: string | null;
publicSchemsOnly?: boolean | null;
addReferee?: string[] | null;
removeReferee?: string[] | null;
}
export interface ResponseUser {
name: string;
uuid: string;
prefix: string;
perms: string[];
}
export class EventRepo {
constructor(private token: string) {
}
constructor(private token: string) {}
public async listEvents(): Promise<ShortEvent[]> {
return await fetchWithToken(this.token, "/events")
.then(value => value.json())
.then(value => z.array(ShortEventSchema).parse(value));
.then((value) => value.json())
.then((value) => z.array(ShortEventSchema).parse(value));
}
public async getEvent(id: string): Promise<ExtendedEvent> {
return await fetchWithToken(this.token, `/events/${id}`)
.then(value => value.json())
.then((value) => value.json())
.then(ExtendedEventSchema.parse);
}
@ -66,7 +86,8 @@ export class EventRepo {
start: +event.start,
end: +event.end,
}),
}).then(value => value.json())
})
.then((value) => value.json())
.then(SWEventSchema.parse);
}
@ -87,7 +108,8 @@ export class EventRepo {
headers: {
"Content-Type": "application/json",
},
}).then(value => value.json())
})
.then((value) => value.json())
.then(SWEventSchema.parse);
}
@ -98,6 +120,154 @@ export class EventRepo {
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));

View File

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

View File

@ -17,27 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Page, PageList} from "@type/page.ts";
import {fetchWithToken, tokenStore} from "./repo.ts";
import {PageListSchema, PageSchema} from "@type/page.ts";
import {bytesToBase64} from "../admin/util.ts";
import {z} from "zod";
import {derived} from "svelte/store";
import type { Page, PageList } from "@type/page.ts";
import { fetchWithToken, tokenStore } from "./repo.ts";
import { PageListSchema, PageSchema } from "@type/page.ts";
import { bytesToBase64 } from "../admin/util.ts";
import { z } from "zod";
import { derived } from "svelte/store";
export class PageRepo {
constructor(private token: string) {
}
constructor(private token: string) {}
public async listPages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page?branch=${branch}`)
.then(value => value.json())
.then((value) => value.json())
.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> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then(value => value.json())
.then((value) => value.json())
.then(PageSchema.parse);
}
@ -46,40 +45,55 @@ export class PageRepo {
method: "PUT",
body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)),
sha, message,
sha,
message,
}),
});
}
public async getBranches(): Promise<string[]> {
return await fetchWithToken(this.token, "/page/branch")
.then(value => value.json())
.then(value => z.array(z.string()).parse(value));
.then((value) => value.json())
.then((value) => z.array(z.string()).parse(value));
}
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> {
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> {
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> {
await fetchWithToken(this.token, "/page/branch/merge", {
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> {
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
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 }),
});
}
}

View File

@ -17,31 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {writable} from "svelte/store";
import {get, writable} from "svelte/store";
import {authV2Repo} from "@repo/authv2.ts";
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) =>
fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
headers: {
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
"Content-Type": "application/json", ...params.headers,
},
})
.then(value => {
if (value.status === 401) {
tokenStore.set("");
}
return value;
});
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params);
export function getLocalStorage() {
if (typeof localStorage === "undefined") {
return {
getItem: () => "",
setItem: () => {},
};
}
return localStorage;
}
export const tokenStore = writable((getLocalStorage().getItem("sw-session") ?? ""));
tokenStore.subscribe((value) => getLocalStorage().setItem("sw-session", value));
export const tokenStore = writable("");

View File

@ -36,7 +36,7 @@ export class StatsRepo {
}
public async getUserStats(id: string): Promise<UserStats> {
return await fetchWithToken(this.token, `/stats/user/${id}`).then(value => value.json()).then(UserStatsSchema.parse);
return await fetchWithToken(this.token, `/stats/user`).then(value => value.json()).then(UserStatsSchema.parse);
}
}

View File

@ -17,20 +17,31 @@
* 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> {
reload: () => void;
future: Promise<T>;
}
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
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;
const reload = () => {
init().then(data => {
init().then((data) => {
store.set(data);
});
};
@ -45,6 +56,7 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
return store.subscribe(run, invalidate);
},
reload,
future,
};
}
@ -58,7 +70,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
let first = true;
const reload = () => {
init(arg).then(data => {
init(arg).then((data) => {
store.set(data);
});
};

View File

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

View File

@ -19,7 +19,7 @@
<script lang="ts">
import {onMount} from "svelte";
import {stopPropagation} from "@components/util.ts";
import {stopPropagation} from "@components/utils.ts";
interface Props {
title: string;
@ -68,16 +68,18 @@
})
</script>
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full max-w-md w-full rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
<div onclick={stopPropagation(onclick)} aria-hidden="true">
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full min-w-md w-fit rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
<div onclick={stopPropagation(onclick)} aria-hidden="true" class="w-fit">
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
<h1 class="text-4xl font-bold">{title}</h1>
</div>
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
{@render children?.()}
</div>
<div class="flex mx-4 my-2 p-6" onclick={() => dialog.close()} aria-hidden="true">
<div class="mx-4 my-2 p-6">
<div class="ml-auto flex justify-end" onclick={() => dialog.close()} aria-hidden="true">
{@render footer?.()}
</div>
</div>
</div>
</dialog>

View File

@ -1,7 +1,7 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2023 SteamWar.de-Serverteam
* 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
@ -17,17 +17,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function window<T>(arr: T[], len: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += len) {
result.push(arr.slice(i, i + len));
}
return result;
}
import {z} from "zod";
export function stopPropagation(a: any) {
return (e: Event) => {
e.stopPropagation();
a(e);
};
}
export const TokenSchema = z.object({
token: z.string(),
expires: z.string(),
});
export type Token = z.infer<typeof TokenSchema>;
export const AuthTokenSchema = z.object({
accessToken: TokenSchema,
refreshToken: TokenSchema,
});
export type AuthToken = z.infer<typeof AuthTokenSchema>;

View File

@ -17,7 +17,7 @@
* 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({
name: z.string(),
@ -57,3 +57,12 @@ export const ResponseErrorSchema = z.object({
});
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,9 +17,58 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {z} from "zod";
import {TeamSchema} from "./team.js";
import {PlayerSchema} from "./data.js";
import { z } from "zod";
import { TeamSchema } from "./team.js";
import { PlayerSchema, ResponseUserSchema } 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({
id: z.number(),
@ -35,29 +84,69 @@ export const SWEventSchema = ShortEventSchema.extend({
maxTeamMembers: z.number(),
schemType: z.string().nullable(),
publicSchemsOnly: z.boolean(),
referees: z.array(PlayerSchema),
});
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({
event: SWEventSchema,
teams: z.array(TeamSchema),
groups: z.array(ResponseGroupsSchema),
fights: z.array(EventFightSchema),
referees: z.array(ResponseUserSchema),
relations: z.array(ResponseRelationSchema),
});
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/>.
*/
import {z} from "zod";
import { z } from "zod";
export const PrefixSchema = z.object({
name: z.string().startsWith("PREFIX_"),
colorCode: z.string().length(2).startsWith("§"),
colorCode: z.string().startsWith("§"),
chatPrefix: z.string(),
});

View File

@ -17,12 +17,12 @@
* 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({
id: z.number(),
name: z.string(),
kuerzel: z.string().min(1).max(4),
kuerzel: z.string().min(1).max(16),
color: z.string().max(1),
});

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { slide } from "svelte/transition";
import { cn } from "$lib/components/utils.js";
type $$Props = AccordionPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = slide;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 200,
};
export { className as class };
</script>
<AccordionPrimitive.Content
class={cn("overflow-hidden text-sm transition-all", className)}
{transition}
{transitionConfig}
{...$$restProps}
>
<div class="pb-4 pt-0">
<slot />
</div>
</AccordionPrimitive.Content>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
type $$Props = AccordionPrimitive.ItemProps;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<AccordionPrimitive.Item {value} class={cn("border-b", className)} {...$$restProps}>
<slot />
</AccordionPrimitive.Item>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/components/utils.js";
type $$Props = AccordionPrimitive.TriggerProps;
type $$Events = AccordionPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
export { className as class };
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...$$restProps}
on:click
>
<slot />
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@ -0,0 +1,17 @@
import { Accordion as AccordionPrimitive } from "bits-ui";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
const Root = AccordionPrimitive.Root;
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
};

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...restProps}
/>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: AlertDialogPrimitive.PortalProps;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
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",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

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

View File

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

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
class={cn(
"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",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,9 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
type $$Props = AlertDialogPrimitive.PortalProps;
</script>
<AlertDialogPrimitive.Portal {...$$restProps}>
<slot />
</AlertDialogPrimitive.Portal>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
class={cn("text-lg font-semibold", className)}
{level}
{...restProps}
/>

View File

@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};

View File

@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/components/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
<slot />
</div>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/components/utils.js";
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h5";
export { className as class };
</script>
<svelte:element
this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</svelte:element>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type Variant, alertVariants } from "./index.js";
import { cn } from "$lib/components/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant;
};
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export { className as class };
</script>
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
<slot />
</div>

View File

@ -0,0 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants";
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export const alertVariants = tv({
base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4",
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
export type Variant = VariantProps<typeof alertVariants>["variant"];
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { AspectRatio as AspectRatioPrimitive } from "bits-ui";
type $$Props = AspectRatioPrimitive.Props;
export let ratio: $$Props["ratio"] = 4 / 3;
</script>
<AspectRatioPrimitive.Root {ratio} {...$$restProps}>
<slot />
</AspectRatioPrimitive.Root>

View File

@ -0,0 +1,3 @@
import Root from "./aspect-ratio.svelte";
export { Root, Root as AspectRatio };

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
type $$Props = AvatarPrimitive.FallbackProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Fallback
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
{...$$restProps}
>
<slot />
</AvatarPrimitive.Fallback>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
type $$Props = AvatarPrimitive.ImageProps;
let className: $$Props["class"] = undefined;
export let src: $$Props["src"] = undefined;
export let alt: $$Props["alt"] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Image
{src}
{alt}
class={cn("aspect-square h-full w-full", className)}
{...$$restProps}
/>

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