216 Commits

Author SHA1 Message Date
Chaoscaot 9717946784 Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
SteamWar CI / Build (push) Failing after 1m6s
SteamWar CI / Deploy (push) Has been skipped
2026-05-15 13:27:07 +02:00
Chaoscaot 46ad3599e8 Add Gitea CI workflow for build and deploy
- Move CI config into `.gitea/workflows/steamwarci.yml`
- Build with pnpm, sync i18n, and upload `dist/`
- Deploy master pushes to `/var/www/html` over SCP
2026-05-15 13:26:54 +02:00
Chaoscaot e27269ec8b Updated 2026-wgs.md
SteamWarCI Build successful
2026-05-09 14:31:00 +02:00
JajaKings 388492cd21 Updated 2026-wgs.md
SteamWarCI Build successful
2026-05-07 16:32:23 +02:00
Chaoscaot b98f197d14 Make group round grouping configurable
SteamWarCI Build successful
- Add round grouping time and prefix to group view config
- Use the new settings in group display and editor defaults
2026-05-07 16:28:51 +02:00
JajaKings fc53376c73 Updated 2026-wgs.md
SteamWarCI Build successful
2026-05-07 16:17:45 +02:00
JajaKings ae5d232c54 Updated 2026-wgs.md
SteamWarCI Build successful
2026-05-07 16:11:27 +02:00
Chaoscaot 08e2e37737 Merge pull request 'Add schema-driven frontmatter editor' (#24) from feature/frontmatter-schema-editor into master
SteamWarCI Build successful
Reviewed-on: #24
Reviewed-by: D4rkr34lm <dark@steamwar.de>
2026-05-07 16:05:09 +02:00
Chaoscaot 3b7aafd56e Add schema-driven frontmatter editor
SteamWarCI Build successful
- Render frontmatter fields from content schemas
- Add specialized selectors for events, images, and view config
- Refresh the pages editor layout and tab bar
2026-05-07 15:56:58 +02:00
D4rkr34lm a16d663fa3 Updated 2026-wgs.md
SteamWarCI Build successful
2026-05-06 22:00:19 +02:00
Chaoscaot 6ef46cb911 Add view configuration for Gruppenphase in 2026 WarGear event
SteamWarCI Build successful
2026-05-02 11:54:58 +02:00
Chaoscaot 470d715a7a Add view configuration for Gruppenphase and Finale in OsterEvent
SteamWarCI Build successful
2026-04-25 01:21:58 +02:00
Chaoscaot 3948009c35 Remove teamserver.json configuration file
SteamWarCI Build successful
2026-04-23 23:51:03 +02:00
Chaoscaot a410830a93 Refactor z-index and background styles for improved layout consistency
SteamWarCI Build successful
2026-04-23 23:48:59 +02:00
Chaoscaot f1d7b60fae Fix z-Index
SteamWarCI Build successful
2026-04-23 23:43:54 +02:00
Chaoscaot 01077da029 Increase z-index for dropdown button to improve visibility
SteamWarCI Build successful
2026-04-23 23:42:08 +02:00
Chaoscaot a195b074a7 Preserve Pathname on Lang Change
SteamWarCI Build successful
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 15:07:04 +02:00
Chaoscaot a8817115a3 Fix Safari
SteamWarCI Build successful
2026-04-23 14:59:56 +02:00
Chaoscaot 5639655f99 Fix Lang Switcher
SteamWarCI Build successful
2026-04-23 14:10:54 +02:00
Chaoscaot ba4aa67ff1 Update language selection links in Navbar to use data-astro-reload attribute
SteamWarCI Build failed
2026-04-23 14:08:54 +02:00
Chaoscaot 6fde748088 Merge pull request 'page-redesign' (#22) from page-redesign into master
SteamWarCI Build successful
Reviewed-on: #22
Reviewed-by: YoyoNow <yoyonow@noreply.localhost>
2026-04-23 12:23:26 +02:00
Chaoscaot 01da718802 Merge branch 'master' into page-redesign
SteamWarCI Build successful
2026-04-23 12:23:15 +02:00
Chaoscaot 23566e72d3 Refactor Navbar component to replace search button with language selection dropdown
SteamWarCI Build successful
2026-04-23 12:22:42 +02:00
Chaoscaot 9d06dc7d0b Merge branch 'master' into page-redesign
SteamWarCI Build successful
2026-04-23 12:11:04 +02:00
Chaoscaot c41b1f9daa Refactor i18n configuration and utility functions for improved clarity and simplicity
SteamWarCI Build successful
2026-04-23 11:53:56 +02:00
YoyoNow 6daee3a58b Fix 2026-wgs.md
SteamWarCI Build successful
2026-04-23 11:09:42 +02:00
YoyoNow 4d361415ed Fix 2026-wgs.md
SteamWarCI Build successful
2026-04-23 11:06:29 +02:00
YoyoNow 78259e7263 Fix 2026-wgs.md
SteamWarCI Build successful
2026-04-23 10:52:10 +02:00
YoyoNow 76d9ee9810 Fix 2026-wgs.md
SteamWarCI Build successful
2026-04-23 10:14:24 +02:00
YoyoNow 3b1a491bc2 Fix events
SteamWarCI Build successful
2026-04-23 08:35:12 +02:00
YoyoNow b357c89dac Remove duplicated title
SteamWarCI Build successful
2026-04-22 17:51:51 +02:00
YoyoNow f8399e4f31 Fix grammar
SteamWarCI Build successful
2026-04-22 09:51:57 +02:00
YoyoNow dea500e70d Add timeline.md
SteamWarCI Build successful
2026-04-22 09:07:47 +02:00
Chaoscaot 2204bb8663 fixes
SteamWarCI Build failed
2026-04-21 21:52:02 +02:00
Chaoscaot 0f20cc0485 Fix link case sensitivity in 2026 Oster-Event announcement
SteamWarCI Build successful
2026-04-21 21:36:36 +02:00
Chaoscaot e44a3f81e4 Add event announcements for Oster-Event 2026 and WarGear Season 2026
SteamWarCI Build successful
2026-04-21 21:34:31 +02:00
Chaoscaot 43f42c03c0 Add event announcements for Oster-Event 2026 and WarGear Season 2026 2026-04-21 21:33:31 +02:00
DreamJxnas 931d59565d Übersetzung Brücke und Auflösen von Doppeldeutigkeit in der Werfer-Ansteuerung
SteamWarCI Build successful
2026-04-19 16:32:29 +02:00
Chaoscaot 1426536c88 Enhance layout and styling for improved UI consistency across components 2026-03-28 21:53:31 +01:00
Chaoscaot 5d365bc744 Refactor components and pages for improved readability and consistency
- Updated BackgroundImage.astro to format props for better readability.
- Adjusted FightTable.svelte to remove unnecessary trailing commas.
- Modified GroupTable.svelte to fix sorting syntax.
- Cleaned up LanguageWarning.astro by standardizing import statements.
- Enhanced Login.svelte for better formatting and readability.
- Simplified Navbar.svelte by merging multi-line attributes into single lines.
- Streamlined PostComponent.astro by condensing Image component props.
- Improved SearchComponent.svelte for consistent spacing and formatting.
- Refined TagComponent.astro for better readability and structure.
- Updated PageLayout.astro to simplify div structure.
- Enhanced downloads.astro for improved readability and consistency.
- Cleaned up index.astro in help directory for better formatting.
- Refactored index.astro in main pages for improved readability.
- Standardized login.astro for better formatting.
- Cleaned up not-found.astro for consistent import formatting.
- Enhanced [...schem].astro for better readability.
- Refactored [mode].astro for consistent import formatting.
- Improved [...gamemode].astro for better readability.
- Cleaned up [mode].astro in regeln directory for consistent formatting.
- Refactored index.astro in regeln directory for improved readability.
- Enhanced fight.astro for consistent import formatting.
2026-03-28 15:56:56 +01:00
Chaoscaot d2ee422d6d Refactor styles and components for improved UI consistency
- Updated login page layout by removing unnecessary classes.
- Redesigned 404 not found page with new layout and styles.
- Enhanced public pages with consistent font styling and hover effects.
- Improved ranking page header styling for better readability.
- Updated rules page with consistent font and link styles.
- Enhanced statistics page header styling.
- Refined global CSS variables for better theme consistency.
- Updated button styles for improved interaction feedback.
- Enhanced table styles for better readability and interaction.
- Updated Tailwind configuration to include new font families.
2026-03-28 15:56:32 +01:00
YoyoNow f612b15b06 Add en locale
SteamWarCI Build successful
2026-03-10 08:49:22 +01:00
YoyoNow 587f0f5405 Add 2026-OsterEvent.md
SteamWarCI Build successful
2026-03-10 08:46:49 +01:00
JajaKings f7378dfe1f Updated warship.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
YoyoNow a1617d8e80 Fix 2026-wgs.md
SteamWarCI Build successful
2026-02-18 18:06:15 +01:00
YoyoNow c546aec449 Fix 2026-wgs.md
SteamWarCI Build successful
2026-02-18 16:55:39 +01:00
YoyoNow ed2e47d987 Add 2026-wgs.md
SteamWarCI Build successful
2026-02-18 16:54:17 +01:00
YoyoNow 6a6a5801ae Fix warship.md
SteamWarCI Build successful
2026-02-16 14:30:05 +01:00
JajaKings c27ba9fae3 Updated warship.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
Chaoscaot 55f98be765 src/content/modes/warship.json aktualisiert
SteamWarCI Build successful
2026-01-22 18:37:31 +01:00
JajaKings 092f318274 Updated warship.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
JajaKings 47b0bf34a1 Updated warship.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
Chaoscaot 35765b90e6 feat: Refactor connection rendering logic for improved layout and offset handling
SteamWarCI Build successful
2026-01-01 17:18:54 +01:00
Chaoscaot fa7e68ca10 feat: Add roundRows configuration to group view and update event markdowns
SteamWarCI Build successful
2026-01-01 16:23:13 +01:00
Chaoscaot f507dce94a feat: Enhance group display with hover effect for team visibility
SteamWarCI Build successful
2025-12-31 13:25:10 +01:00
Chaoscaot 1ed1901311 feat: Add view configuration for group phase in neujahr2026 event
SteamWarCI Build successful
2025-12-31 09:48:02 +01:00
AdmiralSeekrank 63d03f2226 Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
Chaoscaot e27b4fb0f4 rebuild
SteamWarCI Build successful
2025-12-20 19:05:11 +01:00
Chaoscaot 8fa1d41639 fix: Replace ResponseUserSchema with PlayerSchema in auth and event repositories
SteamWarCI Build failed
2025-12-20 19:03:36 +01:00
Chaoscaot f13305d116 feat: Add Team info Sidebar
SteamWarCI Build failed
2025-12-20 18:36:33 +01:00
YoyoNow ff59ac3747 Update warship.md
SteamWarCI Build successful
2025-12-18 18:30:21 +01:00
Chaoscaot 09035e3acd fix: Update script tags to remove inline type and improve page load handling in dashboard and login pages
SteamWarCI Build successful
2025-12-17 21:36:55 +01:00
Chaoscaot 9be8702e6a fix: Change script tag to type="module" in dashboard and login pages
SteamWarCI Build successful
2025-12-17 21:34:26 +01:00
Chaoscaot ffe875260d fix: Update copyright year in FightStatistics.svelte
SteamWarCI Build successful
2025-12-17 21:29:55 +01:00
Chaoscaot 64b82eddff rebuild
SteamWarCI Build failed
2025-12-17 21:28:52 +01:00
Chaoscaot e3432ce7bd fix: Update copyright year in FightStatistics.svelte
SteamWarCI Build failed
2025-12-17 21:23:28 +01:00
Chaoscaot 6cdf2e0933 fix: Update copyright year in FightStatistics.svelte
SteamWarCI Build failed
2025-12-17 21:22:11 +01:00
Chaoscaot b0a9d56216 refactor: Update loggedIn store type and streamline login/dashboard navigation logic
SteamWarCI Build failed
2025-12-17 21:20:46 +01:00
Chaoscaot 3ffc715929 refactor: Remove unused FightList.svelte component
SteamWarCI Build successful
2025-12-17 21:07:54 +01:00
Chaoscaot 9589a496c0 Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
SteamWarCI Build failed
2025-12-17 21:07:35 +01:00
Chaoscaot bdebe768b2 refactor: Clean up imports and formatting in Event.svelte; remove FightList.svelte 2025-12-17 21:06:22 +01:00
Chaoscaot ab4d4a1a91 Merge pull request 'Add AuditLog' (#20) from auditlog into master
SteamWarCI Build failed
Reviewed-on: #20
2025-12-17 21:03:15 +01:00
Chaoscaot e1220ff5ac Refactor player components: streamline state management and improve code readability in PermissionsDropdown, Players, and PrefixDropdown
SteamWarCI Build failed
2025-12-02 22:35:32 +01:00
Chaoscaot 3bde299ea5 Refactor code for consistency: standardize formatting and improve readability across multiple Svelte components
SteamWarCI Build failed
2025-12-02 22:24:09 +01:00
Chaoscaot 5f5988e270 Refactor player handling: replace player arrays with IDs, implement PlayerSelector component 2025-12-02 22:23:55 +01:00
Chaoscaot 7ec678ae7d Add AuditLog
SteamWarCI Build failed
2025-12-01 18:38:06 +01:00
AdmiralSeekrank 9aa62956a0 Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank 7ea7536367 Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
YoyoNow 2a2ee6701e Add microwargear.md and miniwargear.md translations for English
SteamWarCI Build successful
2025-12-04 09:37:16 +01:00
YoyoNow d1e889e2ff Add quickgear.md and megawargear.md translations for English
SteamWarCI Build successful
2025-12-02 18:04:15 +01:00
AdmiralSeekrank 6beb488b0b Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
Chaoscaot f3b5be675a Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank 385d72b541 Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank 62a2a0fb3b Updated neujahr2026.md
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
Chaoscaot 5500f3b058 feat: Add Neujahr 2026 event details
SteamWarCI Build successful
2025-12-01 11:50:09 +01:00
Chaoscaot b17cdb7d51 Add SFA Event View
SteamWarCI Build successful
2025-11-24 17:05:25 +01:00
Chaoscaot a761ce371c fix: Update TeamList component to use event object instead of eventId
SteamWarCI Build successful
2025-11-23 12:14:35 +01:00
Chaoscaot ba88dd1ec3 feat: Add TeamList component for displaying registered teams in event details
SteamWarCI Build failed
2025-11-23 12:12:26 +01:00
Chaoscaot ddb19a85dc Update some Event View Code
SteamWarCI Build successful
2025-11-22 21:49:51 +01:00
Chaoscaot 64adfe7c3b fix: Update Discord login handling to use async/await for better error handling
SteamWarCI Build successful
2025-11-15 00:06:13 +01:00
Chaoscaot f503d59eeb fix: Set Content-Type header to text/plain for Discord login request
SteamWarCI Build successful
2025-11-14 23:42:10 +01:00
Chaoscaot a06e66012b rebuild
SteamWarCI Build successful
2025-11-14 23:34:09 +01:00
Chaoscaot d746e26a1c rebuild
SteamWarCI Build failed
2025-11-14 23:25:52 +01:00
Chaoscaot a9e1cb6025 fix: Update Discord OAuth link in Login.svelte for correct redirect URI
SteamWarCI Build successful
2025-11-14 23:18:55 +01:00
Chaoscaot 3daac95059 fix: Remove unnecessary blank line in Login.svelte
SteamWarCI Build failed
2025-11-14 23:15:27 +01:00
Chaoscaot 1905aed535 fix: Update copyright year in middleware.ts to 2025
SteamWarCI Build failed
2025-11-14 23:03:31 +01:00
Chaoscaot 9c353a5eea fix: Remove unnecessary blank line in Login.svelte
SteamWarCI Build failed
2025-11-14 22:49:53 +01:00
Chaoscaot 2840fe80ef Merge pull request 'Enhance login functionality with Discord integration' (#19) from authv3 into master
SteamWarCI Build failed
Reviewed-on: #19
Reviewed-by: YoyoNow <yoyonow@noreply.localhost>
2025-11-14 22:43:10 +01:00
Chaoscaot d79c532009 feat: Enhance login functionality with Discord integration and improve code formatting
SteamWarCI Build failed
2025-11-13 14:32:06 +01:00
Chaoscaot b4099c6b88 fix: Correct sorting method for group points in GroupDisplay component
SteamWarCI Build successful
2025-11-10 14:31:14 +01:00
Chaoscaot bf6df41fc2 feat: Add Halloween 2025 event details and structure
SteamWarCI Build failed
2025-11-10 14:25:38 +01:00
Chaoscaot c3bb62f3fb feat: Add event collection and event page structure
SteamWarCI Build successful
- Introduced a new events collection in config.ts with schema validation.
- Created a new event markdown file for the WarGear event.
- Updated German translations to include new event-related strings.
- Modified PageLayout to support a wide layout option.
- Enhanced announcements page to improve tag filtering and post rendering.
- Implemented dynamic event pages with detailed event information and fight plans.
- Added an index page for events to list all upcoming events.
2025-11-10 12:37:32 +01:00
Chaoscaot 446e4bb839 src/content/docs/docs/index.mdx aktualisiert
SteamWarCI Build successful
2025-11-05 00:13:17 +01:00
Chaoscaot 7f41765acb Fix App
SteamWarCI Build successful
2025-11-02 00:27:23 +01:00
Chaoscaot 0d810f9a7e Merge pull request 'Update 2025 Halloween event deadlines' (#18) from update-2025-hallowen-deadlines into master
SteamWarCI Build failed
Reviewed-on: #18
Reviewed-by: Chaoscaot <max@chaoscaot.de>
2025-11-02 00:25:57 +01:00
D4rkr34lm 5d384bc336 Update 2025 Halloween event deadlines
SteamWarCI Build failed
2025-11-01 23:35:38 +01:00
YoyoNow f95cf6cbfa Fix pro-wargear.md
SteamWarCI Build failed
2025-10-30 16:06:13 +01:00
Chaoscaot 972b8da9e6 Enhance EventFight handling by adding conditional relation names and improving group button visibility
SteamWarCI Build failed
2025-10-30 12:06:44 +01:00
YoyoNow cb41356351 Fix date in 2025-halloween.md
SteamWarCI Build successful
2025-10-27 19:32:08 +01:00
YoyoNow 276dc56627 Add 2025-halloween.md
SteamWarCI Build successful
2025-10-27 13:21:07 +01:00
YoyoNow 0edec9cdf0 Add 2025-halloween.md
SteamWarCI Build successful
2025-10-27 11:03:24 +01:00
Chaoscaot 4703fde5a3 src/content/downloads/advancedscripts.json aktualisiert
SteamWarCI Build successful
2025-10-07 23:09:54 +02:00
YoyoNow 954a8cc318 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 18:29:49 +02:00
YoyoNow 1229edbf51 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 18:16:46 +02:00
YoyoNow 00bce50a49 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 18:06:44 +02:00
YoyoNow 5a44f2160c Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 17:55:25 +02:00
YoyoNow 9b65d5d730 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 17:33:42 +02:00
YoyoNow 8397aace8d Update Eventplan MWGL2025 2025-09-28 17:33:36 +02:00
YoyoNow c2b0bcc54e Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 17:21:46 +02:00
YoyoNow 5c48f0cb85 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 17:04:14 +02:00
YoyoNow d30cceaad0 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:52:46 +02:00
YoyoNow 41be843be4 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:47:39 +02:00
YoyoNow 3768788f32 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:34:31 +02:00
YoyoNow 7e6f953e44 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:32:28 +02:00
YoyoNow cad3a795a7 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:20:02 +02:00
YoyoNow 48e8165417 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-28 16:10:34 +02:00
Chaoscaot b11534490d Refactor EventFight handling to include team relation names and update type definitions
SteamWarCI Build successful
2025-09-28 14:11:58 +02:00
Chaoscaot c0f4a852b5 Refactor event handling and introduce TeamSelector component for improved fight management
SteamWarCI Build successful
2025-09-28 10:26:08 +02:00
YoyoNow 54d49cca5b Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:41:56 +02:00
YoyoNow 831ea3af11 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:14:21 +02:00
YoyoNow b6a0692c50 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:13:48 +02:00
YoyoNow 01394953d4 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:12:09 +02:00
YoyoNow c515b19e74 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:09:57 +02:00
YoyoNow 98199cc9a0 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 19:08:31 +02:00
YoyoNow 3f61564067 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:53:59 +02:00
YoyoNow 7b0f18f65d Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:40:17 +02:00
YoyoNow 4ac5d2d2b2 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:37:20 +02:00
YoyoNow 8fd3e04116 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:10:17 +02:00
YoyoNow 3180ad1263 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:09:56 +02:00
YoyoNow f689415b98 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 18:08:59 +02:00
YoyoNow 894d0f8a05 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 17:56:24 +02:00
YoyoNow 16d377e3e4 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 17:46:19 +02:00
YoyoNow 1b2a05c204 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 17:11:35 +02:00
YoyoNow 04969e79c3 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 16:45:08 +02:00
YoyoNow a949237334 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 16:09:41 +02:00
YoyoNow 01a59d6de4 Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 13:48:50 +02:00
YoyoNow 3daeb8b62d Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 12:45:30 +02:00
YoyoNow aa72de70ef Update Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 12:06:12 +02:00
YoyoNow 324025dd57 Add Eventplan MWGL2025
SteamWarCI Build successful
2025-09-27 12:02:51 +02:00
Chaoscaot 41b847b3e4 Refactor colorCode validation in PrefixSchema to allow any length string starting with "§"
SteamWarCI Build successful
2025-09-16 18:07:39 +02:00
Chaoscaot 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.
SteamWarCI Build successful
2025-09-16 18:03:29 +02:00
Chaoscaot 5f12a0cc7a Update kuerzel max length in TeamSchema to 16 characters
SteamWarCI Build successful
2025-08-13 21:06:35 +02:00
Chaoscaot 7166575806 Fix section number for cannon count in WarShip rules
SteamWarCI Build successful
2025-08-13 20:58:50 +02:00
Chaoscaot 0055e9fb9c Update WarShip rules to clarify restrictions on protective materials
SteamWarCI Build successful
2025-08-13 20:55:49 +02:00
Chaoscaot fc5a209638 Refactor WarShip rules for clarity and structure; added section numbers and improved definitions.
SteamWarCI Build successful
2025-08-13 20:54:07 +02:00
Chaoscaot c7cdc19102 Fix typo in WarGear Event announcement text
SteamWarCI Build successful
2025-08-12 20:57:09 +02:00
Chaoscaot c6bbe8c9c8 Add team size information to WarGear Event announcement
SteamWarCI Build successful
2025-08-11 22:46:26 +02:00
Chaoscaot 1cec1b917e Add note about new Schematic type for WarGear Event
SteamWarCI Build successful
2025-08-11 22:45:40 +02:00
Chaoscaot 13805c7f3f Add WarGear Event announcement for November 2025
SteamWarCI Build successful
2025-08-11 22:41:15 +02:00
Chaoscaot da668c574a Updated mwgl.md
SteamWarCI Build successful
2025-07-28 13:42:19 +02:00
Chaoscaot 2aab86573a Add Image generated-image(8).png
SteamWarCI Build successful
2025-07-28 13:41:51 +02:00
Chaoscaot 5d7eb3b8fb Merge pull request 'Merge branch mwgl' (#16) from mwgl into master
SteamWarCI Build successful
Reviewed-on: #16
2025-07-28 13:02:14 +02:00
Chaoscaot 6933af1554 Updated mwgl.md
SteamWarCI Build successful
2025-07-28 13:01:56 +02:00
Chaoscaot e607ea1343 Updated mwgl.md
SteamWarCI Build successful
2025-07-28 13:00:51 +02:00
Chaoscaot b0ae4e978e Create page announcements/de/mwgl.md
SteamWarCI Build successful
2025-07-28 12:57:57 +02:00
Chaoscaot 8fe273f3e0 Add Open-Source section to documentation
SteamWarCI Build successful
2025-07-10 17:57:42 +02:00
Chaoscaot 1b48cbe1f4 Update edit link base URL to point to the master branch
SteamWarCI Build successful
2025-07-10 13:51:09 +02:00
Chaoscaot 7276552ed1 Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
SteamWarCI Build successful
2025-07-10 13:49:15 +02:00
Chaoscaot a2ef92aaad Add Docs 2025-07-10 13:49:00 +02:00
Chaoscaot 8b85cd0729 src/content/modes/spacecraft.json aktualisiert
SteamWarCI Build successful
2025-06-29 22:51:13 +02:00
Chaoscaot 2d024cf64b Create page modes/spacecraft.json
SteamWarCI Build failed
2025-06-29 22:49:58 +02:00
TheBreadBeard 13d76d0a97 Updated SC-Eventplan.md
SteamWarCI Build successful
2025-06-29 20:34:55 +02:00
Chaoscaot e65fadb65c Updated SC-Eventplan.md
SteamWarCI Build successful
2025-06-29 20:32:23 +02:00
TheBreadBeard 6b4693b7f1 Updated SC-Eventplan.md
SteamWarCI Build successful
2025-06-29 20:29:54 +02:00
TheBreadBeard 92282006fe Add Image SpaceCraftWinners3.png
SteamWarCI Build successful
2025-06-29 20:29:16 +02:00
Chaoscaot 5457632598 Fix formatting of teamPoints calculation in GroupTable component for improved readability
SteamWarCI Build successful
2025-06-29 19:47:35 +02:00
Chaoscaot bed134f8e0 Fix group points retrieval in GroupTable component to ensure correct mapping of event groups
SteamWarCI Build successful
2025-06-29 19:45:18 +02:00
Chaoscaot 353a415990 Refactor GroupTable component to use $props for event, group, and rows; simplify teamPoints calculation with derived state
SteamWarCI Build successful
2025-06-29 19:41:43 +02:00
Chaoscaot 3c6d0f8528 Fix
SteamWarCI Build successful
2025-06-29 14:00:09 +02:00
Chaoscaot 887235dc86 Enhance caching mechanism by adding future promise to cached function and updating maps retrieval logic to use it
SteamWarCI Build successful
2025-06-29 13:54:03 +02:00
Chaoscaot a99a066f0d Merge branch 'master' of https://git.steamwar.de/SteamWar/Website
SteamWarCI Build successful
2025-06-29 11:24:07 +02:00
Chaoscaot e5e3c15b07 Add refresh functionality and duplicate fight feature in FightEditRow component 2025-06-29 11:23:51 +02:00
TheBreadBeard fb74689c39 Updated SC-Eventplan.md
SteamWarCI Build successful
2025-06-29 09:15:11 +02:00
Chaoscaot 18b1f97a84 src/content/announcements/de/SC-Eventplan.md aktualisiert
SteamWarCI Build successful
2025-06-29 03:07:37 +02:00
TheBreadBeard 53b81db2c4 Updated SC-Eventplan.md
SteamWarCI Build failed
2025-06-29 02:44:17 +02:00
TheBreadBeard 2314b4c5b5 Updated SC-Eventplan.md
SteamWarCI Build failed
2025-06-28 17:53:43 +02:00
TheBreadBeard 6a81936f77 Updated SC-Eventplan.md
SteamWarCI Build failed
2025-06-28 17:32:32 +02:00
TheBreadBeard a128de3213 Updated SC-Eventplan.md
SteamWarCI Build failed
2025-06-28 17:31:58 +02:00
TheBreadBeard 6df661f885 Create page announcements/de/SC-Eventplan.md
SteamWarCI Build failed
2025-06-28 17:21:11 +02:00
TheBreadBeard a32d84ed86 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-27 16:12:51 +02:00
Chaoscaot e60cebc9a3 Updated sw-arcade-fightplan.md
SteamWarCI Build successful
2025-06-27 00:31:58 +02:00
Chaoscaot 3576d5e034 Refactor save method to remove base64 encoding for page content
SteamWarCI Build successful
2025-06-27 00:31:08 +02:00
Chaoscaot d5c7d8fc27 Updated sw-arcade-fightplan.md
SteamWarCI Build failed
2025-06-27 00:29:26 +02:00
Chaoscaot ce895e9297 Add default value to prompt for change description in page update
SteamWarCI Build successful
2025-06-27 00:28:37 +02:00
Chaoscaot 7c83ad0937 Add prompt for change description in page update
SteamWarCI Build successful
2025-06-27 00:28:16 +02:00
Chaoscaot 5e0a9d89b3 Fixing
SteamWarCI Build successful
2025-06-27 00:24:13 +02:00
Chaoscaot 2a8b98ce5b Update copyright year to 2025 in table.css
SteamWarCI Build successful
2025-06-26 23:57:55 +02:00
Chaoscaot 427818d6bf Fixing
SteamWarCI Build failed
2025-06-26 23:56:45 +02:00
Chaoscaot 8424c14ca9 Remove unused import of ExtendedEvent from TeamTable.svelte
SteamWarCI Build failed
2025-06-26 23:54:21 +02:00
Chaoscaot 602a7e1453 Remove unused import of Team from TeamTable.svelte
SteamWarCI Build failed
2025-06-26 23:53:03 +02:00
Chaoscaot 9f31c5ff0c Remove unused import of Team from EventFightList.svelte
SteamWarCI Build failed
2025-06-26 23:50:28 +02:00
Chaoscaot 8a41b98c58 Remove unused import of ExtendedEvent from EventFightList.svelte
SteamWarCI Build failed
2025-06-26 23:49:09 +02:00
Chaoscaot 9fc5c500f5 Merge pull request 'Event Brackets' (#11) from event-brackets into master
SteamWarCI Build successful
Reviewed-on: #11
2025-06-26 23:40:59 +02:00
YoyoNow bc879d7cad Add login.page to de.json
SteamWarCI Build successful
2025-06-26 15:02:08 +02:00
TheBreadBeard 96f0019dc1 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-22 16:55:31 +02:00
TheBreadBeard 7418b608ab Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-21 23:33:31 +02:00
TheBreadBeard 3802b9bc26 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-13 14:21:16 +02:00
Chaoscaot 03effd2fd2 src/content/downloads/advancedscripts.json aktualisiert
SteamWarCI Build successful
2025-06-12 20:35:23 +02:00
TheBreadBeard a4669a897b Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-06 19:20:33 +02:00
Chaoscaot eac0d5592d src/i18n/common/de.json aktualisiert
SteamWarCI Build successful
2025-06-03 14:11:22 +02:00
TheBreadBeard bd9aea8f35 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-03 09:53:14 +02:00
TheBreadBeard 6e715cee07 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-03 07:11:00 +02:00
TheBreadBeard 4147a1d243 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-02 23:46:46 +02:00
TheBreadBeard 46dba2a6f9 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-02 23:44:12 +02:00
TheBreadBeard 3d8ad3a129 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-02 23:40:31 +02:00
TheBreadBeard 7d50a4db12 Update spacheship-event-ankündigung-und-regelwerk.md
SteamWarCI Build successful
2025-06-02 23:30:42 +02:00
171 changed files with 8317 additions and 2939 deletions
-4
View File
@@ -37,10 +37,6 @@
"error", "error",
4 4
], ],
"linebreak-style": [
"error",
"unix"
],
"quotes": [ "quotes": [
"error", "error",
"double" "double"
+88
View File
@@ -0,0 +1,88 @@
name: SteamWar CI
on:
push:
branches:
- master
pull_request:
workflow_dispatch:
env:
PUBLIC_API_SERVER: https://api.steamwar.de
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.14.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Write production environment
run: echo "PUBLIC_API_SERVER=${PUBLIC_API_SERVER}" > .env
- name: Generate i18n files
run: pnpm run i18n:sync
- name: Build website
run: pnpm run build
- name: Upload website artifact
uses: actions/upload-artifact@v3
with:
name: steamwar-website
path: dist/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- name: Download website artifact
uses: actions/download-artifact@v3
with:
name: steamwar-website
path: dist
- name: Upload website with scp
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PATH: /var/www/html
run: |
set -euo pipefail
: "${DEPLOY_HOST:?Missing DEPLOY_HOST secret}"
: "${DEPLOY_USER:?Missing DEPLOY_USER secret}"
: "${DEPLOY_SSH_KEY:?Missing DEPLOY_SSH_KEY secret}"
port="${DEPLOY_PORT:-22}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p '$DEPLOY_PATH' && find '$DEPLOY_PATH' -mindepth 1 -maxdepth 1 -exec rm -rf {} +"
scp -i ~/.ssh/deploy_key -P "$port" -r dist/* "${DEPLOY_USER}@${DEPLOY_HOST}:$DEPLOY_PATH/"
+4 -1
View File
@@ -1,7 +1,10 @@
import type { AstroIntegration } from "astro"; import type { AstroIntegration } from "astro";
import { mkdir, access, constants, copyFile, rename } from "node:fs/promises"; import { mkdir, access, constants, copyFile, rename } from "node:fs/promises";
const locales = ["en"]; const locales = [
"en",
"de",
];
export default function configureI18n(): AstroIntegration { export default function configureI18n(): AstroIntegration {
return { return {
+33 -6
View File
@@ -1,4 +1,4 @@
import {defineConfig, sharpImageService} from "astro/config"; import { defineConfig, sharpImageService } from "astro/config";
import svelte from "@astrojs/svelte"; import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
import configureI18n from "./astro-i18n.adapter"; import configureI18n from "./astro-i18n.adapter";
@@ -8,6 +8,8 @@ import robotsTxt from "astro-robots-txt";
import path from "node:path"; import path from "node:path";
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
import starlight from "@astrojs/starlight";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: "static", output: "static",
@@ -18,14 +20,39 @@ export default defineConfig({
site: "https://steamwar.de", site: "https://steamwar.de",
integrations: [ integrations: [
svelte(), svelte(),
starlight({
disable404Route: true,
title: "SteamWar Docs",
defaultLocale: "de",
logo: {
src: "./src/images/logo.png",
},
social: [
{ icon: "discord", label: "Discord", href: "https://steamwar.de/discord" },
{ icon: "document", label: "Gitea", href: "https://git.steamwar.de" },
],
sidebar: [
{ label: "Startseite", slug: "docs" },
{ label: "Bau", badge: "WIP", items: ["docs/bausystem", { label: "Script System", items: ["docs/bausystem/script"] }] },
{ label: "Kampfsystem", badge: "WIP", items: ["docs/fightsystem"] },
{ label: "Minigames", badge: "WIP", items: ["docs/minigames"] },
{ label: "Schematicsystem", badge: "WIP", items: ["docs/schematicsystem"] },
{ label: "API", badge: "WIP", items: ["docs/api"] },
],
editLink: {
baseUrl: "https://git.steamwar.de/SteamWar/Website/src/branch/master/",
},
}),
tailwind({ tailwind({
configFile: "./tailwind.config.js", configFile: "./tailwind.config.js",
applyBaseStyles: false,
}), }),
configureI18n(),
sitemap({ sitemap({
i18n: { i18n: {
defaultLocale: "en", locales: { defaultLocale: "en",
en: "en-US", de: "de-DE", locales: {
en: "en-US",
de: "de-DE",
}, },
}, },
}), }),
@@ -49,7 +76,7 @@ export default defineConfig({
{ userAgent: "omgili", disallow: "/" }, { userAgent: "omgili", disallow: "/" },
{ userAgent: "OmigliBot", disallow: "/" }, { userAgent: "OmigliBot", disallow: "/" },
{ userAgent: "PerplexityBot", disallow: "/" }, { userAgent: "PerplexityBot", disallow: "/" },
{ userAgent: "Timpibot", disallow: "/" } { userAgent: "Timpibot", disallow: "/" },
], ],
}), }),
mdx(), mdx(),
@@ -66,7 +93,7 @@ export default defineConfig({
"@layouts": path.resolve("./src/layouts"), "@layouts": path.resolve("./src/layouts"),
"@repo": path.resolve("./src/components/repo"), "@repo": path.resolve("./src/components/repo"),
"@stores": path.resolve("./src/components/stores"), "@stores": path.resolve("./src/components/stores"),
"$lib": path.resolve("./src"), $lib: path.resolve("./src"),
}, },
}, },
}, },
+3 -4
View File
@@ -1,8 +1,6 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": { "tailwind": {
"config": "tailwind.config.js",
"css": "src\\styles\\app.css", "css": "src\\styles\\app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
@@ -10,8 +8,9 @@
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/components/utils", "utils": "$lib/components/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks" "hooks": "$lib/hooks",
"lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://next.shadcn-svelte.com/registry" "registry": "https://tw3.shadcn-svelte.com/registry/default"
} }
+3 -3
View File
@@ -58,6 +58,8 @@
"dependencies": { "dependencies": {
"@astrojs/mdx": "^4.3.0", "@astrojs/mdx": "^4.3.0",
"@astrojs/sitemap": "^3.4.0", "@astrojs/sitemap": "^3.4.0",
"@astrojs/starlight": "^0.34.4",
"@astrojs/starlight-tailwind": "^4.0.1",
"@codemirror/commands": "^6.8.1", "@codemirror/commands": "^6.8.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/view": "^6.36.8", "@codemirror/view": "^6.36.8",
@@ -84,11 +86,9 @@
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"pnpm": { "pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",
"esbuild",
"sharp" "sharp"
] ]
} }
+9 -3
View File
@@ -3,7 +3,13 @@ import { Image } from "astro:assets";
import localBau from "@images/90.png"; import localBau from "@images/90.png";
--- ---
<Image src={localBau} alt="Bau" widths={[240, 540, 720, 1080, 1920, localBau.width]} <Image
src={localBau}
alt="Bau"
widths={[240, 540, 720, 1080, 1920, localBau.width]}
sizes={`(max-width: 240px) 240px, (max-width: 540px) 540px, (max-width: 720px) 720px, (max-width: 1080px) 1080px, (max-width: 1920px) 1920px, ${localBau.width}px`} sizes={`(max-width: 240px) 240px, (max-width: 540px) 540px, (max-width: 720px) 720px, (max-width: 1080px) 1080px, (max-width: 1920px) 1920px, ${localBau.width}px`}
class="w-full h-full object-cover rounded-b-2xl shadow-2xl" quality={100} class="w-full h-full object-cover"
draggable="false" loading="eager"/> quality={100}
draggable="false"
loading="eager"
/>
+5 -2
View File
@@ -52,7 +52,7 @@
} }
let { hoverEffect = true, extraClasses = "", children }: Props = $props(); let { hoverEffect = true, extraClasses = "", children }: Props = $props();
let classes = $derived(twMerge("w-72 border-2 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses)); let classes = $derived(twMerge("flex flex-col items-center p-8 m-4 bg-[#0c0c0c] border border-[rgba(255,255,255,0.06)] text-gray-100", extraClasses));
</script> </script>
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect> <div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
@@ -67,10 +67,13 @@
:global(h1) { :global(h1) {
@apply text-xl font-bold mt-4; @apply text-xl font-bold mt-4;
font-family: "Barlow Condensed", sans-serif;
letter-spacing: 0.06em;
} }
:global(svg) { :global(svg) {
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl; color: #f59e0b;
@apply transition-transform duration-300 ease-in-out hover:scale-110;
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<!-- <!--
- This file is a part of the SteamWar software. - This file is a part of the SteamWar software.
- -
- Copyright (C) 2023 SteamWar.de-Serverteam - Copyright (C) 2026 SteamWar.de-Serverteam
- -
- This program is free software: you can redistribute it and/or modify - This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by - it under the terms of the GNU Affero General Public License as published by
+2 -2
View File
@@ -44,7 +44,7 @@
} }
</script> </script>
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto"> <div class="p-3 bg-[#0c0c0c] border border-[rgba(255,255,255,0.06)] w-3/4 mx-auto">
<table> <table>
<thead> <thead>
<tr class="font-bold border-b"> <tr class="font-bold border-b">
@@ -57,7 +57,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows ) as fights} {#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows, ) as fights}
<tr> <tr>
{#each fights as fight (fight.id)} {#each fights as fight (fight.id)}
<td <td
+18 -29
View File
@@ -24,38 +24,27 @@
import type { ExtendedEvent } from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import "@styles/table.css"; import "@styles/table.css";
export let event: ExtendedEvent; let {
export let group: number; event,
export let rows: number = 1; group,
rows = 1,
}: {
event: ExtendedEvent;
group: number;
rows?: number;
} = $props();
$: teamPoints = event.teams let teamPoints = $derived(
.map((team) => { Object.entries(event.groups.find((g) => g.id === group)?.points ?? {})
let fights = event.fights.filter((fight) => fight.blueTeam.id === team.id || fight.redTeam.id === team.id); .map(([teamId, points]) => ({
team: event.teams.find((t) => t.id === Number(teamId))!!,
if (group !== undefined) { points: points,
fights = fights.filter((fight) => fight.group?.id === group); }))
} .sort((a, b) => b.points - a.points),
);
const points = fights.reduce((acc, fight) => {
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
return acc + (fight.group?.pointsPerWin ?? 3);
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
return acc + (fight.group?.pointsPerWin ?? 3);
} else if (fight.ergebnis === 3) {
return acc + (fight.group?.pointsPerDraw ?? 1);
} else {
return acc + (fight.group?.pointsPerLoss ?? 0);
}
}, 0);
return {
team,
points,
};
})
.sort((a, b) => b.points - a.points);
</script> </script>
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto"> <div class="p-3 bg-[#0c0c0c] border border-[rgba(255,255,255,0.06)] w-3/4 mx-auto">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="font-bold border-b"> <tr class="font-bold border-b">
+4 -4
View File
@@ -1,8 +1,8 @@
--- ---
import {t} from "astro-i18n"; import { t } from "astro-i18n";
--- ---
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4" role="alert"> <div class="border-l-2 border-amber-500 bg-amber-500/5 p-4" role="alert">
<p class="font-bold">{t("warning.title")}</p> <p class="font-bold text-amber-400" style="font-family: 'Barlow Condensed', sans-serif; letter-spacing: 0.1em; text-transform: uppercase;">{t("warning.title")}</p>
<p>{t("warning.text")}</p> <p class="text-gray-400 text-sm">{t("warning.text")}</p>
</div> </div>
+85 -15
View File
@@ -1,7 +1,7 @@
<!-- <!--
- This file is a part of the SteamWar software. - This file is a part of the SteamWar software.
- -
- Copyright (C) 2023 SteamWar.de-Serverteam - Copyright (C) 2026 SteamWar.de-Serverteam
- -
- This program is free software: you can redistribute it and/or modify - This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by - it under the terms of the GNU Affero General Public License as published by
@@ -18,20 +18,20 @@
--> -->
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy'; import { preventDefault } from "svelte/legacy";
import { l } from "@utils/util.ts";
import {l} from "@utils/util.ts"; import { t } from "astro-i18n";
import {t} from "astro-i18n"; import { get } from "svelte/store";
import {get} from "svelte/store"; import { navigate } from "astro:transitions/client";
import {navigate} from "astro:transitions/client"; import { onMount } from "svelte";
import { authV2Repo } from "./repo/authv2.ts";
let username: string = $state(""); let username: string = $state("");
let pw: string = $state(""); let pw: string = $state("");
let error: string = $state(""); let error: string = $state("");
async function login() { async function login() {
let {authV2Repo} = await import("./repo/authv2.ts"); let { authV2Repo } = await import("./repo/authv2.ts");
if (username === "" || pw === "") { if (username === "" || pw === "") {
pw = ""; pw = "";
error = t("login.error"); error = t("login.error");
@@ -52,10 +52,30 @@
error = t("login.error"); error = t("login.error");
} }
} }
onMount(() => {
if (window.location.hash.includes("access_token")) {
const params = new URLSearchParams(window.location.hash.substring(1));
const accessToken = params.get("access_token");
if (accessToken) {
(async () => {
let auth = await $authV2Repo.loginDiscord(accessToken);
if (!auth) {
pw = "";
error = t("login.error");
return;
}
navigate(l("/dashboard"));
})();
}
}
});
</script> </script>
<form class="bg-gray-100 dark:bg-neutral-900 p-12 rounded-2xl shadow-2xl border-2 border-gray-600 flex flex-col" onsubmit={preventDefault(login)}> <form class="sw-login-form" onsubmit={preventDefault(login)}>
<h1 class="text-4xl text-white text-center">{t("login.title")}</h1> <h1 class="text-4xl text-white text-center" style="font-family: 'Barlow Condensed', sans-serif; letter-spacing: 0.08em;">{t("login.title")}</h1>
<div class="ml-2 flex flex-col"> <div class="ml-2 flex flex-col">
<label for="username">{t("login.label.username")}</label> <label for="username">{t("login.label.username")}</label>
<input type="text" id="username" name="username" placeholder={t("login.placeholder.username")} bind:value={username} /> <input type="text" id="username" name="username" placeholder={t("login.placeholder.username")} bind:value={username} />
@@ -63,20 +83,70 @@
<input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} /> <input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} />
</div> </div>
<p class="mt-2"> <p class="mt-2">
<a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a></p> <a class="sw-link" href={l("/set-password")}>{t("login.setPassword")}</a>
</p>
{#if error} {#if error}
<p class="mt-2 text-red-500">{error}</p> <p class="mt-2 text-red-500">{error}</p>
{/if} {/if}
<button class="btn mt-4 !mx-0 justify-center" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button> <button class="btn mt-4 justify-center w-full" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button>
<a
class="btn mt-4 justify-center w-full"
href="https://discord.com/oauth2/authorize?client_id=869606970099904562&response_type=token&redirect_uri=https%3A%2F%2Fsteamwar.de%2Flogin&scope=identify"
>
{t("login.discord")}
</a>
</form> </form>
<style lang="postcss"> <style lang="postcss">
.sw-login-form {
background: rgba(12, 12, 12, 0.95);
border: 1px solid rgba(255, 255, 255, 0.06);
border-top: 2px solid #f59e0b;
backdrop-filter: blur(24px);
padding: 3rem;
display: flex;
flex-direction: column;
}
input { input {
@apply border-2 rounded-md p-2 shadow-2xl w-80 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent text-black; width: 20rem;
padding: 0.6rem 0.8rem;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f5f5;
font-size: 0.85rem;
outline: none;
transition: border-color 0.2s ease;
}
input:focus {
border-color: rgba(245, 158, 11, 0.5);
} }
label { label {
@apply text-neutral-300; font-family: "Barlow Condensed", sans-serif;
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(163, 163, 163, 0.7);
margin-top: 0.5rem;
}
.sw-link {
color: rgba(163, 163, 163, 0.5);
text-decoration: none;
font-size: 0.85rem;
border-bottom: 1px solid transparent;
transition:
color 0.2s,
border-color 0.2s;
}
.sw-link:hover {
color: #f59e0b;
border-bottom-color: #f59e0b;
} }
</style> </style>
+57 -20
View File
@@ -19,11 +19,11 @@
<script lang="ts"> <script lang="ts">
import "../styles/button.css"; import "../styles/button.css";
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, GlobeOutline } from "flowbite-svelte-icons";
import { t } from "astro-i18n"; import { t, l } from "astro-i18n";
import { l } from "../util/util";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { loggedIn } from "@repo/authv2.ts"; import { loggedIn } from "@repo/authv2.ts";
import { astroI18n } from "astro-i18n";
interface Props { interface Props {
logo?: import("svelte").Snippet; logo?: import("svelte").Snippet;
} }
@@ -35,6 +35,8 @@
let accountBtn = $state<HTMLAnchorElement>(); let accountBtn = $state<HTMLAnchorElement>();
let currentPage = $state(astroI18n.route);
$effect(() => { $effect(() => {
if ($loggedIn) { if ($loggedIn) {
accountBtn!.href = l("/dashboard"); accountBtn!.href = l("/dashboard");
@@ -45,33 +47,34 @@
onMount(() => { onMount(() => {
handleScroll(); handleScroll();
document.addEventListener("astro:page-load", () => {
astroI18n.route = location.pathname;
currentPage = astroI18n.route;
});
}); });
function handleScroll() { function handleScroll() {
if (window.scrollY > 0) { if (window.scrollY > 0) {
navbar!.classList.add("before:scale-y-100"); navbar!.classList.add("sw-nav-scrolled");
} else { } else {
navbar!.classList.remove("before:scale-y-100"); navbar!.classList.remove("sw-nav-scrolled");
} }
} }
</script> </script>
<svelte:window onscroll={handleScroll} /> <svelte:window onscroll={handleScroll} />
<nav <nav data-pagefind-ignore class="sw-nav z-20 fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors flex justify-center" bind:this={navbar}>
data-pagefind-ignore
class="z-20 fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top"
bind:this={navbar}
>
<div class="flex flex-row items-center justify-evenly md:justify-between match"> <div class="flex flex-row items-center justify-evenly md:justify-between match">
<a class="flex items-center" href={l("/")}> <a class="flex items-center" href={l("/")}>
{@render logo?.()} {@render logo?.()}
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block"> <span class="sw-nav-title hidden md:inline-block">
{t("navbar.title")} {t("navbar.title")}
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span> <span class="scrolled-trigger" style="display: none" aria-hidden="true"></span>
</span> </span>
</a> </a>
<div class="flex justify-center flex-wrap"> <div class="flex justify-center flex-wrap gap-2">
<div class="btn-dropdown"> <div class="btn-dropdown">
<button class="btn btn-gray"> <button class="btn btn-gray">
<a href={l("/")}> <a href={l("/")}>
@@ -81,6 +84,7 @@
</button> </button>
<div> <div>
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a> <a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
<a class="btn btn-gray" href={l("/events")}>{t("navbar.links.home.events")}</a>
<a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a> <a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a>
<a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a> <a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a>
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a> <a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a>
@@ -103,8 +107,6 @@
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</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/microwargear")} class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
<a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a> <a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a>
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a>
</div> </div>
</div> </div>
<!-- TODO: Add help center <!-- TODO: Add help center
@@ -124,11 +126,21 @@
<a class="btn" href={l("/login")} bind:this={accountBtn}> <a class="btn" href={l("/login")} bind:this={accountBtn}>
<span class="btn__text">{t("navbar.links.account")}</span> <span class="btn__text">{t("navbar.links.account")}</span>
</a> </a>
<!-- <div class="btn-dropdown">
<button class="btn my-1" onclick={() => searchOpen = true}> <button class="btn btn-gray">
<SearchOutline ariaLabel="Site Search" class="inline-block h-6"/> <GlobeOutline />
</button> </button>
--> <div>
<a
data-astro-reload
href={l(currentPage, {}, { targetLocale: typeof navigator !== "undefined" ? navigator.language.split("-")[0] : "de" })}
onclick={() => cookieStore.delete("MANUAL_LANGUAGE")}
class="btn btn-gray">Auto</a
>
<a data-astro-reload href={l(currentPage, {}, { targetLocale: "de" })} onclick={() => cookieStore.set("MANUAL_LANGUAGE", "TRUE")} class="btn btn-gray">Deutsch</a>
<a data-astro-reload href={l(currentPage, {}, { targetLocale: "en" })} onclick={() => cookieStore.set("MANUAL_LANGUAGE", "TRUE")} class="btn btn-gray">English</a>
</div>
</div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -143,4 +155,29 @@
.match { .match {
width: min(100vw, 70em); width: min(100vw, 70em);
} }
:global(.sw-nav) {
backdrop-filter: none;
background: transparent;
transition:
background 0.3s ease,
backdrop-filter 0.3s ease,
border-color 0.3s ease;
border-bottom: 1px solid transparent;
}
:global(.sw-nav-scrolled) {
background: rgba(8, 8, 8, 0.85) !important;
backdrop-filter: blur(16px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.04) !important;
}
.sw-nav-title {
font-family: "Barlow Condensed", sans-serif;
font-size: 1.3rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #f5f5f5;
}
</style> </style>
+4 -9
View File
@@ -22,26 +22,21 @@ const {
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`); const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
--- ---
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}> <Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-2 backdrop-blur-xl bg-transparent border-0" : "border-t-2 border-t-amber-500/30"}`} hoverEffect={false}>
<div class={`flex flex-row ${slim ? "" : "p-4"}`}> <div class={`flex flex-row ${slim ? "" : "p-4"}`}>
{ {
post.data.image != null ? ( post.data.image != null ? (
<a href={postUrl}> <a href={postUrl}>
<div class="flex-shrink-0 pr-2"> <div class="flex-shrink-0 pr-2">
<Image <Image transition:name={post.data.title + "-image"} src={post.data.image} alt="Post Image" class="object-cover h-32 w-32 max-w-none transition-transform hover:scale-105" />
transition:name={post.data.title + "-image"}
src={post.data.image}
alt="Post Image"
class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105"
/>
</div> </div>
</a> </a>
) : null ) : null
} }
<div> <div>
<a href={postUrl} class="flex flex-col items-start"> <a href={postUrl} class="flex flex-col items-start">
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2> <h2 class="text-2xl font-bold" style="font-family: 'Barlow Condensed', sans-serif; letter-spacing: 0.04em;" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
<P class="text-gray-500" <P class="text-gray-500 text-sm"
>{ >{
Intl.DateTimeFormat(astroI18n.locale, { Intl.DateTimeFormat(astroI18n.locale, {
day: "numeric", day: "numeric",
+34 -21
View File
@@ -18,12 +18,11 @@
--> -->
<script lang="ts"> <script lang="ts">
import { slide, fade } from "svelte/transition";
import {slide, fade} from "svelte/transition"; import { onMount } from "svelte";
import {onMount} from "svelte"; import { importPagefind, type Pagefind, type PagefindDocument } from "@type/pagefind.js";
import {importPagefind, type Pagefind, type PagefindDocument} from "@type/pagefind.js";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
import {l} from "@utils/util.ts"; import { l } from "@utils/util.ts";
let pagefind: Pagefind; let pagefind: Pagefind;
onMount(async () => { onMount(async () => {
@@ -36,27 +35,26 @@
async function search(e: KeyboardEvent) { async function search(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement) { if (e.target instanceof HTMLInputElement) {
let search: {results: any[]} = await pagefind.debouncedSearch(e.target.value); let search: { results: any[] } = await pagefind.debouncedSearch(e.target.value);
results = await Promise.all(search.results.slice(0, 10).map(value => value.data())) results = await Promise.all(search.results.slice(0, 10).map((value) => value.data()));
} }
} }
interface Props { interface Props {
open?: boolean; open?: boolean;
} }
let { open = $bindable(false) }: Props = $props(); let { open = $bindable(false) }: Props = $props();
</script> </script>
<button transition:fade class="fixed top-0 left-0 w-screen h-screen backdrop-blur z-20 cursor-default" onclick={() => open = false}> <button transition:fade class="fixed top-0 left-0 w-screen h-screen bg-black/60 backdrop-blur-sm z-20 cursor-default" onclick={() => (open = false)}> </button>
</button> <div transition:slide style="width: min(100%, 75em);" class="fixed top-0 left-1/2 -translate-x-1/2 h-2/3 z-30 p-4 text-white flex flex-col sw-search-panel">
<div transition:slide style="width: min(100%, 75em);" class="fixed top-0 left-1/2 -translate-x-1/2 h-2/3 dark:bg-zinc-900 rounded-b-2xl shadow-2xl z-30 p-4 text-white flex flex-col"> <input placeholder="Search..." onkeypress={search} />
<input placeholder="Search..." onkeypress={search}>
<div class="overflow-y-scroll flex-1 w-full mt-2 rounded-2xl"> <div class="overflow-y-scroll flex-1 w-full mt-2">
{#each results as result} {#each results as result}
<Card extraClasses="w-full m-0 my-2" hoverEffect={false}> <Card extraClasses="w-full m-0 my-2 border-t-2 border-t-amber-500/30" hoverEffect={false}>
<a class="grid grid-cols-3" href={l(result.url)}> <a class="grid grid-cols-3" href={l(result.url)}>
<h1>{result.meta.title}</h1> <h1>{result.meta.title}</h1>
{#each result.sub_results.slice(0, 2) as sub_result} {#each result.sub_results.slice(0, 2) as sub_result}
@@ -69,13 +67,28 @@
</div> </div>
<style lang="postcss"> <style lang="postcss">
.sw-search-panel {
background: rgba(8, 8, 8, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px);
}
input { input {
@apply border-2 rounded-md p-2 shadow-2xl w-full width: 100%;
dark:bg-neutral-800 padding: 0.7rem 1rem;
focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f5f5;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s ease;
}
input:focus {
border-color: rgba(245, 158, 11, 0.5);
} }
label { label {
@apply text-neutral-300; color: rgba(163, 163, 163, 0.7);
} }
</style> </style>
+20 -11
View File
@@ -1,22 +1,31 @@
--- ---
import { l } from "../util/util";
import {l} from "../util/util"; import { capitalize } from "./admin/util";
import {capitalize} from "./admin/util";
interface Props { interface Props {
tag: string; tag: string;
noLink?: boolean; noLink?: boolean;
} }
const {tag, noLink} = Astro.props; const { tag, noLink } = Astro.props;
--- ---
{noLink {
? ( noLink ? (
<span class="inline-block bg-gray-800 rounded-full px-3 py-1 text-sm font-semibold text-white mr-2 shadow-2xl">{capitalize(tag)}</span> <span
) class="inline-block bg-transparent border border-amber-500/30 px-3 py-0.5 text-xs font-semibold text-amber-400 mr-2 uppercase tracking-wider"
: ( style="font-family: 'Barlow Condensed', sans-serif;"
>
{capitalize(tag)}
</span>
) : (
<a href={l(`/announcements/tags/${tag}`)}> <a href={l(`/announcements/tags/${tag}`)}>
<span class="inline-block bg-gray-800 rounded-full px-3 py-1 text-sm font-semibold text-white mr-2 shadow-2xl">{capitalize(tag)}</span> <span
class="inline-block bg-transparent border border-amber-500/30 px-3 py-0.5 text-xs font-semibold text-amber-400 mr-2 uppercase tracking-wider hover:border-amber-400 hover:text-amber-300 transition-colors"
style="font-family: 'Barlow Condensed', sans-serif;"
>
{capitalize(tag)}
</span>
</a> </a>
)} )
}
@@ -18,83 +18,103 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Input, Label, Select} from "flowbite-svelte"; import { Input, Label, Select } from "flowbite-svelte";
import TypeAheadSearch from "./TypeAheadSearch.svelte"; import TypeAheadSearch from "./TypeAheadSearch.svelte";
import {gamemodes, groups, maps, players} from "@stores/stores.ts"; import { gamemodes, groups, maps } from "@stores/stores.ts";
import type {Team} from "@type/team.ts"; import type { Team } from "@type/team.ts";
interface Props { interface Props {
teams?: Team[]; teams?: Team[];
blueTeam: string; blueTeam: string;
redTeam: string; redTeam: string;
start?: string; start?: string;
gamemode?: string; gamemode?: string;
map?: string; map?: string;
spectatePort?: string | null; spectatePort?: string | null;
group?: string | null; group?: string | null;
groupSearch?: string; groupSearch?: string;
} }
let { let {
teams = [], teams = [],
blueTeam = $bindable(), blueTeam = $bindable(),
redTeam = $bindable(), redTeam = $bindable(),
start = $bindable(""), start = $bindable(""),
gamemode = $bindable(""), gamemode = $bindable(""),
map = $bindable(""), map = $bindable(""),
spectatePort = $bindable(null), spectatePort = $bindable(null),
group = $bindable(""), group = $bindable(""),
groupSearch = $bindable("") groupSearch = $bindable(""),
}: Props = $props(); }: Props = $props();
let selectableTeams = $derived(teams.map(team => { let selectableTeams = $derived(
return { teams
name: team.name, .map((team) => {
value: team.id.toString() return {
}; name: team.name,
}).sort((a, b) => a.name.localeCompare(b.name))); value: team.id.toString(),
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let selectableGamemodes = $derived($gamemodes.map(gamemode => { let selectableGamemodes = $derived(
return { $gamemodes
name: gamemode, .map((gamemode) => {
value: gamemode return {
}; name: gamemode,
}).sort((a, b) => a.name.localeCompare(b.name))); value: gamemode,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== ""); let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== "");
let selectableCustomGamemode = $derived([ let selectableCustomGamemode = $derived([
...selectableGamemodes, { ...selectableGamemodes,
{
name: gamemode + " (custom)", name: gamemode + " (custom)",
value: gamemode value: gamemode,
} },
]); ]);
let mapsStore = $derived(maps(gamemode)); let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map(map => { let selectableMaps = $derived(
return { $mapsStore
name: map, .map((map) => {
value: map return {
}; name: map,
}).sort((a, b) => a.name.localeCompare(b.name))); value: map,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== ""); let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== "");
let selectableCustomMaps = $derived([ let selectableCustomMaps = $derived([
...selectableMaps, { ...selectableMaps,
{
name: map + " (custom)", name: map + " (custom)",
value: map value: map,
} },
]); ]);
let selectableGroups = $derived([{ let selectableGroups = $derived([
name: "None", {
value: "" name: "None",
}, { value: "",
value: groupSearch, },
name: `Create: '${groupSearch}'` {
}, ...$groups.map(group => { value: groupSearch,
return { name: `Create: '${groupSearch}'`,
name: group, },
value: group ...$groups
}; .map((group) => {
}).sort((a, b) => a.name.localeCompare(b.name))]); return {
name: group,
value: group,
};
})
.sort((a, b) => a.name.localeCompare(b.name)),
]);
</script> </script>
<div class="m-2"> <div class="m-2">
@@ -107,32 +127,29 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<Label for="fight-start">Start</Label> <Label for="fight-start">Start</Label>
<Input id="fight-start" bind:value={start} > <Input id="fight-start" bind:value={start}>
{#snippet children({ props })} {#snippet children({ props })}
<input type="datetime-local" {...props} bind:value={start}/> <input type="datetime-local" {...props} bind:value={start} />
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-gamemode">Gamemode</Label> <Label for="fight-gamemode">Gamemode</Label>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} <Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select>
id="fight-gamemode"></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-maps">Map</Label> <Label for="fight-maps">Map</Label>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" <Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-port">Spectate Port</Label> <Label for="fight-port">Spectate Port</Label>
<Input id="fight-port" bind:value={spectatePort} > <Input id="fight-port" bind:value={spectatePort}>
{#snippet children({ props })} {#snippet children({ props })}
<input type="number" inputmode="numeric" {...props} bind:value={spectatePort}/> <input type="number" inputmode="numeric" {...props} bind:value={spectatePort} />
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-kampf">Group</Label> <Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} <TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch>
all></TypeAheadSearch>
</div> </div>
+27 -29
View File
@@ -18,19 +18,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Navbar, NavBrand, Spinner, TabItem, Tabs} from "flowbite-svelte"; import { Navbar, NavBrand, Spinner, TabItem, Tabs } from "flowbite-svelte";
import EventEdit from "./event/EventEdit.svelte"; import EventEdit from "./event/EventEdit.svelte";
import {ArrowLeftOutline} from "flowbite-svelte-icons"; import { ArrowLeftOutline } from "flowbite-svelte-icons";
import FightList from "./event/FightList.svelte";
import TeamList from "./event/TeamList.svelte"; import TeamList from "./event/TeamList.svelte";
import {eventRepo} from "@repo/event.ts"; import { eventRepo } from "@repo/event.ts";
import RefereesList from "@components/admin/pages/event/RefereesList.svelte"; import RefereesList from "@components/admin/pages/event/RefereesList.svelte";
interface Props { interface Props {
params: { id: number }; params: { id: number };
} }
let { params }: Props = $props(); let { params }: Props = $props();
let id = params.id; let id = params.id;
let event = $eventRepo.getEvent(id.toString()); let event = $eventRepo.getEvent(id.toString());
@@ -38,44 +37,43 @@
{#await event} {#await event}
<div class="h-screen w-screen grid place-items-center"> <div class="h-screen w-screen grid place-items-center">
<Spinner size={16}/> <Spinner size={16} />
</div> </div>
{:then data} {:then data}
<Navbar > <Navbar>
{#snippet children({ hidden, toggle })} {#snippet children({ hidden, toggle })}
<NavBrand href="#"> <NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline> <ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> <span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
{data.event.name} {data.event.name}
</span> </span>
</NavBrand> </NavBrand>
{/snippet} {/snippet}
</Navbar> </Navbar>
<Tabs style="pill" class="mx-4 flex shadow-lg border-b-2 border-gray-700 pb-2" contentClass=""> <Tabs style="pill" class="mx-4 flex shadow-lg border-b-2 border-gray-700 pb-2" contentClass="">
<TabItem open> <TabItem open>
{#snippet title()} {#snippet title()}
<span >Event</span> <span>Event</span>
{/snippet} {/snippet}
<EventEdit {data}/> <EventEdit {data} />
</TabItem> </TabItem>
<TabItem> <TabItem>
{#snippet title()} {#snippet title()}
<span >Teams</span> <span>Teams</span>
{/snippet} {/snippet}
<TeamList {data}/> <TeamList {data} />
</TabItem> </TabItem>
<TabItem> <TabItem>
{#snippet title()} {#snippet title()}
<span >Schiedsrichter</span> <span>Schiedsrichter</span>
{/snippet} {/snippet}
<RefereesList {data}/> <RefereesList {data} />
</TabItem> </TabItem>
<TabItem> <TabItem>
{#snippet title()} {#snippet title()}
<span >Kämpfe</span> <span>Kämpfe</span>
{/snippet} {/snippet}
<FightList {data}/>
</TabItem> </TabItem>
</Tabs> </Tabs>
{:catch error} {:catch error}
+56 -51
View File
@@ -18,21 +18,37 @@
--> -->
<script lang="ts"> <script lang="ts">
import { run, preventDefault } from 'svelte/legacy'; import { run, preventDefault } from "svelte/legacy";
import {Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner} from "flowbite-svelte"; import { Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner } from "flowbite-svelte";
import {ArrowLeftOutline} from "flowbite-svelte-icons"; import { ArrowLeftOutline } from "flowbite-svelte-icons";
import {players} from "@stores/stores.ts"; import { capitalize } from "../util.ts";
import {capitalize} from "../util.ts"; import { permsRepo } from "@repo/perms.ts";
import {permsRepo} from "@repo/perms.ts"; import { me } from "@stores/me.ts";
import {me} from "@stores/me.ts";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import {userRepo} from "@repo/user.ts"; import { userRepo } from "@repo/user.ts";
import { dataRepo } from "@repo/data.ts";
import type { Player } from "@type/data";
let search = $state(""); let search = $state("");
let playersList: Player[] = $state([]);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 100, 0, undefined, undefined);
playersList = res.players;
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
let selectedPlayerName: string = $state("");
let playerPerms = $state(loadPlayer(selectedPlayer)); let playerPerms = $state(loadPlayer(selectedPlayer));
let prefixEdit = $state("PREFIX_NONE"); let prefixEdit = $state("PREFIX_NONE");
@@ -46,7 +62,7 @@
if (!id) { if (!id) {
return; return;
} }
return $permsRepo.getPerms(id).then(value => { return $permsRepo.getPerms(id).then((value) => {
activePerms = value.perms; activePerms = value.perms;
prefixEdit = value.prefix.name; prefixEdit = value.prefix.name;
return value; return value;
@@ -56,7 +72,7 @@
function togglePerm(perm: string) { function togglePerm(perm: string) {
return () => { return () => {
if (activePerms.includes(perm)) { if (activePerms.includes(perm)) {
activePerms = activePerms.filter(value => value !== perm); activePerms = activePerms.filter((value) => value !== perm);
} else { } else {
activePerms = [...activePerms, perm]; activePerms = [...activePerms, perm];
} }
@@ -64,7 +80,7 @@
} }
function save() { function save() {
playerPerms!.then(async perms => { playerPerms!.then(async (perms) => {
if (perms.prefix.name != prefixEdit) { if (perms.prefix.name != prefixEdit) {
await $permsRepo.setPrefix(selectedPlayer!, prefixEdit); await $permsRepo.setPrefix(selectedPlayer!, prefixEdit);
} }
@@ -99,24 +115,20 @@
resetPasswordRepeat = ""; resetPasswordRepeat = "";
resetPasswordModal = false; resetPasswordModal = false;
} }
let lowerCaseSearch = $derived(search.toLowerCase());
let filteredPlayers = $derived($players.filter(value => value.name.toLowerCase().includes(lowerCaseSearch)));
let player = $derived($players.find(value => value.uuid === selectedPlayer));
run(() => { run(() => {
playerPerms = loadPlayer(selectedPlayer); playerPerms = loadPlayer(selectedPlayer);
}); });
</script> </script>
<div class="flex flex-col h-screen overflow-hidden"> <div class="flex flex-col h-screen overflow-hidden">
<Navbar > <Navbar>
{#snippet children({ hidden, toggle })} {#snippet children({ hidden, toggle })}
<NavBrand href="#"> <NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline> <ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> <span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> Permissions </span>
Permissions </NavBrand>
</span> {/snippet}
</NavBrand>
{/snippet}
</Navbar> </Navbar>
<div class="p-4 flex-1 overflow-hidden"> <div class="p-4 flex-1 overflow-hidden">
@@ -124,14 +136,19 @@
<Card class="h-full flex flex-col overflow-hidden !max-w-full"> <Card class="h-full flex flex-col overflow-hidden !max-w-full">
<div class="border-b border-b-gray-600 pb-2"> <div class="border-b border-b-gray-600 pb-2">
<Label for="user_search" class="mb-2">Search Users...</Label> <Label for="user_search" class="mb-2">Search Users...</Label>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search}/> <Input type="text" id="user_search" placeholder="Name..." bind:value={search} />
</div> </div>
{#if filteredPlayers.length < 100} {#if playersList.length < 100}
<ul class="flex-1 overflow-scroll"> <ul class="flex-1 overflow-scroll">
{#each filteredPlayers as player (player.uuid)} {#each playersList as player (player.uuid)}
<li class="p-4 transition-colors hover:bg-gray-700 cursor-pointer" <li
class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
class:text-orange-500={player.uuid === selectedPlayer} class:text-orange-500={player.uuid === selectedPlayer}
onclick={preventDefault(() => selectedPlayer = player.uuid)}> onclick={preventDefault(() => {
selectedPlayer = player.uuid;
selectedPlayerName = player.name;
})}
>
{player.name} {player.name}
</li> </li>
{/each} {/each}
@@ -140,7 +157,7 @@
</Card> </Card>
<Card class="!max-w-full" style="grid-column: 2/4"> <Card class="!max-w-full" style="grid-column: 2/4">
{#if selectedPlayer} {#if selectedPlayer}
<h1 class="text-3xl">{player.name}</h1> <h1 class="text-3xl">{selectedPlayerName}</h1>
{#await permsFuture} {#await permsFuture}
<Spinner></Spinner> <Spinner></Spinner>
{:then perms} {:then perms}
@@ -149,39 +166,27 @@
{:then player} {:then player}
<h1>Prefix</h1> <h1>Prefix</h1>
{#each Object.entries(perms.prefixes) as [key, prefix]} {#each Object.entries(perms.prefixes) as [key, prefix]}
<Radio name="prefix" bind:group={prefixEdit} <Radio name="prefix" bind:group={prefixEdit} value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
{/each} {/each}
<h1>Permissions</h1> <h1>Permissions</h1>
{#each perms.perms as perm} {#each perms.perms as perm}
<Checkbox checked={activePerms.includes(perm)} <Checkbox checked={activePerms.includes(perm)} onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
{/each} {/each}
<div class="mt-4"> <div class="mt-4">
<Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} <Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} onclick={save}>Save</Button>
onclick={save}>Save
</Button>
{#if $me != null && $me.perms.includes("ADMINISTRATION")} {#if $me != null && $me.perms.includes("ADMINISTRATION")}
<Button onclick={() => resetPasswordModal = true}> <Button onclick={() => (resetPasswordModal = true)}>Reset Password</Button>
Reset Password
</Button>
<SWModal bind:open={resetPasswordModal} title="Reset Password"> <SWModal bind:open={resetPasswordModal} title="Reset Password">
<Label for="new_password">New Password</Label> <Label for="new_password">New Password</Label>
<Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword}/> <Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword} />
<Label for="repeat_password">Repeat Password</Label> <Label for="repeat_password">Repeat Password</Label>
<Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat}/> <Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat} />
{#snippet footer()} {#snippet footer()}
<Button class="ml-auto mr-4" onclick={resetResetPassword}>Cancel</Button>
<Button class="ml-auto mr-4" onclick={resetResetPassword}> <Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>Reset Password</Button>
Cancel {/snippet}
</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>
Reset Password
</Button>
{/snippet}
</SWModal> </SWModal>
{/if} {/if}
</div> </div>
@@ -1,312 +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/>.
-->
<script lang="ts">
import type {EventFight, ExtendedEvent} from "@type/event.ts";
import {
Button,
Checkbox, Input, Label,
Modal,
SpeedDial,
SpeedDialButton,
Toolbar,
ToolbarButton,
ToolbarGroup,
Tooltip
} from "flowbite-svelte";
import {
ArrowsRepeatOutline, CalendarWeekOutline,
PlusOutline, ProfileCardOutline, TrashBinOutline, UsersGroupOutline,
} from "flowbite-svelte-icons";
import FightCard from "./FightCard.svelte";
import CreateFightModal from "./modals/CreateFightModal.svelte";
import {groups, players} from "@stores/stores.ts";
import TypeAheadSearch from "../../components/TypeAheadSearch.svelte";
import {fightRepo, type UpdateFight} from "@repo/fight.ts";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
interface Props {
data: ExtendedEvent;
}
let { data = $bindable() }: Props = $props();
let createOpen = $state(false);
let fights = $state(data.fights);
let selectedFights: Set<EventFight> = $state(new Set());
let groupsMap = $derived(new Set(fights.map(fight => fight.group)));
let groupedFights = $derived(Array.from(groupsMap).map(group => {
return {
group: group,
fights: fights.filter(fight => fight.group === group)
};
}));
function cycleSelect() {
if (selectedFights.size === fights.length) {
selectedFights = new Set();
} else if (selectedFights.size === 0) {
selectedFights = new Set(fights.filter(fight => fight.start > Date.now()));
if (selectedFights.size === 0) {
selectedFights = new Set(fights);
}
} else {
selectedFights = new Set(fights);
}
}
function cycleGroup(groupFights: EventFight[]) {
if (groupFights.every(gf => selectedFights.has(gf))) {
groupFights.forEach(fight => selectedFights.delete(fight));
} else {
groupFights.forEach(fight => selectedFights.add(fight));
}
selectedFights = new Set(selectedFights);
}
let deleteOpen = $state(false);
async function deleteFights() {
for (const fight of selectedFights) {
await $fightRepo.deleteFight(fight.id);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
deleteOpen = false;
}
let spectatePortOpen = $state(false);
let selectPlayers = $derived($players.map(player => {
return {
name: player.name,
value: player.uuid
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let spectatePort = $state("");
async function updateSpectatePort() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: null,
spectatePort: Number.parseInt(spectatePort),
map: null,
redTeam: null,
spielmodus: null,
start: null
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
spectatePort = "";
spectatePortOpen = false;
}
let groupChangeOpen = $state(false);
let group = $state("");
let groupSearch = $state("");
let selectableGroups = $derived([{
name: "Keine",
value: ""
}, {
value: groupSearch,
name: `Erstelle: '${groupSearch}'`
}, ...$groups.map(group => {
return {
name: group,
value: group
};
}).sort((a, b) => a.name.localeCompare(b.name))]);
async function updateGroup() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: group,
spectatePort: null,
map: null,
redTeam: null,
spielmodus: null,
start: null
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
group = "";
groupSearch = "";
groupChangeOpen = false;
}
let minTime = $derived(dayjs(Math.min(...fights.map(fight => fight.start))).utc(true));
let changeTimeOpen = $state(false);
let changedTime = $state(fights.length != 0 ? dayjs(Math.min(...fights.map(fight => fight.start)))?.utc(true)?.toISOString()?.slice(0, -1) : undefined);
let deltaTime = $derived(dayjs.duration(dayjs(changedTime).utc(true).diff(minTime)));
async function updateStartTime() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: null,
spectatePort: null,
map: null,
redTeam: null,
spielmodus: null,
start: dayjs(fight.start).add(deltaTime.asMilliseconds(), "millisecond")
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
changedTime = minTime.toISOString().slice(0, -1);
selectedFights = new Set();
changeTimeOpen = false;
}
</script>
<svelte:head>
<title>{data.event.name} - Fights</title>
</svelte:head>
<div class="pb-28">
<Toolbar class="mx-4 mt-2 w-fit">
<ToolbarGroup>
<Checkbox class="ml-2" checked={selectedFights.size === fights.length} onclick={cycleSelect}/>
<Tooltip>Select Upcoming</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarButton onclick={() => selectedFights.size > 0 ? changeTimeOpen = true : changeTimeOpen = false}>
<CalendarWeekOutline/>
</ToolbarButton>
<Tooltip>Reschedule Fights</Tooltip>
<ToolbarButton onclick={() => selectedFights.size > 0 ? spectatePortOpen = true : spectatePortOpen = false}
disabled={changedTime === undefined}>
<ProfileCardOutline/>
</ToolbarButton>
<Tooltip>Change Spectate Port</Tooltip>
<ToolbarButton onclick={() => selectedFights.size > 0 ? groupChangeOpen = true : groupChangeOpen = false}>
<UsersGroupOutline/>
</ToolbarButton>
<Tooltip>Change Group</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarButton color="red"
onclick={() => selectedFights.size > 0 ? deleteOpen = true : deleteOpen = false}>
<TrashBinOutline/>
</ToolbarButton>
<Tooltip>Delete</Tooltip>
</ToolbarGroup>
</Toolbar>
{#each groupedFights as group}
<div class="flex mt-4">
<Checkbox class="ml-2 text-center" checked={group.fights.every(gf => selectedFights.has(gf))}
onclick={() => cycleGroup(group.fights)}/>
<h1 class="ml-4 text-2xl">{group.group ?? "Ungrouped"}</h1>
</div>
{#each group.fights.sort((a, b) => a.start - b.start) as fight, i (fight.id)}
{@const isSelected = selectedFights.has(fight)}
<FightCard {fight} {i} {data} selected={isSelected}
select={() => {
if (selectedFights.has(fight)) {
selectedFights.delete(fight);
} else {
selectedFights.add(fight);
}
selectedFights = new Set(selectedFights);
}} update={async () => fights = await $fightRepo.listFights(data.event.id)}
/>
{/each}
{/each}
</div>
<CreateFightModal {data} bind:open={createOpen}
on:create={async () => data.fights = await $fightRepo.listFights(data.event.id)}></CreateFightModal>
<Modal bind:open={deleteOpen} title="Delete {selectedFights.size} Fights" autoclose size="sm">
<p>Are you sure you want to delete {selectedFights.size} fights?</p>
{#snippet footer()}
<Button color="red" class="ml-auto" onclick={deleteFights}>Delete</Button>
<Button onclick={() => deleteOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<Modal bind:open={spectatePortOpen} title="Change Kampfleiter" size="sm">
<div class="m-2">
<Label for="fight-kampf">Kampfleiter</Label>
<TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch>
</div>
{#snippet footer()}
<Button class="ml-auto" onclick={updateSpectatePort}>Change</Button>
<Button onclick={() => spectatePortOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2">
<Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch}
all></TypeAheadSearch>
</div>
{#snippet footer()}
<Button class="ml-auto" onclick={updateGroup}>Change</Button>
<Button onclick={() => groupChangeOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<Modal bind:open={changeTimeOpen} title="Change Start Time" size="sm">
<div class="m-2">
<Label for="fight-start">New Start Time:</Label>
<Input id="fight-start" bind:value={changedTime} >
{#snippet children({ props })}
<input type="datetime-local" {...props} bind:value={changedTime}/>
{/snippet}
</Input>
</div>
<p>{deltaTime.asMilliseconds() < 0 ? '' : '+'}{("0" + deltaTime.hours()).slice(-2)}
:{("0" + deltaTime.minutes()).slice(-2)}</p>
{#snippet footer()}
<Button class="ml-auto" onclick={updateStartTime}>Update</Button>
<Button onclick={() => changeTimeOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<SpeedDial>
<SpeedDialButton name="Add" onclick={() => createOpen = true}>
<PlusOutline/>
</SpeedDialButton>
<SpeedDialButton name="Generate" href="#/event/{data.event.id}/generate">
<ArrowsRepeatOutline/>
</SpeedDialButton>
</SpeedDial>
@@ -18,20 +18,19 @@
--> -->
<script lang="ts"> <script lang="ts">
import type {ExtendedEvent} from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import {Button} from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import {PlusOutline} from "flowbite-svelte-icons"; import { PlusOutline } from "flowbite-svelte-icons";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import TypeAheadSearch from "@components/admin/components/TypeAheadSearch.svelte"; import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {players} from "@stores/stores.ts"; import { eventRepo } from "@repo/event.ts";
import {eventRepo} from "@repo/event.ts";
interface Props { interface Props {
data: ExtendedEvent; data: ExtendedEvent;
} }
let { data }: Props = $props(); let { data }: Props = $props();
let searchValue = $state(""); let searchValue = $state("");
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
@@ -42,17 +41,19 @@
async function addReferee() { async function addReferee() {
if (selectedPlayer) { if (selectedPlayer) {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
deadline: null, await $eventRepo.updateEvent(data.event.id.toString(), {
end: null, deadline: null,
maxTeamMembers: null, end: null,
name: null, maxTeamMembers: null,
publicSchemsOnly: null, name: null,
removeReferee: null, publicSchemsOnly: null,
schemType: null, removeReferee: null,
start: null, schemType: null,
addReferee: [selectedPlayer] start: null,
})).referees; addReferee: [selectedPlayer],
})
).referees;
} }
reset(); reset();
@@ -60,18 +61,20 @@
function removeReferee(id: string) { function removeReferee(id: string) {
return async () => { return async () => {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
deadline: null, await $eventRepo.updateEvent(data.event.id.toString(), {
end: null, deadline: null,
maxTeamMembers: null, end: null,
name: null, maxTeamMembers: null,
publicSchemsOnly: null, name: null,
addReferee: null, publicSchemsOnly: null,
schemType: null, addReferee: null,
start: null, schemType: null,
removeReferee: [id], start: null,
})).referees; removeReferee: [id],
} })
).referees;
};
} }
function reset() { function reset() {
@@ -84,9 +87,7 @@
{#each referees as referee} {#each referees as referee}
<li class="flex flex-grow justify-between"> <li class="flex flex-grow justify-between">
{referee.name} {referee.name}
<SWButton onclick={removeReferee(referee.uuid)}> <SWButton onclick={removeReferee(referee.uuid)}>Entfernen</SWButton>
Entfernen
</SWButton>
</li> </li>
{/each} {/each}
@@ -95,23 +96,22 @@
{/if} {/if}
</ul> </ul>
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => showAdd = true}> <Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => (showAdd = true)}>
<PlusOutline/> <PlusOutline />
</Button> </Button>
<SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}> <SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}>
<div class="flex flex-grow justify-center h-80"> <div class="flex flex-grow justify-center h-80">
<div> <div>
<TypeAheadSearch bind:searchValue bind:selected={selectedPlayer} <PlayerSelector bind:value={selectedPlayer} placeholder="Search player..." />
items={$players.map(v => ({ name: v.name, value: v.uuid }))}/>
</div> </div>
</div> </div>
{#snippet footer()} {#snippet footer()}
<div class="flex flex-grow justify-end"> <div class="flex flex-grow justify-end">
<SWButton onclick={reset} type="gray">Abbrechen</SWButton> <SWButton onclick={reset} type="gray">Abbrechen</SWButton>
<SWButton onclick={addReferee}>Hinzufügen</SWButton> <SWButton onclick={addReferee}>Hinzufügen</SWButton>
</div> </div>
{/snippet} {/snippet}
</SWModal> </SWModal>
<style> <style>
@@ -18,12 +18,14 @@
--> -->
<script lang="ts"> <script lang="ts">
import {createEventDispatcher} from "svelte"; import { Card } from "@components/ui/card";
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props(); interface Props {
children?: import("svelte").Snippet;
ondrop: (event: DragEvent) => void;
}
let { children, ondrop }: Props = $props();
let dragover = $state(false); let dragover = $state(false);
@@ -32,19 +34,16 @@
dragover = true; dragover = true;
} }
const dispatch = createEventDispatcher();
function handleDrop(ev: DragEvent) { function handleDrop(ev: DragEvent) {
ev.preventDefault(); ev.preventDefault();
dragover = false; dragover = false;
dispatch("drop", ev); ondrop(ev);
} }
</script> </script>
<div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} ondrop={handleDrop} <Card class="w-56 p-4 rounded m-px {dragover ? 'border-white' : ''}" ondrop={handleDrop} ondragover={handleDragOver} ondragleave={() => (dragover = false)} role="none">
ondragover={handleDragOver} ondragleave={() => dragover = false} role="none">
{@render children?.()} {@render children?.()}
</div> </Card>
<style> <style>
div { div {
@@ -18,28 +18,28 @@
--> -->
<script lang="ts"> <script lang="ts">
import { createBubbler } from 'svelte/legacy'; import type { Team } from "@type/team.ts";
import { brightness, colorFromTeam, lighten } from "../../util";
const bubble = createBubbler(); interface Props {
import type {Team} from "@type/team.ts"; team: Team;
import {brightness, colorFromTeam, lighten} from "../../util"; ondragstart: (event: DragEvent) => void;
}
interface Props { let { team, ondragstart }: Props = $props();
team: Team;
}
let { team }: Props = $props();
let hover = $state(false); let hover = $state(false);
</script> </script>
<div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center" <div
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)} class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
class:text-black={brightness(colorFromTeam(team))} draggable="true" style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
ondragstart={bubble('dragstart')} class:text-black={brightness(colorFromTeam(team))}
onmouseenter={() => hover = true} draggable="true"
onmouseleave={() => hover = false} {ondragstart}
role="figure"> onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
role="figure"
>
<span>{team.name}</span> <span>{team.name}</span>
</div> </div>
+1 -1
View File
@@ -52,7 +52,7 @@
/> />
</figure> </figure>
</Card> </Card>
<div class="flex flex-wrap"> <div class="flex flex-wrap gap-2">
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button> <button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
{#if user.perms.includes("MODERATION")} {#if user.perms.includes("MODERATION")}
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a> <a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import dayjs from "dayjs";
import "dayjs/locale/de";
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import * as Card from "../ui/card";
import EventCard from "./EventCard.svelte";
import SWButton from "@components/styled/SWButton.svelte";
const {
events,
}: {
events: { slug: string; data: { event: ExtendedEvent } }[];
} = $props();
let currentYear = $state(dayjs().year());
// Group events by month
let eventsByMonth = $derived.by(() => {
const grouped = new Map<string, typeof events>();
events.forEach((event) => {
const eventDate = dayjs(event.data.event.event.start).locale("de");
if (eventDate.year() === currentYear) {
const monthKey = eventDate.format("YYYY-MM");
if (!grouped.has(monthKey)) {
grouped.set(monthKey, []);
}
grouped.get(monthKey)!.push(event);
}
});
return grouped;
});
// Generate all 12 months for the current year
let months = $derived.by(() => {
return Array.from({ length: 12 }, (_, i) => {
const monthDate = dayjs().locale("de").year(currentYear).month(i);
const monthKey = monthDate.format("YYYY-MM");
return {
date: monthDate,
key: monthKey,
name: monthDate.format("MMMM"),
events: eventsByMonth.get(monthKey) || [],
};
});
});
function prevYear() {
currentYear = currentYear - 1;
}
function nextYear() {
currentYear = currentYear + 1;
}
</script>
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-white">
{currentYear}
</h2>
<div class="flex gap-2">
<SWButton onclick={prevYear} type="gray">
<ChevronLeft size={20} />
</SWButton>
<SWButton onclick={nextYear} type="gray">
<ChevronRight size={20} />
</SWButton>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each months as month}
<EventCard title={month.name} unsized={true}>
{#if month.events.length > 0}
{#each month.events as event}
<a href={`/events/${event.slug}/`} class="block p-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-600 transition-colors group">
<div class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</div>
<div class="text-xs text-gray-400 mt-1">
{dayjs(event.data.event.event.start).format("MMM D, YYYY • HH:mm")}
</div>
</a>
{/each}
{:else}
<p class="text-gray-500 text-sm italic">Keine Events für diesen Monat</p>
{/if}
</EventCard>
{/each}
</div>
</div>
@@ -0,0 +1,109 @@
<script lang="ts">
import { fightConnector } from "./connections.svelte";
import { onMount, onDestroy } from "svelte";
let root: HTMLElement | null = null;
let refresh = $state(0);
function handleScroll() {
refresh++;
}
function getScrollableParent(el: HTMLElement | null): HTMLElement | null {
let node: HTMLElement | null = el?.parentElement ?? null;
while (node) {
const style = getComputedStyle(node);
const canScrollX = (style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth;
const canScrollY = (style.overflowY === "auto" || style.overflowY === "scroll") && node.scrollHeight > node.clientHeight;
if (canScrollX || canScrollY) return node;
node = node.parentElement;
}
return null;
}
let cleanup: (() => void) | null = null;
onMount(() => {
const scrollParent = getScrollableParent(root);
const target: EventTarget | null = scrollParent ?? window;
target?.addEventListener("scroll", handleScroll, { passive: true } as AddEventListenerOptions);
window.addEventListener("resize", handleScroll, { passive: true });
cleanup = () => {
target?.removeEventListener?.("scroll", handleScroll as EventListener);
window.removeEventListener("resize", handleScroll as EventListener);
};
});
onDestroy(() => {
cleanup?.();
cleanup = null;
});
</script>
<div bind:this={root} class="connection-renderer-root">
{#key refresh}
{#each $fightConnector.showedConnections as connection}
{@const fromLeft = connection.fromElement.offsetLeft + connection.fromElement.offsetWidth}
{@const toLeft = connection.toElement.offsetLeft}
{@const fromTop = connection.fromElement.offsetTop + connection.fromElement.offsetHeight / 2}
{@const toTop = connection.toElement.offsetTop + connection.toElement.offsetHeight / 2}
{@const horizontalDistance = toLeft - fromLeft}
{@const verticalDistance = toTop - fromTop}
<!-- Apply horizontal offset only to the mid bridge and second segment fan-out; also shift vertical line to keep continuity -->
{@const midLeft = fromLeft + horizontalDistance / 2 + connection.offset}
{@const firstSegmentWidth = horizontalDistance / 2}
{#if firstSegmentWidth > 0}
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {fromLeft}px;
top: {fromTop + connection.offset / 4}px;
width: {firstSegmentWidth + connection.offset + 2}px;
"
></div>
<div
class="vertical-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {Math.min(fromTop + connection.offset / 4, toTop + connection.offset / 4)}px;
height: {Math.abs(toTop + connection.offset / 4 - (fromTop + connection.offset / 4))}px;
"
></div>
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {toTop + connection.offset / 4}px;
width: {firstSegmentWidth - connection.offset}px;
"
></div>
{/if}
{/each}
{/key}
</div>
<style>
.connection-renderer-root {
position: static;
pointer-events: none;
}
.vertical-line {
position: absolute;
width: 2px;
z-index: -10;
pointer-events: none;
}
.horizontal-line {
position: absolute;
height: 2px;
z-index: -10;
pointer-events: none;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import type {
ExtendedEvent,
EventFight,
ResponseGroups,
ResponseRelation,
} from "@type/event.ts";
import type { DoubleEleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { fightConnector } from "./connections.svelte.ts";
const {
event,
config,
}: { event: ExtendedEvent; config: DoubleEleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Double Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function indexRelations(
ev: ExtendedEvent,
): Map<number, ResponseRelation[]> {
const map = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = map.get(rel.fight) ?? [];
list.push(rel);
map.set(rel.fight, list);
}
return map;
}
const relationsByFight = indexRelations(event);
const fightMap = new Map<number, EventFight>(
event.fights.map((f) => [f.id, f]),
);
function collectBracket(startFinalId: number): EventFight[][] {
const finalFight = fightMap.get(startFinalId);
if (!finalFight) return [];
const bracketGroupId = finalFight.group?.id ?? null;
const stages: EventFight[][] = [];
let layer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (layer.length) {
stages.push(layer);
const next: EventFight[] = [];
for (const fight of layer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src =
fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (!src) continue;
// Only traverse within the same bracket (group) to avoid cross-bracket pollution
if (
bracketGroupId !== null &&
src.group?.id !== bracketGroupId
)
continue;
if (!visited.has(src.id)) {
visited.add(src.id);
next.push(src);
}
}
}
}
layer = next;
}
stages.reverse();
return stages;
}
const winnersStages = $derived(collectBracket(config.winnersFinalFight));
const losersStages = $derived(collectBracket(config.losersFinalFight));
const grandFinal = fightMap.get(config.grandFinalFight);
function stageName(count: number, isWinners: boolean): string {
switch (count) {
case 1:
return isWinners ? "Finale (W)" : "Finale (L)";
case 2:
return isWinners ? "Halbfinale (W)" : "Halbfinale (L)";
case 4:
return isWinners ? "Viertelfinale (W)" : "Viertelfinale (L)";
case 8:
return isWinners ? "Achtelfinale (W)" : "Achtelfinale (L)";
default:
return `Runde (${count}) ${isWinners ? "W" : "L"}`;
}
}
let connector: any;
const unsubscribe = fightConnector.subscribe((v) => (connector = v));
onDestroy(() => {
connector.clearAllConnections();
unsubscribe();
});
function buildConnections() {
if (!connector) return;
connector.clearAllConnections();
// Track offsets per source fight and team to stagger multiple outgoing lines for visual clarity
const fightTeamOffsetMap = new Map<string, number>();
const step = 8; // px separation between parallel lines
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromId = rel.fromFight.id;
const fromEl = document.getElementById(
`fight-${fromId}`,
) as HTMLElement | null;
const toEl = document.getElementById(
`fight-${rel.fight}-team-${rel.team.toLowerCase()}`,
) as HTMLElement | null;
if (!fromEl || !toEl) continue;
// Use team-signed offsets so BLUE goes left (negative), RED goes right (positive)
const key = `${fromId}:${rel.team}`;
const index = fightTeamOffsetMap.get(key) ?? 0;
const sign = rel.team === "BLUE" ? -1 : 1;
const offset = sign * (index + 1) * step;
const color = rel.fromPlace === 0 ? "#60a5fa" : "#f87171";
connector.addConnection(fromEl, toEl, color, offset);
fightTeamOffsetMap.set(key, index + 1);
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if !grandFinal}
<p class="text-gray-400 italic">
Konfiguration unvollständig (Grand Final fehlt).
</p>
{:else}
{#key winnersStages.length + ":" + losersStages.length}
<!-- Build a grid where rows: winners (stages), losers (stages), with losers offset by one stage/column -->
{@const totalColumns =
Math.max(winnersStages.length, losersStages.length + 1) + 1}
<div
class="grid gap-x-16 gap-y-6 items-start"
style={`grid-template-columns: repeat(${totalColumns}, max-content);`}
>
<!-- Winners heading spans all columns -->
<h2 class="font-bold text-center">Winners Bracket</h2>
<!-- Winners stages in row 2 -->
{#each winnersStages as stage, i}
<div style={`grid-row: 2; grid-column: ${i + 1};`}>
<EventCard title={stageName(stage.length, true)}>
{#each stage as fight}
<EventFightChip
{event}
{fight}
group={fight.group ?? defaultGroup}
/>
{/each}
</EventCard>
</div>
{/each}
<!-- Place Grand Final at the far right, aligned with winners row -->
<div
style={`grid-row: 2; grid-column: ${totalColumns};`}
class="self-center"
>
<EventCard title="Grand Final">
{#if grandFinal}
<EventFightChip
{event}
fight={grandFinal}
group={grandFinal.group ?? defaultGroup}
/>
{/if}
</EventCard>
</div>
<!-- Losers heading spans all columns -->
<h2
class="font-bold text-center"
style="grid-row: 3; grid-column: 1 / {totalColumns};"
>
Losers Bracket
</h2>
<!-- Losers stages in row 4, offset by one column to the right -->
{#each losersStages as stage, j}
<div style={`grid-row: 4; grid-column: ${j + 2};`} class="mt-2">
<EventCard title={stageName(stage.length, false)}>
{#each stage as fight}
<EventFightChip
{event}
{fight}
group={fight.group ?? defaultGroup}
/>
{/each}
</EventCard>
</div>
{/each}
</div>
{/key}
{/if}
@@ -0,0 +1,142 @@
<script lang="ts">
import type {
ExtendedEvent,
EventFight,
ResponseGroups,
ResponseRelation,
} from "@type/event.ts";
import type { EleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { FightConnector, fightConnector } from "./connections.svelte.ts";
const {
event,
config,
}: { event: ExtendedEvent; config: EleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function buildStages(
ev: ExtendedEvent,
finalFightId: number,
): EventFight[][] {
const fightMap = new Map<number, EventFight>(
ev.fights.map((f) => [f.id, f]),
);
const relationsByFight = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = relationsByFight.get(rel.fight) ?? [];
list.push(rel);
relationsByFight.set(rel.fight, list);
}
const finalFight = fightMap.get(finalFightId);
if (!finalFight) return [];
const stages: EventFight[][] = [];
let currentLayer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (currentLayer.length) {
stages.push(currentLayer);
const nextLayer: EventFight[] = [];
for (const fight of currentLayer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src =
fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (src && !visited.has(src.id)) {
visited.add(src.id);
nextLayer.push(src);
}
}
}
}
currentLayer = nextLayer;
}
stages.reverse();
return stages;
}
function stageName(index: number, fights: EventFight[]): string {
const count = fights.length;
switch (count) {
case 1:
return `Finale`;
case 2:
return "Halbfinale";
case 4:
return "Viertelfinale";
case 8:
return "Achtelfinale";
case 16:
return "Sechzehntelfinale";
default:
return `Runde ${index + 1}`;
}
}
const stages = $derived(buildStages(event, config.finalFight));
const connector = $fightConnector;
onDestroy(() => {
connector.clearAllConnections();
});
function buildConnections() {
if (!connector) return;
connector.clearConnections();
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromEl = document.getElementById(
`fight-${rel.fromFight.id}`,
) as HTMLElement | null;
const toEl = document.getElementById(
`fight-${rel.fight}-team-${rel.team.toLowerCase()}`,
) as HTMLElement | null;
if (fromEl && toEl) {
connector.addConnection(fromEl, toEl, "#9ca3af");
}
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if stages.length === 0}
<p class="text-gray-400 italic">Keine Eliminationsdaten gefunden.</p>
{:else}
<div class="flex gap-12">
{#each stages as stage, index}
<div class="flex flex-col justify-center">
<EventCard title={stageName(index, stage)}>
{#each stage as fight}
<EventFightChip
{event}
{fight}
group={fight.group ?? defaultGroup}
/>
{/each}
</EventCard>
</div>
{/each}
</div>
{/if}
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
title,
children,
unsized = false,
}: {
title: string;
children: Snippet;
unsized?: boolean;
} = $props();
</script>
<div class="flex flex-col gap-1 {unsized ? '' : 'w-72 m-4'}">
<div class="bg-amber-500 text-black font-bold px-2 uppercase text-xs tracking-wider" style="font-family: 'Barlow Condensed', sans-serif;">
{title}
</div>
<div class="border border-gray-600 rounded p-2 flex flex-col gap-2 bg-slate-900">
{@render children()}
</div>
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
children,
}: {
children: Snippet;
} = $props();
</script>
<div class="bg-neutral-900 border border-gray-700 rounded-lg overflow-hidden">
{@render children()}
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups } from "@components/types/event";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
let {
fight,
group,
event,
disabled = false,
}: {
fight: EventFight;
group: ResponseGroups;
event: ExtendedEvent;
disabled?: boolean;
} = $props();
function getScore(group: ResponseGroups, fight: EventFight, blueTeam: boolean): string {
if (!fight.hasFinished) return "-";
if (fight.ergebnis === 1) {
return blueTeam ? group.pointsPerWin.toString() : group.pointsPerLoss.toString();
} else if (fight.ergebnis === 2) {
return blueTeam ? group.pointsPerLoss.toString() : group.pointsPerWin.toString();
} else {
return group.pointsPerDraw.toString();
}
}
</script>
<EventCardOutline>
<EventTeamChip
team={{
id: -1,
kuerzel: new Date(fight.start).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
name: new Date(fight.start).toLocaleDateString([]),
color: "-1",
}}
time={true}
{event}
/>
<div id={"fight-" + fight.id}>
<EventTeamChip
{event}
{disabled}
team={fight.blueTeam}
score={getScore(group, fight, true)}
showWinner={true}
isWinner={fight.ergebnis === 1}
noWinner={fight.ergebnis === 0}
id="fight-{fight.id}-team-blue"
/>
<EventTeamChip
{event}
{disabled}
team={fight.redTeam}
score={getScore(group, fight, false)}
showWinner={true}
isWinner={fight.ergebnis === 2}
noWinner={fight.ergebnis === 0}
id="fight-{fight.id}-team-red"
/>
</div>
</EventCardOutline>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { ExtendedEvent } from "@type/event.ts";
import type { EventViewConfig } from "./types";
import { onMount } from "svelte";
import { eventRepo } from "@components/repo/event";
import GroupDisplay from "./GroupDisplay.svelte";
import ConnectionRenderer from "./ConnectionRenderer.svelte";
import EleminationDisplay from "./EleminationDisplay.svelte";
import DoubleEleminationDisplay from "./DoubleEleminationDisplay.svelte";
const { event, viewConfig }: { event: ExtendedEvent; viewConfig: EventViewConfig } = $props();
let loadedEvent = $state<ExtendedEvent>(event);
onMount(() => {
loadEvent();
});
async function loadEvent() {
loadedEvent = await $eventRepo.getEvent(event.event.id.toString());
}
let selectedView = $state<string>(Object.keys(viewConfig)[0]);
</script>
<div class="flex gap-4 overflow-x-auto mb-4">
{#each Object.entries(viewConfig) as [name, view]}
<button
class="mb-8 border-gray-700 border rounded-lg p-4 w-60 hover:bg-gray-700 hover:shadow-lg transition-shadow hover:border-gray-500"
class:bg-gray-800={selectedView === name}
onclick={() => (selectedView = name)}
>
<h1 class="text-left">{view.name}</h1>
</button>
{/each}
</div>
{#if selectedView}
{@const view = viewConfig[selectedView]}
<div class="overflow-x-scroll relative">
<ConnectionRenderer />
{#if view.view.type === "GROUP"}
<GroupDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "ELEMINATION"}
<EleminationDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "DOUBLE_ELEMINATION"}
<DoubleEleminationDisplay event={loadedEvent} config={view.view} />
{/if}
</div>
{/if}
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import dayjs from "dayjs";
import * as Card from "../ui/card";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
// Categorize events into current, upcoming and past.
const now = dayjs();
const sorted = [...events].sort((a, b) => a.data.event.event.start - b.data.event.event.start);
const currentEvents = sorted
.filter((e) => {
const start = dayjs(e.data.event.event.start);
const end = dayjs(e.data.event.event.end);
return start.isBefore(now) && end.isAfter(now);
})
.sort((a, b) => a.data.event.event.end - b.data.event.event.end);
const currentEvent = currentEvents[0];
const upcomingEvents = sorted.filter((e) => dayjs(e.data.event.event.start).isAfter(now));
const pastEvents = sorted.filter((e) => dayjs(e.data.event.event.end).isBefore(now)).sort((a, b) => b.data.event.event.end - a.data.event.event.end);
</script>
{#if currentEvent}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Aktuelles Event</h2>
<div class="grid grid-cols-1">
<a href={`/events/${currentEvent.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(currentEvent.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{currentEvent.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Läuft seit {dayjs(currentEvent.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
</div>
</div>
{/if}
{#if upcomingEvents.length}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Bevorstehende Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each upcomingEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Startet um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}
{#if pastEvents.length}
<div class="mb-4">
<h2 class="text-xl font-semibold text-white mb-4">Vergangene Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 opacity-70">
{#each pastEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Stattgefunden um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { Calendar } from "lucide-svelte";
import { List } from "lucide-svelte";
import EventList from "./EventList.svelte";
import CalendarView from "./Calendar.svelte";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
let viewMode = $state<"list" | "calendar">("list");
</script>
<div class="flex flex-col gap-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-white">Events</h1>
</div>
<CalendarView {events} />
<EventList {events} />
</div>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { Team } from "@type/team.ts";
import { teamHoverService } from "./team-hover.svelte";
import { Sheet, SheetContent, SheetTrigger } from "@components/ui/sheet";
import TeamInfo from "./TeamInfo.svelte";
import type { ExtendedEvent } from "@components/types/event";
const {
team,
event,
score = "",
time = false,
showWinner = false,
isWinner = false,
noWinner = false,
id,
disabled = false,
}: {
team: Team;
event: ExtendedEvent;
score?: string;
time?: boolean;
showWinner?: boolean;
isWinner?: boolean;
noWinner?: boolean;
id?: string;
disabled?: boolean;
} = $props();
let hoverService = $teamHoverService;
type StringAnyRecord = Record<string, any>;
</script>
{#if !disabled}
<Sheet>
<SheetTrigger>
{#snippet child({ props })}
{@render teamButton({ props })}
{/snippet}
</SheetTrigger>
<SheetContent>
<TeamInfo {team} {event} />
</SheetContent>
</Sheet>
{:else}
{@render teamButton({ props: {} })}
{/if}
{#snippet teamButton({ props }: { props: StringAnyRecord })}
<button
{...props}
class="flex justify-between px-2 w-full team-chip text-left border-b border-b-gray-700 last:border-b-0 {time ? 'py-1 hover:bg-gray-800' : 'py-3 cursor-pointer'} team-{disabled
? -1
: team.id} {hoverService.currentHover === team.id ? 'bg-gray-800' : ''} {showWinner ? 'border-l-4' : ''} {showWinner && isWinner ? 'border-l-yellow-500' : 'border-l-gray-950'}"
onmouseenter={() => team.id === -1 || hoverService.setHover(team.id)}
onmouseleave={() => team.id === -1 || hoverService.clearHover()}
{id}
>
<div class="flex">
<div class="w-12 {time ? 'font-bold' : ''}">
{team.kuerzel}
</div>
<span class={time ? "font-mono" : "font-bold"}>{team.name}</span>
</div>
<div class="{showWinner && isWinner && 'font-bold'} {isWinner ? 'text-yellow-400' : ''} {noWinner ? 'font-bold' : ''}">
{score}
</div>
</button>
{/snippet}
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event.ts";
import type { GroupViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { teamHoverService } from "./team-hover.svelte";
const {
event,
config,
}: {
event: ExtendedEvent;
config: GroupViewConfig;
} = $props();
function detectRounds(fights: EventFight[], groupingTimeMinutes: number): EventFight[][] {
if (!fights || fights.length === 0) return [];
const groupingTimeMs = Math.max(1, Math.floor(groupingTimeMinutes || 10)) * 60 * 1000;
const sorted = [...fights].sort((a, b) => a.start - b.start);
const rounds: EventFight[][] = [];
let currentRound: EventFight[] = [];
let roundStart = sorted[0].start;
for (const fight of sorted) {
if (fight.start - roundStart <= groupingTimeMs) {
currentRound.push(fight);
} else {
if (currentRound.length) rounds.push(currentRound);
currentRound = [fight];
roundStart = fight.start;
}
}
if (currentRound.length) rounds.push(currentRound);
return rounds;
}
function chunkIntoRows<T>(items: T[], rowCount: number): T[][] {
if (!items || items.length === 0) return [];
const rows = Math.max(1, Math.floor(rowCount || 1));
const perRow = Math.ceil(items.length / rows);
const chunked: T[][] = [];
for (let i = 0; i < rows; i++) {
const slice = items.slice(i * perRow, (i + 1) * perRow);
if (slice.length) chunked.push(slice);
}
return chunked;
}
const hover = $teamHoverService;
</script>
{#each config.groups as groupId}
{@const group = event.groups.find((g) => g.id === groupId)!!}
{@const fights = event.fights.filter((f) => f.group?.id === groupId)}
{@const rounds = detectRounds(fights, config.roundGroupingTimeMinutes ?? 10)}
{@const roundRows = config.roundRows ?? 1}
{@const roundPrefix = config.roundPrefix ?? "Runde"}
{@const roundRowsChunked = chunkIntoRows(rounds, roundRows)}
<div class="flex">
<div>
<EventCard title={group.name}>
<EventCardOutline>
{#each Object.entries(group.points ?? {}).sort((v1, v2) => v2[1] - v1[1]) as points}
{@const [teamId, point] = points}
{@const team = event.teams.find((t) => t.id.toString() === teamId)!!}
<EventTeamChip {team} {event} score={point.toString()} />
{/each}
</EventCardOutline>
</EventCard>
</div>
<div class="flex flex-col">
{#each roundRowsChunked as row}
<div class="flex">
{#each row as round, index (round)}
{@const roundIndex = rounds.indexOf(round)}
{@const teams = Array.from(new Set(round.flatMap((f) => [f.redTeam, f.blueTeam])))}
<div class="{hover.currentHover && !teams.some((t) => t?.id === hover.currentHover) ? 'opacity-30' : ''} transition-opacity">
<EventCard title={`${roundPrefix} ${roundIndex + 1}`}>
{#each round as fight}
<EventFightChip {event} {fight} {group} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/each}
</div>
</div>
{/each}
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { dataRepo } from "@components/repo/data";
import type { ExtendedEvent, ResponseTeam } from "@components/types/event";
import EventFightChip from "./EventFightChip.svelte";
import SheetHeader from "@components/ui/sheet/sheet-header.svelte";
import { SheetDescription, SheetTitle } from "@components/ui/sheet";
const { event, team }: { event: ExtendedEvent; team: ResponseTeam } = $props();
let members = $derived.by(() => {
return fetchMembers(team.id);
});
let recentFights = $derived.by(() => {
return event.fights
.filter((f) => f.hasFinished && (f.blueTeam.id === team.id || f.redTeam.id === team.id))
.sort((a, b) => b.start - a.start)
.slice(0, 5);
});
async function fetchMembers(teamId: number) {
return await $dataRepo.queryPlayers(undefined, undefined, [teamId], 50, 0, false, false);
}
</script>
<SheetHeader>
<SheetTitle
>{team.name}
<span class="text-sm text-gray-400">{team.kuerzel}</span></SheetTitle
>
<SheetDescription>Statistiken des Teams</SheetDescription>
</SheetHeader>
<div class="mt-8 space-y-8">
<section>
<h3 class="text-lg font-semibold mb-4 border-b border-slate-800 pb-2 text-blue-400">Teammitglieder</h3>
{#await members}
<p class="text-slate-500 italic text-sm">Lade Mitglieder...</p>
{:then member}
<div class="grid grid-cols-2 gap-2">
{#each member.entries as p (p.uuid)}
<div class="bg-slate-800/50 p-2 rounded border border-slate-700 flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-slate-700 flex items-center justify-center text-[10px]">
{p.name.charAt(0)}
</div>
<span class="truncate text-sm">{p.name}</span>
</div>
{/each}
</div>
{/await}
</section>
<section>
<h3 class="text-lg font-semibold mb-4 border-b border-slate-800 pb-2 text-green-400">Letzte 5 Kämpfe</h3>
{#if recentFights.length > 0}
<div class="space-y-3">
{#each recentFights as fight}
<div class="scale-90 origin-left">
<EventFightChip
{event}
disabled={true}
{fight}
group={fight.group ?? {
id: -1,
name: "Event",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "GROUP_STAGE",
points: null,
}}
/>
</div>
{/each}
</div>
{:else}
<p class="text-slate-500 italic text-sm">Keine beendeten Kämpfe in diesem Event.</p>
{/if}
</section>
</div>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
const {
event,
}: {
event: ExtendedEvent;
} = $props();
let teams: Team[] = $state(event.teams);
const colorMap: Record<string, string> = {
"0": "#000000",
"1": "#0000AA",
"2": "#00AA00",
"3": "#00AAAA",
"4": "#AA0000",
"5": "#AA00AA",
"6": "#FFAA00",
"7": "#AAAAAA",
"8": "#555555",
"9": "#5555FF",
a: "#55FF55",
b: "#55FFFF",
c: "#FF5555",
d: "#FF55FF",
e: "#FFFF55",
f: "#FFFFFF",
};
onMount(async () => {
teams = await $eventRepo.listTeams(event.event.id.toString());
});
</script>
<div class="py-2 border-t border-t-gray-600">
<h1 class="text-2xl font-bold mb-4">Angemeldete Teams</h1>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2"
>
{#each teams as team}
<button
class="bg-neutral-800 p-2 rounded-md border border-neutral-700 border-l-4 flex flex-row items-center gap-2 cursor-pointer hover:bg-neutral-700 transition-colors w-full text-left"
style="border-left-color: {colorMap[team.color] || '#FFFFFF'}"
>
<span
class="text-sm font-mono text-neutral-400 shrink-0 w-8 text-center"
>{team.kuerzel}</span
>
<span class="font-bold truncate" title={team.name}>
{team.name}
</span>
</button>
{/each}
{#if teams.length === 0}
<p class="col-span-full text-center text-neutral-400">
Keine Teams angemeldet.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,55 @@
import { readonly, writable } from "svelte/store";
class FightConnection {
constructor(
public readonly fromElement: HTMLElement,
public readonly toElement: HTMLElement,
public readonly color: string = "white",
public readonly background: boolean,
public readonly offset: number = 0
) {}
}
export class FightConnector {
private connections: FightConnection[] = $state([]);
get allConnections(): FightConnection[] {
return this.connections;
}
get showedConnections(): FightConnection[] {
const showBackground = this.connections.some((conn) => !conn.background);
return showBackground ? this.connections.filter((conn) => !conn.background) : this.connections;
}
addTeamConnection(teamId: number): void {
const teamElements = document.getElementsByClassName(`team-${teamId}`);
const teamArray = Array.from(teamElements);
teamArray.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.top - rectB.top || rectA.left - rectB.left;
});
for (let i = 1; i < teamElements.length; i++) {
const fromElement = teamElements[i - 1] as HTMLElement;
const toElement = teamElements[i] as HTMLElement;
this.connections.push(new FightConnection(fromElement, toElement, "white", false));
}
}
addConnection(fromElement: HTMLElement, toElement: HTMLElement, color: string = "white", offset: number = 0): void {
this.connections.push(new FightConnection(fromElement, toElement, color, true, offset));
}
clearConnections(): void {
this.connections = this.connections.filter((conn) => conn.background);
}
clearAllConnections(): void {
this.connections = [];
}
}
const fightConnectorInternal = writable(new FightConnector());
export const fightConnector = readonly(fightConnectorInternal);
+21
View File
@@ -0,0 +1,21 @@
import { get, writable } from "svelte/store";
import { fightConnector } from "./connections.svelte";
class TeamHoverService {
public currentHover = $state<number | undefined>(undefined);
private fightConnector = get(fightConnector);
public disableConnections = $state(false);
setHover(teamId: number): void {
this.currentHover = teamId;
if (!this.disableConnections) this.fightConnector.addTeamConnection(teamId);
}
clearHover(): void {
this.currentHover = undefined;
if (!this.disableConnections) this.fightConnector.clearConnections();
}
}
export const teamHoverService = writable(new TeamHoverService());
+37
View File
@@ -0,0 +1,37 @@
import { z } from "astro:content";
export const GroupViewSchema = z.object({
type: z.literal("GROUP"),
groups: z.array(z.number()),
roundRows: z.number().int().positive().optional().default(1),
roundGroupingTimeMinutes: z.number().int().positive().optional().default(10),
roundPrefix: z.enum(["Runde", "Tag"]).optional().default("Runde"),
});
export type GroupViewConfig = z.infer<typeof GroupViewSchema>;
export const EleminationViewSchema = z.object({
type: z.literal("ELEMINATION"),
finalFight: z.number(),
});
export type EleminationViewConfig = z.infer<typeof EleminationViewSchema>;
// Double elimination config: needs final fight (grand final) and entry fights for winners & losers brackets
export const DoubleEleminationViewSchema = z.object({
type: z.literal("DOUBLE_ELEMINATION"),
winnersFinalFight: z.number(), // Final fight of winners bracket (feeds into grand final)
losersFinalFight: z.number(), // Final fight of losers bracket (feeds into grand final)
grandFinalFight: z.number(), // Grand final fight id
});
export type DoubleEleminationViewConfig = z.infer<typeof DoubleEleminationViewSchema>;
export const EventViewConfigSchema = z.record(
z.object({
name: z.string(),
view: z.discriminatedUnion("type", [GroupViewSchema, EleminationViewSchema, DoubleEleminationViewSchema]),
})
);
export type EventViewConfig = z.infer<typeof EventViewConfigSchema>;
+9 -8
View File
@@ -21,21 +21,23 @@
import type { RouteDefinition } from "svelte-spa-router"; import type { RouteDefinition } from "svelte-spa-router";
import Router from "svelte-spa-router"; import Router from "svelte-spa-router";
import NavLinks from "@components/moderator/layout/NavLinks.svelte"; import NavLinks from "@components/moderator/layout/NavLinks.svelte";
import { Switch } from "@components/ui/switch";
import { Label } from "@components/ui/label";
import { navigate } from "astro:transitions/client";
import Players from "@components/moderator/pages/players/Players.svelte"; import Players from "@components/moderator/pages/players/Players.svelte";
import Events from "@components/moderator/pages/events/Events.svelte"; import Events from "@components/moderator/pages/events/Events.svelte";
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte"; import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
import Event from "@components/moderator/pages/event/Event.svelte"; import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte"; import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte";
import AuditLog from "@components/moderator/pages/logs/AuditLog.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = { const routes: RouteDefinition = {
"/": Dashboard, "/": Dashboard,
"/events": Events, "/events": Events,
"/players": Players, "/players": Players,
"/event/:id": Event, "/event/:id": Event,
"/event/:id/generate": Generator,
"/pages": Pages, "/pages": Pages,
"/logs": AuditLog,
}; };
</script> </script>
@@ -44,11 +46,10 @@
<div class="flex h-16 items-center px-4"> <div class="flex h-16 items-center px-4">
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a> <a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
<NavLinks /> <NavLinks />
<div class="ml-auto flex items-center space-x-4">
<Switch id="new-ui-switch" checked={true} onclick={() => navigate("/admin")} />
<Label for="new-ui-switch">New UI!</Label>
</div>
</div> </div>
</div> </div>
<Router {routes} />
<Tooltip.Provider>
<Router {routes} />
</Tooltip.Provider>
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import GroupSelector from "./GroupSelector.svelte"; import GroupSelector from "./GroupSelector.svelte";
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event"; import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { fromAbsolute } from "@internationalized/date"; import { fromAbsolute } from "@internationalized/date";
import { Label } from "@components/ui/label"; import { Label } from "@components/ui/label";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte"; import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
@@ -11,46 +11,36 @@
import { ChevronsUpDown, Check } from "lucide-svelte"; import { ChevronsUpDown, Check } from "lucide-svelte";
import { Button } from "@components/ui/button"; import { Button } from "@components/ui/button";
import { cn } from "@components/utils"; import { cn } from "@components/utils";
import type { Team } from "@components/types/team";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { Input } from "@components/ui/input"; import { Input } from "@components/ui/input";
import TeamSelector from "./TeamSelector.svelte";
import type { EventModel } from "../pages/event/eventmodel.svelte";
let { let {
fight, fight,
teams,
event,
actions, actions,
onSave, onSave,
groups = $bindable(), data,
}: { }: {
fight: EventFight | null; fight: EventFight | null;
teams: Team[];
event: SWEvent;
groups: ResponseGroups[];
actions: Snippet<[boolean, () => void]>; actions: Snippet<[boolean, () => void]>;
onSave: (fight: EventFightEdit) => void; onSave: (fight: EventFightEdit) => void;
data: EventModel;
} = $props(); } = $props();
let fightModus = $state(fight?.spielmodus); let fightModus = $state(fight?.spielmodus);
let fightMap = $state(fight?.map); let fightMap = $state(fight?.map);
let fightBlueTeam = $state(fight?.blueTeam); let fightBlueTeam = $state(fight?.blueTeam);
let fightRedTeam = $state(fight?.redTeam); let fightRedTeam = $state(fight?.redTeam);
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(event.start, "Europe/Berlin")); let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(data.event.start, "Europe/Berlin"));
let fightErgebnis = $state(fight?.ergebnis ?? 0); let fightErgebnis = $state(fight?.ergebnis ?? 0);
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null); let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
let fightGroup = $state(fight?.group?.id ?? null); let fightGroup = $state(fight?.group?.id ?? null);
let selectedGroup = $derived(groups.find((group) => group.id === fightGroup));
let mapsStore = $derived(maps(fightModus ?? "null")); let mapsStore = $derived(maps(fightModus ?? "null"));
let gamemodeSelectOpen = $state(false); let gamemodeSelectOpen = $state(false);
let mapSelectOpen = $state(false); let mapSelectOpen = $state(false);
let blueTeamSelectOpen = $state(false);
let redTeamSelectOpen = $state(false);
let createOpen = $state(false);
let groupSelectOpen = $state(false);
let dirty = $derived( let dirty = $derived(
fightModus !== fight?.spielmodus || fightModus !== fight?.spielmodus ||
@@ -151,128 +141,10 @@
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Label for="fight-blue-team">Blue Team</Label> <Label>Blue Team</Label>
<Popover bind:open={blueTeamSelectOpen}> <TeamSelector bind:selectedTeam={fightBlueTeam} {data} fightId={fight?.id} team="BLUE" />
<PopoverTrigger> <Label>Red Team</Label>
{#snippet child({ props })} <TeamSelector bind:selectedTeam={fightRedTeam} {data} fightId={fight?.id} team="RED" />
<Button variant="outline" class="justify-between" {...props} role="combobox">
{teams.find((value) => value.id === fightBlueTeam?.id)?.name || fightBlueTeam?.name || "Select a team..."}
<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={() => {
fightBlueTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
blueTeamSelectOpen = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
fightBlueTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
blueTeamSelectOpen = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each teams as team}
<CommandItem
value={team.name}
onSelect={() => {
fightBlueTeam = team;
blueTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== fightBlueTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-red-team">Red Team</Label>
<Popover bind:open={redTeamSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{teams.find((value) => value.id === fightRedTeam?.id)?.name || fightRedTeam?.name || "Select a team..."}
<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={() => {
fightRedTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
redTeamSelectOpen = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
fightRedTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
redTeamSelectOpen = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each teams as team}
<CommandItem
value={team.name}
onSelect={() => {
fightRedTeam = team;
redTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== fightRedTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label>Start</Label> <Label>Start</Label>
<DateTimePicker bind:value={fightStart} /> <DateTimePicker bind:value={fightStart} />
{#if fight !== null} {#if fight !== null}
@@ -290,7 +162,7 @@
{/if} {/if}
<Label for="fight-group">Gruppe</Label> <Label for="fight-group">Gruppe</Label>
<GroupSelector {event} bind:value={fightGroup} bind:groups></GroupSelector> <GroupSelector event={data.event} bind:value={fightGroup} bind:groups={data.groups}></GroupSelector>
<Label for="spectate-port">Spectate Port</Label> <Label for="spectate-port">Spectate Port</Label>
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" /> <Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
</div> </div>
@@ -25,7 +25,7 @@
let groupSelectOpen = $state(false); let groupSelectOpen = $state(false);
async function handleGroupSave(group: GroupUpdateEdit) { async function handleGroupSave(group: GroupUpdateEdit) {
let g = await $eventRepo.createGroup(event.id.toString(), group); let g = await $eventRepo.createGroup(event.id, group);
groups.push(g); groups.push(g);
value = g.id; value = g.id;
createOpen = false; createOpen = false;
@@ -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>
@@ -27,4 +27,5 @@
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </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="#/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> <a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
<a href="#/logs" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/logs"}> Logs </a>
</nav> </nav>
@@ -20,7 +20,7 @@
<script lang="ts"> <script lang="ts">
import FightEditRow from "./FightEditRow.svelte"; import FightEditRow from "./FightEditRow.svelte";
import type { EventFight, EventFightEdit, ExtendedEvent } from "@type/event"; import type { EventFightEdit } from "@type/event";
import { createSvelteTable, FlexRender } from "@components/ui/data-table"; import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core"; import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
import { columns } from "./columns"; import { columns } from "./columns";
@@ -38,7 +38,6 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
import GroupSelector from "@components/moderator/components/GroupSelector.svelte"; import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
import { fightRepo } from "@components/repo/fight"; import { fightRepo } from "@components/repo/fight";
import type { Team } from "@components/types/team";
import type { EventModel } from "./eventmodel.svelte"; import type { EventModel } from "./eventmodel.svelte";
let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props(); let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
@@ -135,7 +134,7 @@
<DialogTitle>Fight Erstellen</DialogTitle> <DialogTitle>Fight Erstellen</DialogTitle>
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription> <DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
</DialogHeader> </DialogHeader>
<FightEdit fight={null} teams={data.teams} event={data.event} groups={data.groups} onSave={handleSave}> <FightEdit fight={null} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)} {#snippet actions(dirty, submit)}
<DialogFooter> <DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button> <Button disabled={!dirty} onclick={submit}>Speichern</Button>
@@ -195,6 +194,16 @@
<MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem> <MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
<MenubarItem disabled>Startzeit Verschieben</MenubarItem> <MenubarItem disabled>Startzeit Verschieben</MenubarItem>
<MenubarItem disabled>Spectate Port Ändern</MenubarItem> <MenubarItem disabled>Spectate Port Ändern</MenubarItem>
<MenubarItem
onclick={async () => {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.deleteFight(data.event.id, g.id);
}
refresh();
}}>Kämpfe Löschen</MenubarItem
>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
<MenubarMenu> <MenubarMenu>
@@ -203,8 +212,9 @@
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem> <MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
<MenubarGroup> <MenubarGroup>
<MenubarGroupHeading>Generatoren</MenubarGroupHeading> <MenubarGroupHeading>Generatoren</MenubarGroupHeading>
<MenubarItem disabled>Gruppenphase</MenubarItem> <a href="#/event/{data.event.id}/generate">
<MenubarItem disabled>K.O. Phase</MenubarItem> <MenubarItem>Gruppenphase</MenubarItem>
</a>
</MenubarGroup> </MenubarGroup>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
@@ -258,12 +268,14 @@
{group?.name ?? "Keine Gruppe"} {group?.name ?? "Keine Gruppe"}
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="text-right">
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group)}> {#if group}
<EditIcon /> <Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group!)}>
</Button> <EditIcon />
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group)}> </Button>
<GroupIcon /> <Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group!)}>
</Button> <GroupIcon />
</Button>
{/if}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@@ -291,13 +303,7 @@
</TableCell> </TableCell>
{/each} {/each}
<TableCell class="text-right"> <TableCell class="text-right">
<FightEditRow <FightEditRow fight={row.original} {data} onupdate={(update) => (data._fights = data._fights.map((v) => (v.id === update.id ? update : v)))} {refresh}></FightEditRow>
fight={row.original}
teams={data.teams}
bind:groups={data.groups}
event={data.event}
onupdate={(update) => (data.fights = data.fights.map((v) => (v.id === update.id ? update : v)))}
></FightEditRow>
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@@ -1,18 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event"; import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { Button } from "@components/ui/button"; import { Button } from "@components/ui/button";
import { EditIcon, MenuIcon, GroupIcon } from "lucide-svelte"; import { EditIcon, CopyIcon } from "lucide-svelte";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte"; import FightEdit from "@components/moderator/components/FightEdit.svelte";
import type { Team } from "@components/types/team"; import type { Team } from "@components/types/team";
import { fightRepo } from "@components/repo/fight"; import { fightRepo } from "@components/repo/fight";
import { eventRepo } from "@components/repo/event";
import type { EventModel } from "./eventmodel.svelte";
let { fight, teams, groups = $bindable(), event, onupdate }: { fight: EventFight; teams: Team[]; groups: ResponseGroups[]; event: SWEvent; onupdate: (update: EventFight) => void } = $props(); let { fight, onupdate, refresh, data }: { fight: EventFight; onupdate: (update: EventFight) => void; refresh: () => void; data: EventModel } = $props();
let editOpen = $state(false); let editOpen = $state(false);
let duplicateOpen = $state(false);
async function handleSave(fightData: EventFightEdit) { async function handleSave(fightData: EventFightEdit) {
let f = await $fightRepo.updateFight(event.id, fight.id, { let f = await $fightRepo.updateFight(data.event.id, fight.id, {
...fightData, ...fightData,
blueTeam: fightData.blueTeam.id, blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id, redTeam: fightData.redTeam.id,
@@ -23,6 +26,23 @@
editOpen = false; editOpen = false;
} }
async function handlyCopy(fightData: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
});
refresh();
duplicateOpen = false;
}
async function handleDelete() {
await $fightRepo.deleteFight(data.event.id, fight.id);
refresh();
}
</script> </script>
<div> <div>
@@ -37,13 +57,34 @@
<DialogTitle>Fight bearbeiten</DialogTitle> <DialogTitle>Fight bearbeiten</DialogTitle>
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription> <DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
</DialogHeader> </DialogHeader>
<FightEdit {fight} {teams} bind:groups {event} onSave={handleSave}> <FightEdit {fight} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)} {#snippet actions(dirty, submit)}
<DialogFooter> <DialogFooter>
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button> <Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter> </DialogFooter>
{/snippet} {/snippet}
</FightEdit> </FightEdit>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
@@ -19,12 +19,10 @@
<script lang="ts"> <script lang="ts">
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table"; 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 { Button } from "@components/ui/button/index.js";
import type { ExtendedEvent } from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event"; import { eventRepo } from "@repo/event";
import { players } from "@stores/stores"; import PlayerSelector from "@components/ui/PlayerSelector.svelte";
const { event }: { event: ExtendedEvent } = $props(); const { event }: { event: ExtendedEvent } = $props();
@@ -39,8 +37,6 @@
await $eventRepo.deleteReferees(event.event.id.toString(), [value]); await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString()); referees = await $eventRepo.listReferees(event.event.id.toString());
} }
let playerSearch = $state("");
</script> </script>
<Table> <Table>
@@ -60,27 +56,7 @@
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>
<Popover> <TableCaption>
<TableCaption> <PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
<PopoverTrigger> </TableCaption>
<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.includes(playerSearch))
.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> </Table>
@@ -20,12 +20,10 @@
<script lang="ts"> <script lang="ts">
import { Button } from "@components/ui/button"; import { Button } from "@components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event"; import { eventRepo } from "@repo/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { teams } from "@components/stores/stores"; import { teams } from "@components/stores/stores";
import type { Team } from "@components/types/team";
import type { EventModel } from "./eventmodel.svelte"; import type { EventModel } from "./eventmodel.svelte";
let { event = $bindable() }: { event: EventModel } = $props(); let { event = $bindable() }: { event: EventModel } = $props();
@@ -20,9 +20,9 @@
import { Checkbox } from "@components/ui/checkbox"; import { Checkbox } from "@components/ui/checkbox";
import { renderComponent } from "@components/ui/data-table"; import { renderComponent } from "@components/ui/data-table";
import type { ColumnDef } from "@tanstack/table-core"; import type { ColumnDef } from "@tanstack/table-core";
import type { EventFight } from "@type/event.ts"; import type { EventFightModel } from "./eventmodel.svelte";
export const columns: ColumnDef<EventFight> = [ export const columns: ColumnDef<EventFightModel>[] = [
{ {
id: "auswahl", id: "auswahl",
header: ({ table }) => { header: ({ table }) => {
@@ -32,7 +32,7 @@ export const columns: ColumnDef<EventFight> = [
onCheckedChange: () => { onCheckedChange: () => {
if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) { if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
const now = new Date(); const now = new Date();
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.date) > now); const rows = table.getRowModel().rows.filter((row) => new Date(row.original.start) > now);
if (rows.length > 0) { if (rows.length > 0) {
rows.forEach((row) => { rows.forEach((row) => {
@@ -57,7 +57,7 @@ export const columns: ColumnDef<EventFight> = [
}, },
}, },
{ {
accessorFn: (r) => r.blueTeam.name + " vs " + r.redTeam.name, accessorFn: (r) => r.blueTeam.nameWithRelation + " vs " + r.redTeam.nameWithRelation,
id: "begegnung", id: "begegnung",
header: "Begegnung", header: "Begegnung",
}, },
@@ -1,21 +1,68 @@
import type { ResponseUser } from "@components/repo/event"; import type { ResponseUser } from "@components/repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event"; import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
import type { Team } from "@components/types/team"; import type { Team } from "@components/types/team";
import { derived } from "svelte/store";
export class EventModel { export class EventModel {
public event: SWEvent = $state({} as SWEvent); public event: SWEvent = $state({} as SWEvent);
public teams: Array<Team> = $state([]); public teams: Array<Team> = $state([]);
public groups: Array<ResponseGroups> = $state([]); public groups: Array<ResponseGroups> = $state([]);
public fights: Array<EventFight> = $state([]); public _fights: Array<EventFight> = $state([]);
public referees: Array<ResponseUser> = $state([]); public referees: Array<ResponseUser> = $state([]);
public relations: Array<ResponseRelation> = $state([]); public relations: Array<ResponseRelation> = $state([]);
public fights = $derived(this.remapFights(this._fights, this.relations));
constructor(data: ExtendedEvent) { constructor(data: ExtendedEvent) {
this.event = data.event; this.event = data.event;
this.relations = data.relations;
this.teams = data.teams; this.teams = data.teams;
this.groups = data.groups; this.groups = data.groups;
this.fights = data.fights; this._fights = data.fights;
this.referees = data.referees; this.referees = data.referees;
this.relations = data.relations; }
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];
@@ -0,0 +1,31 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
import SingleEliminationGenerator from "./gens/elimination/SingleEliminationGenerator.svelte";
import DoubleEliminationGenerator from "./gens/elimination/DoubleEliminationGenerator.svelte";
let {
data,
}: {
data: ExtendedEvent;
} = $props();
</script>
<div class="m-4">
<Tabs value="group">
<TabsList class="mb-4">
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
<TabsTrigger value="double">Double Elimination</TabsTrigger>
</TabsList>
<TabsContent value="group">
<GroupPhaseGenerator {data} />
</TabsContent>
<TabsContent value="ko">
<SingleEliminationGenerator {data} />
</TabsContent>
<TabsContent value="double">
<DoubleEliminationGenerator {data} />
</TabsContent>
</Tabs>
</div>
@@ -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}
@@ -0,0 +1,515 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// Seed model (reuse from single elimination)
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
}
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const ni = index + dir;
if (ni < 0 || ni >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(ni, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "TEAM", id: -1 }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30);
let startDelay = $state(30);
let gamemode = $state("");
let map = $state("");
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Build winners bracket rounds (same as single elimination seeding)
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildWinnersRounds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
if ((n & (n - 1)) !== 0) return []; // power of two required
const rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
let prevWinners = round.map((f) => f.blue);
while (prevWinners.length > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) next.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
rounds.push(next);
prevWinners = next.map((f) => f.blue);
}
return rounds;
}
let winnersRounds = $derived(buildWinnersRounds(seedSlots));
// Losers bracket structure: each losers round takes losers from previous winners round or previous losers round.
// Simplified pairing: For each winners round except final, collect losers and pair them sequentially; then advance until one remains for losers final.
function buildLosersTemplate(wRounds: BracketRoundPreview[]): BracketRoundPreview[] {
const losersRounds: BracketRoundPreview[] = [];
if (wRounds.length < 2) return losersRounds;
// Round 1 losers (from winners round 1)
const firstLosersPairs: BracketRoundPreview = [];
wRounds[0].forEach((f) => firstLosersPairs.push({ blue: f.blue, red: f.red })); // placeholders (will label as losers)
losersRounds.push(firstLosersPairs);
// Subsequent losers rounds shrink similarly
let remaining = firstLosersPairs.length; // number of fights that feed losers next stage
while (remaining > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < remaining; i += 2) next.push(firstLosersPairs[i]); // placeholder reuse
losersRounds.push(next);
remaining = next.length;
}
return losersRounds;
}
let losersRounds = $derived(buildLosersTemplate(winnersRounds));
let generateDisabled = $derived(gamemode !== "" && map !== "" && winnersRounds.length > 0 && seedSlots.length >= 4);
// Type helpers
function slotLabel(slot: SeedSlot): string {
if (slot.kind === "TEAM") return teams.get(slot.id)?.name ?? "???";
if (slot.kind === "GROUP") {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
async function generateDouble() {
if (!generateDisabled) return;
const eventId = data.event.id;
// Create two groups: winners & losers + grand final group (optional combine winners)
const winnersGroup = await $eventRepo.createGroup(eventId, { name: "Winners", type: "ELIMINATION_STAGE" });
const losersGroup = await $eventRepo.createGroup(eventId, { name: "Losers", type: "ELIMINATION_STAGE" });
const finalGroup = await $eventRepo.createGroup(eventId, { name: "Final", type: "ELIMINATION_STAGE" });
function fallbackTeamId(slot: SeedSlot): number {
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
return data.teams[0].id;
}
const winnersFightIdsByRound: number[][] = [];
for (let r = 0; r < winnersRounds.length; r++) {
const round = winnersRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const f = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(round[i].blue),
redTeam: fallbackTeamId(round[i].red),
group: winnersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
// Attach relations for GROUP/FIGHT seeds
const pair = round[i];
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(f.id);
}
winnersFightIdsByRound.push(ids);
}
// Progression in winners bracket
for (let r = 1; r < winnersFightIdsByRound.length; r++) {
const prev = winnersFightIdsByRound[r - 1];
const curr = winnersFightIdsByRound[r];
for (let i = 0; i < curr.length; i++) {
const target = curr[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
await $eventRepo.createRelation(eventId, { fightId: target, team: "BLUE", fromType: "FIGHT", fromId: srcA, fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: target, team: "RED", fromType: "FIGHT", fromId: srcB, fromPlace: 0 });
}
}
// Losers bracket (canonical pattern):
// L1: losers of WBR1 paired; then for r=2..(k-1):
// Major: winners of previous L vs losers of WBRr
// Minor: winners of that major paired (except after last WBR where we go to LB final vs WB final loser)
const losersFightIdsByRound: number[][] = [];
let losersRoundIndex = 0;
const k = winnersFightIdsByRound.length; // number of winners rounds
// L1 from WBR1 losers
{
const wb1 = winnersFightIdsByRound[0];
const ids: number[] = [];
for (let i = 0; i < wb1.length; i += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (i / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: wb1[i], fromPlace: 1 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: wb1[i + 1], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// For each subsequent winners round except the final
for (let wr = 1; wr < k - 1; wr++) {
const prevLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
// Major: winners of prevLBRound vs losers of current WBR (wr)
{
const ids: number[] = [];
for (let j = 0; j < winnersFightIdsByRound[wr].length; j++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * j })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: prevLBRound[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFightIdsByRound[wr][j], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// Minor: pair winners of last LBRound among themselves (if more than 1)
{
const last = losersFightIdsByRound[losersFightIdsByRound.length - 1];
if (last.length > 1) {
const ids: number[] = [];
for (let j = 0; j < last.length; j += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (j / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: last[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: last[j + 1], fromPlace: 0 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
}
}
// Final losers round: winners of last LBRound vs loser of Winners Final (last WBR)
const winnersFinal = winnersFightIdsByRound[k - 1][0];
const lastLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
let losersFinal: number | undefined = undefined;
if (lastLBRound && lastLBRound.length >= 1) {
let finalMap2 = map;
if (finalMap2 === "%random%" && selectableMaps.length > 0) finalMap2 = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap2,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: lastLBRound[lastLBRound.length - 1], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 1 });
losersFinal = lf.id;
losersFightIdsByRound.push([lf.id]);
losersRoundIndex++;
}
// Grand Final
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const grandFinal = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: finalGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "BLUE", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 0 });
if (losersFinal !== undefined) await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "RED", fromType: "FIGHT", fromId: losersFinal, fromPlace: 0 });
await replace("#/event/" + eventId);
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Double Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 4}
<p class="text-gray-400">Mindestens vier Seeds benötigt.</p>
{:else if winnersRounds.length === 0}
<p class="text-yellow-400">Seedanzahl muss eine Zweierpotenz sein. Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid lg:grid-cols-3 gap-6">
<div class="space-y-4">
<Label>Seeds</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
<div class="space-y-2">
<Label>Hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
{#if data.groups.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
</div>
{/if}
{#if data.fights.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
</div>
{/if}
</div>
</div>
</div>
<div>
<Label>Konfiguration</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}<SelectItem value={gm.value}>{gm.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}<SelectItem value={mp.value}>{mp.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-6">
<div>
<Label>Winners Bracket Vorschau</Label>
{#if winnersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each winnersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">W Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<div>
<Label>Losers Bracket (vereinfachte Vorschau)</Label>
{#if losersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each losersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">L Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
Verlierer Paar {i + 1} (aus W Runde {r + 1})
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateDouble} aria-label="Double Bracket generieren">
<Plus />
</Button>
</Card>
<!-- minimal styles only -->
@@ -0,0 +1,364 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// --- Seeding model: support teams, group results, unknown placeholders ---
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedUnknownSlot {
kind: "UNKNOWN";
uid: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
} // 0 winner, 1 loser
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedUnknownSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
let unknownCounter = 1;
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const newIndex = index + dir;
if (newIndex < 0 || newIndex >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(newIndex, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "UNKNOWN", uid: unknownCounter++ }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
// Fight seed selection
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config inputs
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30); // minutes per round
let startDelay = $state(30); // seconds between fights inside round
let gamemode = $state("");
let map = $state("");
// Gamemode / Map selection stores
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Derived: bracket rounds preview
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildBracketSeeds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
// Require power of two for now; simplest implementation
if ((n & (n - 1)) !== 0) return [];
let rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
// Higher rounds placeholders using first team from each prior pairing as seed representative
let prevWinners = round.map((fight) => fight.blue);
while (prevWinners.length > 1) {
const nextRound: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) {
nextRound.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
}
rounds.push(nextRound);
prevWinners = nextRound.map((f) => f.blue);
}
return rounds;
}
let bracketRounds = $derived(buildBracketSeeds(seedSlots));
let generateDisabled = $derived(gamemode !== "" && map !== "" && bracketRounds.length > 0 && seedSlots.length >= 2);
async function generateBracket() {
if (!generateDisabled) return;
const eventId = data.event.id;
// create elimination group
const group = await $eventRepo.createGroup(eventId, { name: "Elimination", type: "ELIMINATION_STAGE" });
// Create fights round by round & keep ids for relation wiring
const fightIdsByRound: number[][] = [];
function fallbackTeamId(slot: SeedSlot): number {
// If this seed is a relation (GROUP or FIGHT), use -1 as requested
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
// UNKNOWN stays as a concrete placeholder team or -1? Keep concrete team to avoid backend errors.
return data.teams[0].id;
}
for (let r = 0; r < bracketRounds.length; r++) {
const round = bracketRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
const pair = round[i];
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const fight = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(pair.blue),
redTeam: fallbackTeamId(pair.red),
group: group.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(fight.id);
}
fightIdsByRound.push(ids);
}
// Wire relations: for each fight in rounds >0, reference winners of two source fights from previous round
for (let r = 1; r < fightIdsByRound.length; r++) {
const prev = fightIdsByRound[r - 1];
const current = fightIdsByRound[r];
for (let i = 0; i < current.length; i++) {
const targetFightId = current[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
// Winner assumed place 1
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "BLUE",
fromType: "FIGHT",
fromId: srcA,
fromPlace: 1,
});
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "RED",
fromType: "FIGHT",
fromId: srcB,
fromPlace: 1,
});
}
}
// Redirect back to event view
await replace("#/event/" + eventId);
}
// Helpers for template rendering with TS type guards
function isTeam(slot: SeedSlot): slot is SeedTeamSlot {
return slot.kind === "TEAM";
}
function isGroup(slot: SeedSlot): slot is SeedGroupSlot {
return slot.kind === "GROUP";
}
function slotLabel(slot: SeedSlot): string {
if (isTeam(slot)) return teams.get(slot.id)?.name ?? "Team";
if (isGroup(slot)) {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Single Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 2}
<p class="text-gray-400">Mindestens zwei Seeds benötigt.</p>
{:else if bracketRounds.length === 0}
<p class="text-yellow-400">Anzahl der Seeds muss eine Zweierpotenz sein (2,4,8,16,...). Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-4">
<div>
<Label>Seeds / Reihenfolge</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
</div>
<div class="space-y-2">
<Label>Seed hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.groups.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.fights.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
{/if}
</div>
</div>
<p class="text-xs text-gray-500">Gruppen- oder Kampfplätze erzeugen Relationen beim Generieren. ??? bleibt Platzhalter.</p>
</div>
</div>
<div>
<Label>Startzeit</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}
<SelectItem value={gm.value}>{gm.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}
<SelectItem value={mp.value}>{mp.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="mt-6">
<Label>Vorschau</Label>
{#if bracketRounds.length > 0}
<div class="flex gap-8 overflow-x-auto mt-2">
{#each bracketRounds as round, r}
<div>
<h3 class="font-semibold mb-2">Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-sm">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} &nbsp;vs.&nbsp; {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
<Plus />
</Button>
</Card>
<!-- no component-scoped styles needed -->
@@ -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>
@@ -0,0 +1,191 @@
<script lang="ts">
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { auditLog } from "@components/repo/auditlog";
import { now, ZonedDateTime } from "@internationalized/date";
import { AuditLogEntrySchema, type AuditLogEntry } from "@components/types/auditlog";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Check } from "lucide-svelte";
import { cn } from "@components/utils";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let actionText = $state("");
let serverText = $state("");
let fullText = $state("");
let actors = $state<number[]>([]);
let actionTypes = $state<string[]>([]);
let timeGreater = $state<ZonedDateTime>(now("Europe/Berlin").subtract({ months: 1 }));
let timeLess = $state<ZonedDateTime>(now("Europe/Berlin"));
let serverOwner = $state<number[]>([]);
let velocity = $state(false);
let sorting = $state("DESC");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<AuditLogEntry[]>([]);
let rows = $state(0);
$effect(() => {
$auditLog
.get(
actionText || undefined,
serverText || undefined,
fullText || undefined,
actors.length > 0 ? actors : undefined,
actionTypes.length > 0 ? actionTypes : undefined,
timeGreater ? timeGreater.toDate().getTime() : undefined,
timeLess ? timeLess.toDate().getTime() : undefined,
serverOwner.length > 0 ? serverOwner : undefined,
velocity,
pagination.pageIndex,
pagination.pageSize,
sorting || undefined
)
.then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
let playerSearch = $state("");
let ownerSearch = $state("");
</script>
<div class="p-4">
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
<Input
class="w-48 mr-2"
placeholder="Suchen..."
value={fullText}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
/>
<Select type="multiple" value={actionTypes} onValueChange={(e) => (actionTypes = e)}>
<SelectTrigger class="w-48 mr-2" placeholder="Aktionstypen auswählen...">Aktionstypen ({actionTypes.length})</SelectTrigger>
<SelectContent>
{#each ["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"] as option}
<SelectItem value={option}>{option}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="mr-2">
<PlayerSelector bind:value={actors} multiple placeholder="Spieler Filter" />
</div>
<div class="mr-2">
<PlayerSelector bind:value={serverOwner} multiple placeholder="Server Owner" />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeGreater} />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeLess} />
</div>
<Select type="single" value={sorting} onValueChange={(e) => (sorting = e)}>
<SelectTrigger class="w-48 mr-2">{sorting === "ASC" ? "Aufsteigend" : "Absteigend"}</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">Aufsteigend</SelectItem>
<SelectItem value="DESC">Absteigend</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">Keine Einträge gefunden.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>
@@ -0,0 +1,35 @@
import type { AuditLogEntry } from "@components/types/auditlog";
import type { ColumnDef } from "@tanstack/table-core";
export const columns: ColumnDef<AuditLogEntry>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "time",
header: "Time",
cell: (info) => new Date(info.getValue<number>()).toLocaleString(),
},
{
accessorKey: "server",
header: "Server",
},
{
accessorKey: "serverOwner",
header: "Server Owner",
cell: (info) => info.getValue<string | null>() || "N/A",
},
{
accessorKey: "actor",
header: "Spieler",
},
{
accessorKey: "actionType",
header: "Action Type",
},
{
accessorKey: "actionText",
header: "Action Text",
},
];
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Separator } from "@components/ui/separator";
import { manager, OpenEditPage } from "./page.svelte"; import { manager, OpenEditPage } from "./page.svelte";
import { File, X } from "lucide-svelte"; import { File, FileCode2, FileText, Save, X } from "lucide-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror"; import { basicSetup } from "codemirror";
@@ -61,56 +60,144 @@
}); });
easyMde.codemirror.on("change", () => { easyMde.codemirror.on("change", () => {
if (manager.selectedPage?.content !== easyMde?.value()) { if (!manager.selectedPage) {
manager.selectedPage!.dirty = true; return;
} }
manager.selectedPage!.content = easyMde?.value() || ""; if (manager.selectedPage.content !== easyMde?.value()) {
manager.selectedPage.dirty = true;
}
manager.selectedPage.content = easyMde?.value() || "";
}); });
return () => {
view?.destroy();
easyMde?.toTextArea();
};
}); });
</script> </script>
<div class="flex flex-col h-full w-full"> <div class="flex h-full min-h-0 w-full flex-col bg-neutral-950">
<div class="h-8 flex"> <div class="flex h-11 shrink-0 items-end overflow-x-auto border-b border-neutral-800 bg-neutral-950 px-2">
{#each manager.pages as tab, index} {#if manager.pages.length === 0}
{@const isActive = manager.openPageIndex === index} <div class="flex h-full items-center px-2 text-sm text-muted-foreground">No file open</div>
<button {:else}
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive {#each manager.pages as tab, index}
? 'text-primary bg-neutral-900' {@const isActive = manager.openPageIndex === index}
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}" <button
onclick={() => (manager.openPageIndex = index)} class="group flex h-9 max-w-64 items-center gap-2 rounded-t-md border border-b-0 px-3 text-sm transition-colors {isActive
> ? 'border-neutral-800 bg-neutral-900 text-foreground'
<File class="h-4 w-4 mr-2" /> : 'border-transparent text-muted-foreground hover:bg-neutral-900/70'} {tab.dirty ? 'italic' : ''}"
{tab.pageTitle} onclick={() => (manager.openPageIndex = index)}
<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> {#if tab.fileType === "json"}
{/each} <FileCode2 class="size-4 shrink-0" />
</div> {:else if tab.fileType === "md" || tab.fileType === "mdx"}
<Separator /> <FileText class="size-4 shrink-0" />
<div class="flex-1 flex flex-col"> {:else}
{#if manager.selectedPage} <File class="size-4 shrink-0" />
<div class="flex items-center justify-end p-2"> {/if}
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button> <span class="truncate">{tab.pageTitle}</span>
</div> {#if tab.dirty}
<div class="flex gap-2 items-center"> <span class="size-1.5 shrink-0 rounded-full bg-primary"></span>
{#if manager.selectedPage.path.startsWith("src/content/announcements/")} {/if}
<div class="border-b flex-1" transition:slide> <span
<FrontmatterEditor /> class="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-neutral-800 group-hover:opacity-100 {isActive ? 'opacity-100' : ''}"
</div> onclick={(e) => {
{/if} e.stopPropagation();
</div> manager.closePage(index);
}}><X class="size-4" /></span
>
</button>
{/each}
{/if} {/if}
<div class="flex-1"> </div>
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full"> <div class="flex min-h-0 flex-1 flex-col">
<textarea bind:this={easyMdeParent}></textarea> {#if manager.selectedPage}
<header class="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-800 bg-neutral-900/70 px-4 py-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="truncate text-base font-semibold">{manager.selectedPage.pageTitle}</h2>
{#if manager.selectedPage.dirty}
<span class="rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary">Unsaved</span>
{/if}
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">{manager.selectedPage.path}</p>
</div>
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>
<Save class="mr-2 size-4" />
Speichern
</Button>
</header>
{/if}
<div class="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_26rem]">
<main class="min-h-0 overflow-hidden border-r border-neutral-800 bg-neutral-950">
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 border-b border-neutral-800 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Content</div>
<div class="relative min-h-0 flex-1 overflow-hidden">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
{#if !manager.selectedPage}
<div class="absolute inset-0 flex items-center justify-center bg-neutral-950 p-8">
<div class="max-w-sm text-center">
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-neutral-800 bg-neutral-900">
<FileText class="size-6 text-muted-foreground" />
</div>
<h2 class="text-lg font-semibold">Select a page</h2>
<p class="mt-2 text-sm text-muted-foreground">Open a markdown, MDX, or JSON file from the repository tree to start editing.</p>
</div>
</div>
{/if}
</div>
</div> </div>
</main>
{#if manager.selectedPage?.supportsFrontmatter() && manager.selectedPage.frontmatterSchema}
<aside class="min-h-0 overflow-y-auto bg-neutral-950" transition:slide>
<FrontmatterEditor />
</aside>
{/if}
</div> </div>
</div> </div>
</div> </div>
<style>
:global(.EasyMDEContainer) {
height: 100%;
display: flex;
min-height: 0;
flex-direction: column;
background: rgb(10 10 10);
}
:global(.EasyMDEContainer .CodeMirror) {
flex: 1;
min-height: 0;
height: 100%;
border: 0;
background: rgb(10 10 10);
color: rgb(245 245 245);
}
:global(.EasyMDEContainer .editor-toolbar) {
border: 0;
border-bottom: 1px solid rgb(38 38 38);
background: rgb(23 23 23);
}
:global(.EasyMDEContainer .editor-toolbar button) {
color: rgb(212 212 212) !important;
}
:global(.EasyMDEContainer .editor-toolbar button:hover),
:global(.EasyMDEContainer .editor-toolbar button.active) {
background: rgb(38 38 38);
border-color: rgb(64 64 64);
}
</style>
@@ -1,122 +1,309 @@
<script lang="ts"> <script lang="ts">
import { X } from "lucide-svelte"; import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Checkbox } from "@components/ui/checkbox";
import { Plus, X } from "lucide-svelte";
import yaml from "js-yaml";
import { manager } from "./page.svelte"; import { manager } from "./page.svelte";
import { slide } from "svelte/transition"; import type { FrontmatterFieldSchema } from "../../../../content/frontmatter-editor-schemas";
import EventSelector from "./frontmatter/EventSelector.svelte";
import ImageFrontmatterSelector from "./frontmatter/ImageFrontmatterSelector.svelte";
import ViewConfigEditor from "./frontmatter/ViewConfigEditor.svelte";
function markDirty() {
if (manager.selectedPage) {
manager.selectedPage.dirty = true;
}
}
function setField(key: string, value: unknown) {
if (!manager.selectedPage) {
return;
}
manager.selectedPage.frontmatter[key] = value;
markDirty();
}
function removeField(key: string) {
if (!manager.selectedPage) {
return;
}
delete manager.selectedPage.frontmatter[key];
markDirty();
}
function getFieldValue(field: FrontmatterFieldSchema) {
const existingValue = manager.selectedPage?.frontmatter[field.key];
if (existingValue !== undefined) {
return existingValue;
}
if (field.defaultValue !== undefined) {
return field.defaultValue;
}
switch (field.kind) {
case "boolean":
return false;
case "number":
return "";
case "string-array":
return [];
case "object":
return {};
default:
return "";
}
}
function dateInputValue(value: unknown) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().split("T")[0];
}
if (typeof value === "string") {
return value.split("T")[0];
}
return "";
}
function yamlValue(value: unknown) {
if (value === undefined || value === null || value === "") {
return "";
}
return yaml.dump(value).trim();
}
function setYamlField(key: string, value: string) {
if (!value.trim()) {
setField(key, {});
return;
}
try {
setField(key, yaml.load(value));
} catch (error) {
alert(`Invalid YAML for ${key}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function arrayValue(key: string): string[] {
const value = manager.selectedPage?.frontmatter[key];
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function setArrayItem(key: string, index: number, value: string) {
const values = arrayValue(key);
values[index] = value;
setField(key, values);
}
function addArrayItem(key: string) {
setField(key, [...arrayValue(key), ""]);
}
function removeArrayItem(key: string, index: number) {
setField(
key,
arrayValue(key).filter((_, itemIndex) => itemIndex !== index)
);
}
function renameCustomField(oldKey: string, newKey: string) {
if (!manager.selectedPage || !newKey || newKey === oldKey) {
return;
}
if (Object.hasOwn(manager.selectedPage.frontmatter, newKey)) {
alert(`A frontmatter field named "${newKey}" already exists.`);
return;
}
manager.selectedPage.frontmatter[newKey] = manager.selectedPage.frontmatter[oldKey];
delete manager.selectedPage.frontmatter[oldKey];
markDirty();
}
function addCustomField() {
if (!manager.selectedPage) {
return;
}
let index = 1;
let key = "customField";
while (Object.hasOwn(manager.selectedPage.frontmatter, key)) {
index += 1;
key = `customField${index}`;
}
setField(key, "");
}
function schemaKeys(schema = manager.selectedPage?.frontmatterSchema) {
return new Set(schema?.fields.map((field) => field.key) ?? []);
}
</script> </script>
<details class="group"> {#if manager.selectedPage?.frontmatterSchema}
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800"> {@const schema = manager.selectedPage.frontmatterSchema}
<span class="font-medium">Frontmatter</span> {@const customEntries = Object.entries(manager.selectedPage.frontmatter).filter(([key]) => !schemaKeys(schema).has(key))}
<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> <details class="group" open>
</svg> <summary class="sticky top-0 z-10 flex cursor-pointer items-center justify-between border-b border-neutral-800 bg-neutral-950/95 px-4 py-3 backdrop-blur hover:bg-neutral-900">
</summary> <div>
<div class="p-3 border-t bg-neutral-900"> <p class="text-sm font-semibold">{schema.label} Frontmatter</p>
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]} <p class="text-xs text-muted-foreground">{schema.collection}</p>
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800"> </div>
<div class="flex items-center gap-2"> <svg class="h-4 w-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<input <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
type="text" </svg>
value={key} </summary>
onchange={(e) => {
const newKey = (e.target as HTMLInputElement).value; <div class="grid grid-cols-1 gap-4 bg-neutral-950 p-4">
if (newKey !== key) { {#each schema.fields as field (field.key)}
manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key]; {@const value = getFieldValue(field)}
delete manager.selectedPage?.frontmatter[key]; <div class="space-y-2">
manager.selectedPage!.dirty = true; <div class="flex items-center justify-between gap-3">
} <Label for={`frontmatter-${field.key}`} class="text-sm">
}} {field.label}
class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900" {#if field.required}
placeholder="Key" <span class="text-red-400">*</span>
/> {/if}
<span>:</span> </Label>
{#if Array.isArray(value)} {#if field.collection}
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span> <span class="rounded border border-neutral-700 px-2 py-0.5 text-xs text-muted-foreground">{field.collection}</span>
{:else if value instanceof Date || key === "created"} {/if}
<input </div>
type="date"
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""} {#if field.key === "eventId"}
onchange={(e) => { <EventSelector value={value as number | string | null | undefined} onSelect={(eventId) => setField(field.key, eventId)} />
const dateValue = (e.target as HTMLInputElement).value; {:else if field.key === "viewConfig"}
manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : ""; <ViewConfigEditor value={value} eventId={manager.selectedPage.frontmatter.eventId as number | string | null | undefined} onChange={(viewConfig) => setField(field.key, viewConfig)} />
manager.selectedPage!.dirty = true; {:else if field.kind === "image"}
<ImageFrontmatterSelector value={String(value ?? "")} onSelect={(path) => setField(field.key, path)} />
{:else if field.kind === "boolean"}
<label class="flex h-9 items-center gap-3 rounded-md border border-neutral-800 bg-neutral-900 px-3">
<Checkbox checked={Boolean(value)} onCheckedChange={(checked) => setField(field.key, checked === true)} />
<span class="text-sm">{Boolean(value) ? "True" : "False"}</span>
</label>
{:else if field.kind === "number"}
<Input
id={`frontmatter-${field.key}`}
type="number"
value={typeof value === "number" ? value : ""}
onchange={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value;
setField(field.key, nextValue === "" ? "" : Number(nextValue));
}} }}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
/> />
{:else if field.kind === "date"}
<Input
id={`frontmatter-${field.key}`}
type="date"
value={dateInputValue(value)}
onchange={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value;
setField(field.key, nextValue ? new Date(`${nextValue}T00:00:00.000Z`) : "");
}}
/>
{:else if field.kind === "text"}
<textarea
id={`frontmatter-${field.key}`}
class="min-h-20 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
maxlength={field.maxLength}
value={String(value ?? "")}
oninput={(event) => setField(field.key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else if field.kind === "string-array"}
<div class="space-y-2 rounded-md border border-neutral-800 bg-neutral-900 p-2">
{#each arrayValue(field.key) as item, index}
<div class="flex items-center gap-2">
<Input value={item} oninput={(event) => setArrayItem(field.key, index, (event.currentTarget as HTMLInputElement).value)} />
<Button type="button" variant="ghost" size="icon" onclick={() => removeArrayItem(field.key, index)}>
<X class="size-4" />
</Button>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={() => addArrayItem(field.key)}>
<Plus class="mr-2 size-4" />
Add item
</Button>
</div>
{:else if field.kind === "object"}
<textarea
id={`frontmatter-${field.key}`}
class="min-h-28 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={yamlValue(value)}
onchange={(event) => setYamlField(field.key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else} {:else}
<input <Input
id={`frontmatter-${field.key}`}
type="text" type="text"
bind:value={manager.selectedPage!.frontmatter[key]} maxlength={field.maxLength}
onchange={() => (manager.selectedPage!.dirty = true)} placeholder={field.kind === "reference" ? `Reference to ${field.collection}` : field.kind === "image" ? "Image path" : ""}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900" value={String(value ?? "")}
placeholder="Value" oninput={(event) => setField(field.key, (event.currentTarget as HTMLInputElement).value)}
/> />
{/if} {/if}
<button
onclick={() => { {#if field.description}
delete manager.selectedPage!.frontmatter[key]; <p class="text-xs text-muted-foreground">{field.description}</p>
manager.selectedPage!.dirty = true; {/if}
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-4 h-4" />
</button>
</div> </div>
{#if Array.isArray(value)} {/each}
<div class="ml-4 space-y-1">
{#each value as item, index} {#if customEntries.length > 0}
<div class="flex items-center gap-2"> <div class="space-y-3 border-t border-neutral-800 pt-4">
<span class="text-xs text-muted-foreground w-6">[{index}]</span> <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Custom fields</p>
<input {#each customEntries as [key, value] (key)}
type="text" <div class="rounded-md border border-neutral-800 bg-neutral-900 p-2">
bind:value={manager.selectedPage!.frontmatter[key][index]} <div class="grid gap-2">
onchange={() => (manager.selectedPage!.dirty = true)} <Input value={key} onchange={(event) => renameCustomField(key, (event.currentTarget as HTMLInputElement).value)} />
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900" {#if Array.isArray(value)}
placeholder="Array item" <div class="space-y-2">
/> {#each arrayValue(key) as item, index}
<button <div class="flex items-center gap-2">
onclick={() => { <Input value={item} oninput={(event) => setArrayItem(key, index, (event.currentTarget as HTMLInputElement).value)} />
manager.selectedPage!.frontmatter[key].splice(index, 1); <Button type="button" variant="ghost" size="icon" onclick={() => removeArrayItem(key, index)}>
manager.selectedPage!.dirty = true; <X class="size-4" />
}} </Button>
class="text-red-500 hover:text-red-700 p-1" </div>
> {/each}
<X class="w-3 h-3" /> <Button type="button" variant="outline" size="sm" onclick={() => addArrayItem(key)}>
</button> <Plus class="mr-2 size-4" />
Add item
</Button>
</div>
{:else if typeof value === "object" && value !== null}
<textarea
class="min-h-24 w-full rounded-md border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={yamlValue(value)}
onchange={(event) => setYamlField(key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else}
<Input value={String(value ?? "")} onchange={(event) => setField(key, (event.currentTarget as HTMLInputElement).value)} />
{/if}
<Button type="button" variant="ghost" size="icon" onclick={() => removeField(key)}>
<X class="size-4" />
</Button>
</div> </div>
{/each} </div>
<button {/each}
onclick={() => { </div>
manager.selectedPage!.frontmatter[key].push(""); {/if}
manager.selectedPage!.dirty = true;
}} <div>
class="text-xs text-blue-500 hover:text-blue-700 ml-8" <Button type="button" variant="outline" size="sm" onclick={addCustomField}>
> <Plus class="mr-2 size-4" />
+ Add item Add custom field
</button> </Button>
</div>
{/if}
</div> </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>
</div> </details>
</details> {/if}
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable"; import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte"; import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte"; import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte"; import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte"; import EditorWithTabs from "./EditorWithTabs.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button"; import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte"; import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus, GitBranch, Upload } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { cn } from "@components/utils"; import { cn } from "@components/utils";
import { pageRepo } from "@components/repo/page"; import { pageRepo } from "@components/repo/page";
@@ -18,16 +17,40 @@
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
</script> </script>
<div class="flex-grow flex flex-col"> <div class="flex min-h-0 flex-grow flex-col bg-neutral-950">
<ResizablePaneGroup direction="horizontal" class="flex-grow"> <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-grow">
<ResizablePane defaultSize={20}> <ResizablePane defaultSize={24} minSize={18} maxSize={36}>
<div class="overflow-y-scroll"> <aside class="flex h-full min-h-0 flex-col border-r border-neutral-800 bg-neutral-950">
<div class="flex p-2 gap-2"> <div class="border-b border-neutral-800 p-3">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h1 class="text-base font-semibold leading-none">Pages</h1>
<p class="mt-1 text-xs text-muted-foreground">Content repository</p>
</div>
<Button
size="icon"
variant="outline"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus class="size-4" />
</Button>
</div>
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<Popover bind:open={branchSelectOpen}> <Popover bind:open={branchSelectOpen}>
<PopoverTrigger> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox"> <Button variant="outline" class="min-w-0 justify-between" {...props} role="combobox">
{manager.branch} <span class="flex min-w-0 items-center gap-2">
<GitBranch class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate">{manager.branch}</span>
</span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
{/snippet} {/snippet}
@@ -62,23 +85,23 @@
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}> <Button size="icon" variant="outline" onclick={() => manager.reloadImages()} title="Refresh images">
<RefreshCw /> <RefreshCw class="size-4" />
</Button> </Button>
<Popover bind:open={imageSelectOpen}> <Popover bind:open={imageSelectOpen}>
<PopoverTrigger> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button size="icon" variant="outline" {...props}> <Button size="icon" variant="outline" {...props} title="Images">
<FileImage /> <FileImage class="size-4" />
</Button> </Button>
{/snippet} {/snippet}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto"> <PopoverContent side="right" class="h-screen w-[960px] max-w-[calc(100vw-2rem)] overflow-y-auto p-0">
{#await manager.imagesLoad} {#await manager.imagesLoad}
<p>Loading images...</p> <p class="p-4 text-sm text-muted-foreground">Loading images...</p>
{:then images} {:then images}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="p-2"> <div class="sticky top-0 z-10 border-b border-neutral-800 bg-neutral-950/95 p-3 backdrop-blur">
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
@@ -100,13 +123,14 @@
}} }}
/> />
<Button onclick={() => fileInput?.click()} class="w-full"> <Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" /> <Upload class="mr-2 size-4" />
Upload Image Upload Image
</Button> </Button>
</div> </div>
<div class="grid grid-cols-4 gap-2 p-2"> <div class="grid grid-cols-3 gap-3 p-3 xl:grid-cols-4">
{#each images as image} {#each images as image}
<button <button
class="overflow-hidden rounded-md border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-primary"
onclick={() => { onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1; const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
@@ -116,7 +140,8 @@
imageSelectOpen = false; imageSelectOpen = false;
}} }}
> >
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" /> <img src={image.downloadUrl} alt={image.name} class="aspect-video w-full object-cover" />
<div class="truncate px-2 py-1.5 text-xs text-muted-foreground">{image.name}</div>
</button> </button>
{/each} {/each}
</div> </div>
@@ -124,31 +149,22 @@
{/await} {/await}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button </div>
size="icon"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus />
</Button>
</div> </div>
<Separator />
{#await manager.pagesLoad} <div class="min-h-0 flex-1 overflow-y-auto py-2">
<p>Loading pages...</p> {#await manager.pagesLoad}
{:then pages} <p class="px-4 py-3 text-sm text-muted-foreground">Loading pages...</p>
{#each Object.values(pages.dirs) as page} {:then pages}
<PagesList {page} path={page.name + "/"} /> {#each Object.values(pages.dirs) as page}
{/each} <PagesList {page} path={page.name + "/"} />
{/await} {/each}
</div> {/await}
</div>
</aside>
</ResizablePane> </ResizablePane>
<ResizableHandle /> <ResizableHandle />
<ResizablePane defaultSize={80}> <ResizablePane defaultSize={76}>
<EditorWithTabs /> <EditorWithTabs />
</ResizablePane> </ResizablePane>
</ResizablePaneGroup> </ResizablePaneGroup>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte"; import { ChevronDown, ChevronRight, Folder, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import type { DirTree } from "./page.svelte"; import type { DirTree } from "./page.svelte";
import PagesList from "./PagesList.svelte"; import PagesList from "./PagesList.svelte";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
@@ -55,19 +55,23 @@
} }
</script> </script>
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}> <button
<div class="flex flex-row items-center"> class="group flex h-8 w-full items-center justify-between px-2 text-left text-sm text-neutral-200 transition-colors hover:bg-neutral-900"
style={`padding-left: ${0.75 + depth * 0.85}rem`}
onclick={() => (open = !open)}
>
<div class="flex min-w-0 flex-row items-center">
{#if open} {#if open}
<ChevronDown class="w-6 h-6" /> <ChevronDown class="mr-1 size-4 shrink-0 text-muted-foreground" />
{:else} {:else}
<ChevronRight class="w-6 h-6" /> <ChevronRight class="mr-1 size-4 shrink-0 text-muted-foreground" />
{/if} {/if}
<Folder class="mr-2 w-4 h-4" /> <Folder class="mr-2 size-4 shrink-0 text-amber-400" />
{page.name}/ <span class="truncate">{page.name}/</span>
</div> </div>
<div class="flex-row items-center hidden group-hover:flex"> <div class="hidden flex-row items-center group-hover:flex">
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}> <Button variant="ghost" size="sm" class="m-0 size-6 p-0" onclick={startNewPageCreate} title="New page">
<FilePlus class="w-3 h-3" /> <FilePlus class="size-3.5" />
</Button> </Button>
</div> </div>
</button> </button>
@@ -76,39 +80,43 @@
<div transition:slide={{ duration: 200, axis: "y" }}> <div transition:slide={{ duration: 200, axis: "y" }}>
<div> <div>
{#if newPage} {#if newPage}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}> <div class="flex h-8 w-full flex-row items-center bg-neutral-900 px-2 py-1" style={`padding-left: ${0.75 + (depth + 1) * 0.85}rem`}>
{#if newPageName.endsWith(".json")} {#if newPageName.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" /> <FileJson class="mr-2 size-4 shrink-0 text-sky-400" />
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")} {:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" /> <FileText class="mr-2 size-4 shrink-0 text-emerald-400" />
{:else} {:else}
<File class="mr-2 w-4 h-4" /> <File class="mr-2 size-4 shrink-0 text-muted-foreground" />
{/if} {/if}
<form onsubmit={createNewPage}> <form onsubmit={createNewPage} class="min-w-0 flex-1">
<input <input
type="text" type="text"
bind:value={newPageName} bind:value={newPageName}
bind:this={newPageInput} bind:this={newPageInput}
onblur={() => (newPage = false)} onblur={() => (newPage = false)}
placeholder="New page name" placeholder="New page name"
class="flex-grow bg-transparent border-none outline-none text-white" class="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-sm text-white outline-none focus:border-primary"
/> />
</form> </form>
</button> </div>
{/if} {/if}
{#each Object.values(page.dirs) as subPage (subPage.name)} {#each Object.values(page.dirs) as subPage (subPage.name)}
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} /> <PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
{/each} {/each}
{#each Object.values(page.files) as file (file.id)} {#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)}> <button
class="flex h-8 w-full min-w-0 flex-row items-center px-2 py-1 text-left text-sm text-muted-foreground transition-colors hover:bg-neutral-900 hover:text-foreground"
style={`padding-left: ${0.75 + (depth + 1) * 0.85}rem`}
onclick={() => manager.openPage(file.id)}
>
{#if file.name.endsWith(".json")} {#if file.name.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" /> <FileJson class="mr-2 size-4 shrink-0 text-sky-400" />
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")} {:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" /> <FileText class="mr-2 size-4 shrink-0 text-emerald-400" />
{:else} {:else}
<File class="mr-2 w-4 h-4" /> <File class="mr-2 size-4 shrink-0" />
{/if} {/if}
{file.name} <span class="truncate">{file.name}</span>
</button> </button>
{/each} {/each}
</div> </div>
@@ -0,0 +1,92 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { eventRepo } from "@repo/event";
import type { ShortEvent } from "@type/event";
import { CalendarDays, Check, ChevronsUpDown } from "lucide-svelte";
const {
value,
onSelect,
}: {
value: number | string | null | undefined;
onSelect: (eventId: number) => void;
} = $props();
let open = $state(false);
let eventsFuture = $state($eventRepo.listEvents());
function selectedEvent(events: ShortEvent[]) {
const eventId = Number(value);
return events.find((event) => event.id === eventId);
}
function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function sortEvents(events: ShortEvent[]) {
return [...events].sort((a, b) => b.start - a.start);
}
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="w-full justify-between" {...props} role="combobox">
{#await eventsFuture}
Loading events...
{:then events}
{@const event = selectedEvent(events)}
{#if event}
<span class="truncate">{event.name} #{event.id}</span>
{:else}
<span class="text-muted-foreground">Select event</span>
{/if}
{:catch}
<span class="text-red-400">Could not load events</span>
{/await}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-[36rem] max-w-[calc(100vw-2rem)] p-0">
<Command>
<CommandInput placeholder="Search events..." />
<CommandList>
<CommandEmpty>No events found.</CommandEmpty>
<CommandGroup>
{#await eventsFuture}
<div class="p-3 text-sm text-muted-foreground">Loading events...</div>
{:then events}
{#each sortEvents(events) as event (event.id)}
<CommandItem
value={`${event.name} ${event.id}`}
onSelect={() => {
onSelect(event.id);
open = false;
}}
>
<Check class="mr-2 size-4 {Number(value) === event.id ? '' : 'text-transparent'}" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm">{event.name}</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarDays class="size-3" />
#{event.id} · {formatDate(event.start)}
</div>
</div>
</CommandItem>
{/each}
{:catch error}
<div class="p-3 text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load events."}</div>
{/await}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
@@ -0,0 +1,74 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Image, Search } from "lucide-svelte";
import { manager } from "../page.svelte";
const {
value,
onSelect,
}: {
value: string | null | undefined;
onSelect: (path: string) => void;
} = $props();
let open = $state(false);
let search = $state("");
function imagePath(path: string) {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
return [...Array(backs).fill(".."), path.replace("src/", "")].join("/");
}
</script>
<div class="flex gap-2">
<Input value={value ?? ""} placeholder="Image path" oninput={(event) => onSelect((event.currentTarget as HTMLInputElement).value)} />
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button type="button" variant="outline" size="icon" {...props}>
<Image class="size-4" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="h-[34rem] w-[56rem] max-w-[calc(100vw-2rem)] overflow-hidden p-0" side="bottom">
<div class="flex items-center gap-2 border-b border-neutral-800 p-3">
<Search class="size-4 text-muted-foreground" />
<Input bind:value={search} placeholder="Search images..." class="border-0 bg-transparent shadow-none focus-visible:ring-0" />
</div>
<div class="h-[30rem] overflow-y-auto p-3">
{#await manager.imagesLoad}
<p class="text-sm text-muted-foreground">Loading images...</p>
{:then images}
{@const filteredImages = images.filter((image) => image.name.toLowerCase().includes(search.toLowerCase()) || image.path.toLowerCase().includes(search.toLowerCase()))}
{#if filteredImages.length === 0}
<p class="text-sm text-muted-foreground">No images found.</p>
{:else}
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
{#each filteredImages as image (image.id)}
{@const path = imagePath(image.path)}
<button
type="button"
class="group overflow-hidden rounded-md border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-primary"
onclick={() => {
onSelect(path);
open = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="aspect-video w-full bg-neutral-950 object-cover" />
<div class="space-y-1 p-2">
<div class="truncate text-xs font-medium">{image.name}</div>
<div class="truncate text-[11px] text-muted-foreground">{path}</div>
</div>
</button>
{/each}
</div>
{/if}
{:catch error}
<p class="text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load images."}</p>
{/await}
</div>
</PopoverContent>
</Popover>
</div>
@@ -0,0 +1,348 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { eventRepo } from "@repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event";
import { Plus, Trash2 } from "lucide-svelte";
type StageType = "GROUP" | "ELEMINATION" | "DOUBLE_ELEMINATION";
type RoundPrefix = "Runde" | "Tag";
const roundGroupingTimeOptions = [
{ label: "10 Minutes", value: 10 },
{ label: "30 Minutes", value: 30 },
{ label: "1 Hour", value: 60 },
{ label: "2 Hours", value: 120 },
{ label: "12 Hours", value: 720 },
];
type StageConfig = {
name: string;
view:
| { type: "GROUP"; groups: number[]; roundRows?: number; roundGroupingTimeMinutes?: number; roundPrefix?: RoundPrefix }
| { type: "ELEMINATION"; finalFight: number }
| { type: "DOUBLE_ELEMINATION"; winnersFinalFight: number; losersFinalFight: number; grandFinalFight: number };
};
type ViewConfig = Record<string, StageConfig>;
const {
value,
eventId,
onChange,
}: {
value: unknown;
eventId: number | string | null | undefined;
onChange: (value: ViewConfig) => void;
} = $props();
let eventFuture: Promise<ExtendedEvent> | undefined = $state();
const config = $derived(normalizeConfig(value));
const selectedEventId = $derived(Number(eventId));
$effect(() => {
if (Number.isFinite(selectedEventId) && selectedEventId > 0) {
eventFuture = $eventRepo.getEvent(selectedEventId.toString());
} else {
eventFuture = undefined;
}
});
function normalizeConfig(raw: unknown): ViewConfig {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return raw as ViewConfig;
}
function cloneConfig(value: ViewConfig): ViewConfig {
return JSON.parse(JSON.stringify(value)) as ViewConfig;
}
function nextConfig(mutator: (draft: ViewConfig) => void) {
const draft = cloneConfig(config);
mutator(draft);
onChange(draft);
}
function addStage() {
nextConfig((draft) => {
let index = Object.keys(draft).length + 1;
let key = `stage${index}`;
while (Object.hasOwn(draft, key)) {
index += 1;
key = `stage${index}`;
}
draft[key] = {
name: "New stage",
view: { type: "GROUP", groups: [], roundRows: 1, roundGroupingTimeMinutes: 10, roundPrefix: "Runde" },
};
});
}
function renameStage(oldKey: string, newKey: string) {
const cleanKey = newKey.trim();
if (!cleanKey || cleanKey === oldKey) {
return;
}
nextConfig((draft) => {
if (Object.hasOwn(draft, cleanKey)) {
alert(`A view config stage named "${cleanKey}" already exists.`);
return;
}
draft[cleanKey] = draft[oldKey];
delete draft[oldKey];
});
}
function removeStage(key: string) {
nextConfig((draft) => {
delete draft[key];
});
}
function setStageName(key: string, name: string) {
nextConfig((draft) => {
draft[key].name = name;
});
}
function setStageType(key: string, type: StageType, event?: ExtendedEvent) {
nextConfig((draft) => {
if (type === "GROUP") {
draft[key].view = { type, groups: [], roundRows: 1, roundGroupingTimeMinutes: 10, roundPrefix: "Runde" };
} else if (type === "ELEMINATION") {
draft[key].view = { type, finalFight: event?.fights[0]?.id ?? 0 };
} else {
draft[key].view = {
type,
winnersFinalFight: event?.fights[0]?.id ?? 0,
losersFinalFight: event?.fights[0]?.id ?? 0,
grandFinalFight: event?.fights[0]?.id ?? 0,
};
}
});
}
function setGroupSelection(key: string, groupId: number, selected: boolean) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type !== "GROUP") {
return;
}
view.groups = selected ? [...new Set([...view.groups, groupId])] : view.groups.filter((id) => id !== groupId);
});
}
function setRoundRows(key: string, value: string) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type === "GROUP") {
view.roundRows = Math.max(1, Number(value) || 1);
}
});
}
function setRoundGroupingTimeMinutes(key: string, value: string) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type === "GROUP") {
view.roundGroupingTimeMinutes = Math.max(1, Math.floor(Number(value) || 10));
}
});
}
function setRoundPrefix(key: string, value: RoundPrefix) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type === "GROUP") {
view.roundPrefix = value;
}
});
}
function setFightParam(key: string, param: "finalFight" | "winnersFinalFight" | "losersFinalFight" | "grandFinalFight", fightId: string) {
nextConfig((draft) => {
const view = draft[key].view as Record<string, unknown>;
view[param] = Number(fightId);
});
}
function groupLabel(group: ResponseGroups) {
return `${group.name} #${group.id}`;
}
function fightLabel(fight: EventFight) {
const start = new Date(fight.start).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return `#${fight.id} ${fight.blueTeam.kuerzel} vs ${fight.redTeam.kuerzel} · ${start}`;
}
</script>
<div class="space-y-3 rounded-md border border-neutral-800 bg-neutral-900 p-3">
{#if !selectedEventId}
<p class="text-sm text-muted-foreground">Select an eventId first to edit the view config with event groups and fights.</p>
{:else if eventFuture}
{#await eventFuture}
<p class="text-sm text-muted-foreground">Loading event context...</p>
{:then event}
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium">{event.event.name}</p>
<p class="text-xs text-muted-foreground">{event.groups.length} groups · {event.fights.length} fights</p>
</div>
<Button type="button" variant="outline" size="sm" onclick={addStage}>
<Plus class="mr-2 size-4" />
Add stage
</Button>
</div>
{#if Object.entries(config).length === 0}
<div class="rounded-md border border-dashed border-neutral-700 p-4 text-sm text-muted-foreground">No stages configured.</div>
{/if}
{#each Object.entries(config) as [key, stage] (key)}
<div class="space-y-3 rounded-md border border-neutral-800 bg-neutral-950 p-3">
<div class="flex items-center justify-between gap-3 border-b border-neutral-800 pb-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{stage.name || key}</p>
<p class="text-xs text-muted-foreground">{stage.view.type}</p>
</div>
<Button type="button" variant="outline" size="sm" onclick={() => removeStage(key)}>
<Trash2 class="mr-2 size-4" />
Remove stage
</Button>
</div>
<div class="grid gap-2">
<div class="space-y-1">
<Label>Key</Label>
<Input value={key} onchange={(event) => renameStage(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
<div class="space-y-1">
<Label>Name</Label>
<Input value={stage.name} oninput={(event) => setStageName(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
<div class="space-y-1">
<Label>Type</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.type}
onchange={(changeEvent) => setStageType(key, (changeEvent.currentTarget as HTMLSelectElement).value as StageType, event)}
>
<option value="GROUP">Group</option>
<option value="ELEMINATION">Elimination</option>
<option value="DOUBLE_ELEMINATION">Double elimination</option>
</select>
</div>
</div>
{#if stage.view.type === "GROUP"}
<div class="space-y-2">
<div class="grid gap-2">
<div>
<Label>Groups</Label>
<div class="mt-2 grid gap-2">
{#each event.groups as group (group.id)}
<label class="flex items-center gap-2 rounded border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm">
<input
type="checkbox"
checked={stage.view.groups.includes(group.id)}
onchange={(changeEvent) => setGroupSelection(key, group.id, (changeEvent.currentTarget as HTMLInputElement).checked)}
/>
<span>{groupLabel(group)}</span>
</label>
{/each}
</div>
</div>
<div class="space-y-1">
<Label>Round rows</Label>
<Input type="number" min="1" value={stage.view.roundRows ?? 1} onchange={(event) => setRoundRows(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
<div class="space-y-1">
<Label>Round grouping time (minutes)</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={(stage.view.roundGroupingTimeMinutes ?? 10).toString()}
onchange={(event) => setRoundGroupingTimeMinutes(key, (event.currentTarget as HTMLSelectElement).value)}
>
{#each roundGroupingTimeOptions as option}
<option value={option.value.toString()}>{option.label}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Round prefix</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.roundPrefix ?? "Runde"}
onchange={(event) => setRoundPrefix(key, (event.currentTarget as HTMLSelectElement).value as RoundPrefix)}
>
<option value="Runde">Runde</option>
<option value="Tag">Tag</option>
</select>
</div>
</div>
</div>
{:else if stage.view.type === "ELEMINATION"}
<div class="space-y-1">
<Label>Final fight</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.finalFight.toString()}
onchange={(event) => setFightParam(key, "finalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
{:else}
<div class="grid gap-2">
<div class="space-y-1">
<Label>Winners final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.winnersFinalFight.toString()}
onchange={(event) => setFightParam(key, "winnersFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Losers final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.losersFinalFight.toString()}
onchange={(event) => setFightParam(key, "losersFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Grand final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.grandFinalFight.toString()}
onchange={(event) => setFightParam(key, "grandFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
</div>
{/if}
</div>
{/each}
{:catch error}
<p class="text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load event context."}</p>
{/await}
{/if}
</div>
@@ -3,13 +3,18 @@ import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page"; import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store"; import { get } from "svelte/store";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { getMarkdownFrontmatterSchema, type FrontmatterCollectionSchema } from "../../../../content/frontmatter-editor-schemas";
type FrontmatterValue = string | string[] | number | boolean | Date | Record<string, unknown> | unknown[] | null;
export class OpenEditPage { export class OpenEditPage {
public content: string = ""; public content: string = "";
public frontmatter: { [key: string]: string | string[] | Date } = $state({}); public frontmatter: { [key: string]: FrontmatterValue } = $state({});
public dirty: boolean = $state(false); public dirty: boolean = $state(false);
public readonly fileType: string; public readonly fileType: string;
public readonly collection: string | undefined;
public readonly frontmatterSchema: FrontmatterCollectionSchema | undefined;
public constructor( public constructor(
private manager: PageManager, private manager: PageManager,
@@ -20,6 +25,8 @@ export class OpenEditPage {
public readonly path: string public readonly path: string
) { ) {
this.fileType = this.path.split(".").pop() || "md"; this.fileType = this.path.split(".").pop() || "md";
this.collection = this.resolveContentCollection();
this.frontmatterSchema = getMarkdownFrontmatterSchema(this.collection);
this.content = this.removeFrontmatter(originalContent); this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent); this.frontmatter = this.parseFrontmatter(originalContent);
@@ -31,16 +38,14 @@ export class OpenEditPage {
} }
let contentToSave = ""; let contentToSave = "";
if (this.frontmatter) { if (this.hasFrontmatter()) {
contentToSave += "---\n"; contentToSave += "---\n";
contentToSave += yaml.dump(this.frontmatter); contentToSave += yaml.dump(this.frontmatter);
contentToSave += "---\n\n"; contentToSave += "---\n\n";
} }
contentToSave += this.content; contentToSave += this.content;
const encodedContent = btoa(new TextEncoder().encode(contentToSave).reduce((data, byte) => data + String.fromCharCode(byte), ""));
console.log(encodedContent); await get(pageRepo).updatePage(this.pageId, contentToSave, this.sha, prompt("Was hast du geändert?", `Updated ${this.pageTitle}`) ?? `Updated ${this.pageTitle}`, this.manager.branch);
//await get(pageRepo).updatePage(this.pageId, this.sha, encodedContent, this.manager.branch);
this.dirty = false; this.dirty = false;
this.manager.reloadImages(); this.manager.reloadImages();
} }
@@ -56,31 +61,49 @@ export class OpenEditPage {
return false; return false;
} }
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } { public supportsFrontmatter(): boolean {
return (this.fileType === "md" || this.fileType === "mdx") && !!this.collection;
}
private hasFrontmatter(): boolean {
return this.supportsFrontmatter() && Object.keys(this.frontmatter).length > 0;
}
private resolveContentCollection(): string | undefined {
const match = this.path.match(/^src\/content\/([^/]+)\//);
return match?.[1];
}
private parseFrontmatter(content: string): { [key: string]: FrontmatterValue } {
if (!this.supportsFrontmatter()) {
return {};
}
const lines = content.split("\n"); const lines = content.split("\n");
let inFrontmatter = false; let inFrontmatter = false;
const frontmatterLines: string[] = []; const frontmatterLines: string[] = [];
for (const line of lines) { if (lines[0]?.trim() !== "---") {
if (line.trim() === "---") { return {};
if (inFrontmatter) {
break; // End of frontmatter
}
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
frontmatterLines.push(line);
}
} }
if (frontmatterLines.length === 0) { for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "---") {
inFrontmatter = false;
break;
}
inFrontmatter = true;
frontmatterLines.push(line);
}
if (inFrontmatter || frontmatterLines.length === 0) {
return {}; return {};
} }
try { try {
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: FrontmatterValue };
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
} catch (error) { } catch (error) {
console.error("Failed to parse YAML frontmatter:", error); console.error("Failed to parse YAML frontmatter:", error);
return {}; return {};
@@ -88,21 +111,21 @@ export class OpenEditPage {
} }
private removeFrontmatter(content: string): string { private removeFrontmatter(content: string): string {
const lines = content.split("\n"); if (!this.supportsFrontmatter()) {
let inFrontmatter = false; return content;
const result: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (!inFrontmatter) {
result.push(line);
}
} }
return result.join("\n").trim(); const lines = content.split("\n");
if (lines[0]?.trim() !== "---") {
return content;
}
const endIndex = lines.slice(1).findIndex((line) => line.trim() === "---");
if (endIndex === -1) {
return content;
}
return lines.slice(endIndex + 2).join("\n").trim();
} }
} }
@@ -138,7 +161,7 @@ export class PageManager {
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater))); 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))); public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
private _t<T>(n: number): (v: T) => T { private _t<T>(_n: number): (v: T) => T {
return (v: T) => v; return (v: T) => v;
} }
@@ -18,24 +18,27 @@
--> -->
<script lang="ts"> <script lang="ts">
import {permissions, players} from "@stores/stores.ts"; import { permissions } from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select"; import { Select, SelectContent, SelectItem } from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js"; import { SelectTrigger } from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts"; import { permsRepo } from "@repo/perms.ts";
const { const { perms, uuid }: { perms: string[]; uuid: string } = $props();
perms, uuid
}: { perms: string[], uuid: string } = $props();
let value = $state(perms); let value = $state(perms);
let prevValue = $state(perms); let prevValue = $state(perms);
$effect(() => {
value = perms;
prevValue = perms;
});
function onChange(change: string[]) { function onChange(change: string[]) {
$permissions.perms.forEach(perm => { $permissions.perms.forEach((perm) => {
if (prevValue.includes(perm) && !change.includes(perm)) { if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm) $permsRepo.removePerm(uuid, perm);
} else if (!prevValue.includes(perm) && change.includes(perm)) { } else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm) $permsRepo.addPerm(uuid, perm);
} }
}); });
@@ -17,16 +17,132 @@
- along with this program. If not, see <https://www.gnu.org/licenses/>. - along with this program. If not, see <https://www.gnu.org/licenses/>.
--> -->
<script> <script lang="ts">
import Table from "@components/moderator/pages/players/Table.svelte"; import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import {dataRepo} from "@repo/data"; let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let playersFuture = $state($dataRepo.getPlayers()) let search = $state("");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<Player[]>([]);
let rows = $state(0);
$effect(() => {
$dataRepo.queryPlayers(search || undefined, undefined, undefined, pagination.pageSize, pagination.pageIndex, true, false).then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
</script> </script>
{#await playersFuture} <div class="p-4">
<p>Loading...</p> <div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
{:then players} <Input
<Table data={players} /> class="w-48 mr-2"
{/await} placeholder="Search players..."
value={search}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
/>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No players found.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>
@@ -18,16 +18,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import {permissions} from "@stores/stores.ts"; import { permissions } from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts"; import { permsRepo } from "@repo/perms.ts";
const { const { prefix, uuid }: { prefix: string; uuid: string } = $props();
prefix, uuid
}: { prefix: string, uuid: string } = $props();
let value = $state(prefix); let value = $state(prefix);
$effect(() => {
value = prefix;
});
function onChange(change: string) { function onChange(change: string) {
$permsRepo.setPrefix(uuid, change); $permsRepo.setPrefix(uuid, change);
@@ -1,174 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
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>
@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {ColumnDef} from "@tanstack/table-core"; import type { ColumnDef } from "@tanstack/table-core";
import type {Player} from "@type/data.ts"; import type { Player } from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table"; import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte"; import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte"; import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
@@ -36,25 +36,20 @@ export const columns: ColumnDef<Player[]> = [
accessorKey: "prefix", accessorKey: "prefix",
header: "Prefix", header: "Prefix",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent( return renderComponent(PrefixDropdown, {
PrefixDropdown, { prefix: row.getValue("prefix"),
prefix: row.getValue("prefix"), uuid: row.getValue("uuid"),
uuid: row.getValue("uuid"), });
},
);
}, },
}, },
{ {
accessorKey: "perms", accessorKey: "perms",
header: "Permissions", header: "Permissions",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent( return renderComponent(PermissionsDropdown, {
PermissionsDropdown, perms: row.getValue("perms"),
{ uuid: row.getValue("uuid"),
perms: row.getValue("perms"), });
uuid: row.getValue("uuid"),
},
);
}, },
}, },
]; ];
+40
View File
@@ -0,0 +1,40 @@
import { derived } from "svelte/store";
import { fetchWithToken, tokenStore } from "./repo";
import { PagedAutidLogSchema } from "@components/types/auditlog";
export class AuditLogRepo {
async get(
actionText: string | undefined,
serverText: string | undefined,
fullText: string | undefined,
actor: number[] | undefined,
actionType: string[] | undefined,
timeFrom: number | undefined,
timeTo: number | undefined,
serverOwner: number[] | undefined,
velocity: boolean | undefined,
page: number,
pageSize: number,
sorting: string | undefined
) {
const params = new URLSearchParams();
if (actionText) params.append("actionText", actionText);
if (serverText) params.append("serverText", serverText);
if (fullText) params.append("fullText", fullText);
if (actor) actor.forEach((a) => params.append("actor", a.toString()));
if (actionType) actionType.forEach((a) => params.append("actionType", a));
if (timeFrom) params.append("timeGreater", timeFrom.toString());
if (timeTo) params.append("timeLess", timeTo.toString());
if (serverOwner) serverOwner.forEach((s) => params.append("serverOwner", s.toString()));
if (velocity !== undefined) params.append("velocity", velocity.toString());
params.append("page", page.toString());
params.append("limit", pageSize.toString());
if (sorting) params.append("sorting", sorting);
return await fetchWithToken("", `/auditlog?${params.toString()}`)
.then((value) => value.json())
.then((data) => PagedAutidLogSchema.parse(data));
}
}
export const auditLog = derived(tokenStore, ($token) => new AuditLogRepo());
+40 -130
View File
@@ -1,7 +1,7 @@
/* /*
* This file is a part of the SteamWar software. * This file is a part of the SteamWar software.
* *
* Copyright (C) 2025 SteamWar.de-Serverteam * Copyright (C) 2026 SteamWar.de-Serverteam
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
@@ -17,49 +17,34 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {readable, writable} from "svelte/store"; import { readable, writable } from "svelte/store";
import dayjs, {type Dayjs} from "dayjs"; import { PlayerSchema } from "@components/types/data";
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
export class AuthV2Repo { export class AuthV2Repo {
private accessToken: string | undefined;
private accessTokenExpires: Dayjs | undefined;
private refreshToken: string | undefined;
private refreshTokenExpires: Dayjs | undefined;
constructor() { constructor() {
if (typeof localStorage === "undefined") { this.request("/data/me").then((value) => {
return; if (value.ok) {
} loggedIn.set(true);
} else {
this.accessToken = localStorage.getItem("sw-access-token") ?? undefined; loggedIn.set(false);
if (this.accessToken) { }
this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? ""); });
}
this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined;
if (this.refreshToken) {
loggedIn.set(true);
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
}
} }
async login(name: string, password: string) { async login(name: string, password: string) {
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
throw new Error("Already logged in");
}
try { try {
const login = await this.request("/auth", { await this.request("/auth", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
name, name,
password, password,
keepLoggedIn: true, keepLoggedIn: true,
}), }),
}).then(value => value.json()).then(value => AuthTokenSchema.parse(value)); })
.then((value) => value.json())
.then((value) => PlayerSchema.parse(value));
this.setLoginState(login); loggedIn.set(true);
return true; return true;
} catch (e) { } catch (e) {
@@ -67,118 +52,43 @@ export class AuthV2Repo {
} }
} }
async logout() { async loginDiscord(token: string) {
if (this.accessToken === undefined) { try {
return; await this.request("/auth/discord", {
method: "POST",
body: token,
headers: {
"Content-Type": "text/plain",
},
})
.then((value) => value.json())
.then((value) => PlayerSchema.parse(value));
loggedIn.set(true);
return true;
} catch (e) {
return false;
} }
}
async logout() {
await this.request("/auth", { await this.request("/auth", {
method: "DELETE", method: "DELETE",
}); });
this.resetAccessToken();
this.resetRefreshToken();
}
private setLoginState(tokens: AuthToken) {
this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires));
this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires));
loggedIn.set(true);
}
private setAccessToken(token: string, expires: Dayjs) {
this.accessToken = token;
this.accessTokenExpires = expires;
localStorage.setItem("sw-access-token", token);
localStorage.setItem("sw-access-token-expires", expires.toString());
}
private resetAccessToken() {
if (this.accessToken === undefined) {
return;
}
this.accessToken = undefined;
this.accessTokenExpires = undefined;
localStorage.removeItem("sw-access-token");
localStorage.removeItem("sw-access-token-expires");
}
private setRefreshToken(token: string, expires: Dayjs) {
this.refreshToken = token;
this.refreshTokenExpires = expires;
localStorage.setItem("sw-refresh-token", token);
localStorage.setItem("sw-refresh-token-expires", expires.toString());
}
private resetRefreshToken() {
if (this.refreshToken === undefined) {
return;
}
this.refreshToken = undefined;
this.refreshTokenExpires = undefined;
localStorage.removeItem("sw-refresh-token");
localStorage.removeItem("sw-refresh-token-expires");
loggedIn.set(false); loggedIn.set(false);
} }
private async refresh() { async request(url: string, params: RequestInit = {}) {
if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) { return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {
this.resetRefreshToken(); ...params,
this.resetAccessToken(); credentials: "include",
return;
}
const response = await this.requestWithToken(this.refreshToken!, "/auth", {
method: "PUT",
}).then(value => {
if (value.status === 401) {
this.resetRefreshToken();
this.resetAccessToken();
return undefined;
}
return value.json();
}).then(value => AuthTokenSchema.parse(value));
this.setLoginState(response);
}
async request(url: string, params: RequestInit = {}, retryCount: number = 0) {
if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
await this.refresh();
}
return this.requestWithToken(this.accessToken ?? "", url, params, retryCount);
}
private async requestWithToken(token: string, url: string, params: RequestInit = {}, retryCount: number = 0): Promise<Response> {
if (retryCount >= 3) {
throw new Error("Too many retries");
}
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
headers: { headers: {
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}), "Content-Type": "application/json",
"Content-Type": "application/json", ...params.headers, ...params.headers,
}, },
}) });
.then(async value => {
if (value.status === 401 && url !== "/auth") {
try {
await this.refresh();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) { /* empty */ }
return this.request(url, params, retryCount + 1);
}
return value;
});
} }
} }
export const loggedIn = writable(false); export const loggedIn = writable<boolean | undefined>(undefined);
export const authV2Repo = readable(new AuthV2Repo()); export const authV2Repo = readable(new AuthV2Repo());
+23 -5
View File
@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Player, Server } from "@type/data.ts"; import type { Player, PlayerList, Server } from "@type/data.ts";
import { PlayerSchema, ServerSchema } from "@type/data.ts"; import { PlayerListSchema, PlayerSchema, ServerSchema } from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts"; import { fetchWithToken, tokenStore } from "./repo.ts";
import { derived, get } from "svelte/store"; import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts"; import { TeamSchema, type Team } from "@components/types/team.ts";
@@ -38,10 +38,28 @@ export class DataRepo {
.then(PlayerSchema.parse); .then(PlayerSchema.parse);
} }
public async getPlayers(): Promise<Player[]> { public async queryPlayers(
return await fetchWithToken(get(tokenStore), "/data/admin/users") name: string | undefined,
uuid: string | undefined,
team: number[] | undefined,
limit: number | undefined,
page: number | undefined,
includePerms: boolean | undefined,
includeId: boolean | undefined
): Promise<PlayerList> {
let query = new URLSearchParams();
if (name) query.append("name", name);
if (uuid) query.append("uuid", uuid);
if (team) team.forEach((t) => query.append("team", t.toString()));
if (limit) query.append("limit", limit.toString());
if (page) query.append("page", page.toString());
if (includePerms !== undefined) query.append("includePerms", includePerms.toString());
if (includeId !== undefined) query.append("includeId", includeId.toString());
return await fetchWithToken(this.token, "/data/admin/users?" + query.toString())
.then((value) => value.json()) .then((value) => value.json())
.then(PlayerSchema.array().parse); .then(PlayerListSchema.parse);
} }
public async getTeams(): Promise<Team[]> { public async getTeams(): Promise<Team[]> {
+8 -8
View File
@@ -36,7 +36,7 @@ import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEve
import { z } from "zod"; import { z } from "zod";
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import { derived } from "svelte/store"; import { derived } from "svelte/store";
import { ResponseUserSchema } from "@components/types/data"; import { PlayerSchema, type Player } from "@components/types/data";
export interface CreateEvent { export interface CreateEvent {
name: string; name: string;
@@ -150,7 +150,7 @@ export class EventRepo {
.then((value) => value.json()) .then((value) => value.json())
.then((value) => z.array(ResponseGroupsSchema).parse(value)); .then((value) => z.array(ResponseGroupsSchema).parse(value));
} }
public async createGroup(eventId: string, group: CreateEventGroup): Promise<ResponseGroups> { public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
CreateEventGroupSchema.parse(group); CreateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups`, { return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
method: "POST", method: "POST",
@@ -186,12 +186,12 @@ export class EventRepo {
} }
// Relations // Relations
public async listRelations(eventId: string): Promise<ResponseRelation[]> { public async listRelations(eventId: number): Promise<ResponseRelation[]> {
return await fetchWithToken(this.token, `/events/${eventId}/relations`) return await fetchWithToken(this.token, `/events/${eventId}/relations`)
.then((value) => value.json()) .then((value) => value.json())
.then((value) => z.array(ResponseRelationSchema).parse(value)); .then((value) => z.array(ResponseRelationSchema).parse(value));
} }
public async createRelation(eventId: string, relation: CreateEventRelation): Promise<ResponseRelation> { public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
CreateEventRelationSchema.parse(relation); CreateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations`, { return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
method: "POST", method: "POST",
@@ -206,7 +206,7 @@ export class EventRepo {
.then((value) => value.json()) .then((value) => value.json())
.then(ResponseRelationSchema.parse); .then(ResponseRelationSchema.parse);
} }
public async updateRelation(eventId: string, relationId: string, relation: UpdateEventRelation): Promise<ResponseRelation> { public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
UpdateEventRelationSchema.parse(relation); UpdateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, { return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "PUT", method: "PUT",
@@ -216,7 +216,7 @@ export class EventRepo {
.then((value) => value.json()) .then((value) => value.json())
.then(ResponseRelationSchema.parse); .then(ResponseRelationSchema.parse);
} }
public async deleteRelation(eventId: string, relationId: string): Promise<boolean> { public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, { const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "DELETE", method: "DELETE",
}); });
@@ -247,10 +247,10 @@ export class EventRepo {
} }
// Referees // Referees
public async listReferees(eventId: string): Promise<ResponseUser[]> { public async listReferees(eventId: string): Promise<Player[]> {
return await fetchWithToken(this.token, `/events/${eventId}/referees`) return await fetchWithToken(this.token, `/events/${eventId}/referees`)
.then((value) => value.json()) .then((value) => value.json())
.then((value) => z.array(ResponseUserSchema).parse(value)); .then((value) => z.array(PlayerSchema).parse(value));
} }
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> { public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, { const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
+1 -2
View File
@@ -31,7 +31,7 @@ export interface CreateFight {
redTeam: number; redTeam: number;
start: Dayjs; start: Dayjs;
spectatePort: number | null; spectatePort: number | null;
group: string | null; group: number | null;
} }
export interface UpdateFight { export interface UpdateFight {
@@ -57,7 +57,6 @@ export class FightRepo {
return await fetchWithToken(this.token, `/events/${eventId}/fights`, { return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
event: eventId,
spielmodus: fight.spielmodus, spielmodus: fight.spielmodus,
map: fight.map, map: fight.map,
blueTeam: fight.blueTeam, blueTeam: fight.blueTeam,
+16 -4
View File
@@ -17,20 +17,31 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {readonly, writable} from "svelte/store"; import { readonly, writable } from "svelte/store";
import type {Readable, Subscriber, Unsubscriber} from "svelte/store"; import type { Readable, Subscriber, Unsubscriber } from "svelte/store";
export interface Cached<T> extends Readable<T> { export interface Cached<T> extends Readable<T> {
reload: () => void; reload: () => void;
future: Promise<T>;
} }
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> { export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
const store = writable<T>(normal); const store = writable<T>(normal);
const future = new Promise<T>((resolve) => {
let f = false;
store.subscribe((value) => {
if (f) {
resolve(value);
} else {
f = true;
}
});
});
let first = true; let first = true;
const reload = () => { const reload = () => {
init().then(data => { init().then((data) => {
store.set(data); store.set(data);
}); });
}; };
@@ -45,6 +56,7 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
return store.subscribe(run, invalidate); return store.subscribe(run, invalidate);
}, },
reload, reload,
future,
}; };
} }
@@ -58,7 +70,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
let first = true; let first = true;
const reload = () => { const reload = () => {
init(arg).then(data => { init(arg).then((data) => {
store.set(data); store.set(data);
}); });
}; };
+1 -5
View File
@@ -31,10 +31,6 @@ 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 () => {
return get(dataRepo).getPlayers();
});
export const teams = cached<Team[]>([], async () => { export const teams = cached<Team[]>([], async () => {
return get(dataRepo).getTeams(); return get(dataRepo).getTeams();
}); });
@@ -55,7 +51,7 @@ export const gamemodes = cached<string[]>([], async () => {
}); });
export const maps = cachedFamily<string, string[]>([], async (gamemode) => { export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
if (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`); const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
if (!res.ok) { if (!res.ok) {
+27 -39
View File
@@ -18,36 +18,21 @@
--> -->
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy'; import { preventDefault } from "svelte/legacy";
import { import { ChevronDoubleLeftOutline, ChevronDoubleRightOutline, ChevronLeftOutline, ChevronRightOutline } from "flowbite-svelte-icons";
ChevronDoubleLeftOutline,
ChevronDoubleRightOutline,
ChevronLeftOutline,
ChevronRightOutline,
} from "flowbite-svelte-icons";
interface Props {
page?: number;
maxPage: number;
firstUrl?: string;
lastUrl?: string;
previousUrl?: string;
nextUrl?: string;
pagesUrl?: (i: number) => string;
}
let { page = $bindable(0), maxPage, firstUrl = "#", lastUrl = "#", previousUrl = "#", nextUrl = "#", pagesUrl = () => "#" }: Props = $props();
interface Props {
page?: number;
maxPage: number;
firstUrl?: string;
lastUrl?: string;
previousUrl?: string;
nextUrl?: string;
pagesUrl?: (i: number) => string;
}
let {
page = $bindable(0),
maxPage,
firstUrl = "#",
lastUrl = "#",
previousUrl = "#",
nextUrl = "#",
pagesUrl = () => "#"
}: Props = $props();
const previous = () => { const previous = () => {
page = Math.max(page - 1, 0); page = Math.max(page - 1, 0);
@@ -56,20 +41,23 @@
const next = () => { const next = () => {
page = Math.min(page + 1, maxPage - 1); page = Math.min(page + 1, maxPage - 1);
}; };
let pages = $derived(new Array(maxPage).fill(0) let pages = $derived(
.map((_, i) => i + 1) new Array(maxPage)
//.slice(Math.max(page - 2, 0) - Math.abs(Math.max(page + 3 - maxPage, 0)), Math.min(page + 3, maxPage) + Math.abs(Math.min(page - 2, 0))) .fill(0)
.map(i => ({ .map((_, i) => i + 1)
name: i.toString(), //.slice(Math.max(page - 2, 0) - Math.abs(Math.max(page + 3 - maxPage, 0)), Math.min(page + 3, maxPage) + Math.abs(Math.min(page - 2, 0)))
active: i === page + 1, .map((i) => ({
i: i - 1 name: i.toString(),
}))); active: i === page + 1,
i: i - 1,
})),
);
</script> </script>
<div class="w-full flex justify-center mt-4"> <div class="w-full flex justify-center mt-4">
<ul class="inline-flex flex-wrap"> <ul class="inline-flex flex-wrap gap-1">
<li> <li>
<a href={firstUrl} onclick={preventDefault(() => page = 0)} class="btn btn-neutral h-8 px-3 text-sm flex items-center !m-0 !rounded-r-none"> <a href={firstUrl} onclick={preventDefault(() => (page = 0))} class="btn btn-neutral h-8 px-3 text-sm flex items-center !m-0 !rounded-r-none">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>
<ChevronDoubleLeftOutline class="w-3 h-3" /> <ChevronDoubleLeftOutline class="w-3 h-3" />
</a> </a>
@@ -82,7 +70,7 @@
</li> </li>
{#each pages as p} {#each pages as p}
<li> <li>
<a href={pagesUrl(p.i)} onclick={preventDefault(() => page = p.i)} class="btn h-8 px-3 text-sm flex items-center !m-0 !rounded-none" class:btn-neutral={!p.active}> <a href={pagesUrl(p.i)} onclick={preventDefault(() => (page = p.i))} class="btn h-8 px-3 text-sm flex items-center !m-0 !rounded-none" class:btn-neutral={!p.active}>
<span>{p.name}</span> <span>{p.name}</span>
</a> </a>
</li> </li>
@@ -94,7 +82,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href={lastUrl} onclick={preventDefault(() => page = maxPage - 1)} class="btn btn-neutral h-8 px-3 text-sm flex items-center !m-0 !rounded-l-none"> <a href={lastUrl} onclick={preventDefault(() => (page = maxPage - 1))} class="btn btn-neutral h-8 px-3 text-sm flex items-center !m-0 !rounded-l-none">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>
<ChevronDoubleRightOutline class="w-3 h-3" /> <ChevronDoubleRightOutline class="w-3 h-3" />
</a> </a>
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
export const AuditLogEntrySchema = z.object({
id: z.number(),
time: z.number(),
server: z.string(),
serverOwner: z.string().nullable(),
actor: z.string(),
actionType: z.enum(["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"]),
actionText: z.string(),
});
export const PagedAutidLogSchema = z.object({
entries: z.array(AuditLogEntrySchema),
rows: z.number(),
});
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
export type PagedAuditLog = z.infer<typeof PagedAutidLogSchema>;
+10 -2
View File
@@ -29,12 +29,20 @@ export type SchematicType = z.infer<typeof SchematicTypeSchema>;
export const PlayerSchema = z.object({ export const PlayerSchema = z.object({
name: z.string(), name: z.string(),
uuid: z.string(), uuid: z.string(),
prefix: z.string(), prefix: z.string().nullable(),
perms: z.array(z.string()), perms: z.array(z.string()).nullable(),
id: z.number().nullable(),
}); });
export type Player = z.infer<typeof PlayerSchema>; export type Player = z.infer<typeof PlayerSchema>;
export const PlayerListSchema = z.object({
entries: z.array(PlayerSchema),
rows: z.number(),
});
export type PlayerList = z.infer<typeof PlayerListSchema>;
export const ServerSchema = z.object({ export const ServerSchema = z.object({
description: z.any(), description: z.any(),
players: z.object({ players: z.object({
+6 -5
View File
@@ -19,7 +19,7 @@
import { z } from "zod"; import { z } from "zod";
import { TeamSchema } from "./team.js"; import { TeamSchema } from "./team.js";
import { PlayerSchema, ResponseUserSchema } from "./data.js"; import { PlayerSchema } from "./data.js";
export const ResponseGroupsSchema = z.object({ export const ResponseGroupsSchema = z.object({
id: z.number(), id: z.number(),
@@ -60,10 +60,11 @@ export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
export const ResponseRelationSchema = z.object({ export const ResponseRelationSchema = z.object({
id: z.number(), id: z.number(),
fight: EventFightSchema, fight: z.number(),
team: z.enum(["RED", "BLUE"]),
type: z.enum(["FIGHT", "GROUP"]), type: z.enum(["FIGHT", "GROUP"]),
fromFight: EventFightSchema.nullable(), fromFight: EventFightSchema.optional(),
fromGroup: ResponseGroupsSchema.nullable(), fromGroup: ResponseGroupsSchema.optional(),
fromPlace: z.number(), fromPlace: z.number(),
}); });
@@ -92,7 +93,7 @@ export const ExtendedEventSchema = z.object({
teams: z.array(TeamSchema), teams: z.array(TeamSchema),
groups: z.array(ResponseGroupsSchema), groups: z.array(ResponseGroupsSchema),
fights: z.array(EventFightSchema), fights: z.array(EventFightSchema),
referees: z.array(ResponseUserSchema), referees: z.array(PlayerSchema),
relations: z.array(ResponseRelationSchema), relations: z.array(ResponseRelationSchema),
}); });
+2 -2
View File
@@ -17,11 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {z} from "zod"; import { z } from "zod";
export const PrefixSchema = z.object({ export const PrefixSchema = z.object({
name: z.string().startsWith("PREFIX_"), name: z.string().startsWith("PREFIX_"),
colorCode: z.string().length(2).startsWith("§"), colorCode: z.string().startsWith("§"),
chatPrefix: z.string(), chatPrefix: z.string(),
}); });
+2 -2
View File
@@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {z} from "zod"; import { z } from "zod";
export const TeamSchema = z.object({ export const TeamSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
kuerzel: z.string().min(1).max(4), kuerzel: z.string().min(1).max(16),
color: z.string().max(1), color: z.string().max(1),
}); });
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown } from "lucide-svelte";
import { cn } from "@components/utils";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
let {
value = $bindable(null),
multiple = false,
placeholder = "Select player...",
onSelect,
}: {
value?: number | number[] | null;
multiple?: boolean;
placeholder?: string;
onSelect?: (player: Player) => void;
} = $props();
let open = $state(false);
let search = $state("");
let players: Player[] = $state([]);
let loading = $state(false);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 50, 0, false, true);
players = res.entries;
} finally {
loading = false;
}
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
function handleSelect(player: Player) {
if (onSelect) {
onSelect(player);
}
if (multiple) {
if (Array.isArray(value)) {
if (value.includes(player.id!)) {
value = value.filter((v) => v !== player.id);
} else {
value = [...value, player.id!];
}
} else {
value = [player.id!];
}
} else {
if (value === player.id) {
value = null; // Deselect
} else {
value = player.id;
open = false;
}
}
}
function isSelected(id: number) {
if (multiple) {
return Array.isArray(value) && value.includes(id);
}
return value === id;
}
let triggerLabel = $derived.by(() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
return `${placeholder} (${value.length})`;
}
return placeholder;
} else {
// We might need to fetch the selected player's name if it's not in the current list
// For now, let's just show the placeholder or "Selected"
// Ideally we would have a way to resolve the name from the UUID if it's not in `players`
// But `players` only contains search results.
// If we want to show the name, we might need to fetch it or pass it in.
// Given the context of AuditLog, it shows "Spieler Filter (count)".
// Given RefereesList, it's a button "Hinzufügen".
return placeholder;
}
});
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class={cn("justify-between", Array.isArray(value) && !value?.length && "text-muted-foreground")} {...props} role="combobox" aria-expanded={open}>
{triggerLabel}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={search} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No players found.</CommandEmpty>
<CommandGroup>
{#each players as player (player.uuid)}
<CommandItem value={player.id?.toString()} onSelect={() => handleSelect(player)}>
<Check class={cn("mr-2 size-4", isSelected(player.id!) ? "opacity-100" : "opacity-0")} />
{player.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
@@ -24,14 +24,15 @@
import { ScrollArea } from "$lib/components/ui/scroll-area"; import { ScrollArea } from "$lib/components/ui/scroll-area";
import { CalendarIcon } from "lucide-svelte"; import { CalendarIcon } from "lucide-svelte";
import { cn } from "@components/utils"; import { cn } from "@components/utils";
import type {ZonedDateTime} from "@internationalized/date"; import { fromDate, type ZonedDateTime } from "@internationalized/date";
import Input from "../input/input.svelte";
let { let {
value = $bindable(), value = $bindable(),
onChange onChange,
}: { }: {
value: ZonedDateTime value: ZonedDateTime;
onChange?: ((date: ZonedDateTime | undefined) => void) | undefined onChange?: ((date: ZonedDateTime | undefined) => void) | undefined;
} = $props(); } = $props();
let isOpen = $state(false); let isOpen = $state(false);
@@ -63,13 +64,7 @@
<Popover bind:open={isOpen}> <Popover bind:open={isOpen}>
<PopoverTrigger> <PopoverTrigger>
<Button <Button variant="outline" class={cn("w-full justify-start text-left font-normal", !value && "text-muted-foreground")}>
variant="outline"
class={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
{#if value} {#if value}
{new Intl.DateTimeFormat("de-DE", { {new Intl.DateTimeFormat("de-DE", {
@@ -86,23 +81,14 @@
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0"> <PopoverContent class="w-auto p-0">
<Input type="datetime-local" value={value.toString().slice(0, 16)} onchange={(e) => handleDateSelect(fromDate(e.target.valueAsDate, "Europe/Berlin"))} />
<div class="sm:flex"> <div class="sm:flex">
<Calendar <Calendar mode="single" bind:value onValueChange={(date) => handleDateSelect(date)} initialFocus />
mode="single"
bind:value
onValueChange={(date) => handleDateSelect(date)}
initialFocus
/>
<div class="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x"> <div class="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x">
<ScrollArea class="w-64 sm:w-auto"> <ScrollArea class="w-64 sm:w-auto">
<div class="flex sm:flex-col p-2"> <div class="flex sm:flex-col p-2">
{#each [...hours].reverse() as hour} {#each [...hours].reverse() as hour}
<Button <Button size="icon" variant={value && value.hour === hour ? "default" : "ghost"} class="sm:w-full shrink-0 aspect-square" onclick={() => handleTimeChange("hour", hour)}>
size="icon"
variant={value && value.hour === hour ? "default" : "ghost"}
class="sm:w-full shrink-0 aspect-square"
onclick={() => handleTimeChange("hour", hour)}
>
{hour} {hour}
</Button> </Button>
{/each} {/each}
@@ -113,12 +99,12 @@
<div class="flex sm:flex-col p-2"> <div class="flex sm:flex-col p-2">
{#each Array.from({ length: 60 }, (_, i) => i) as minute} {#each Array.from({ length: 60 }, (_, i) => i) as minute}
<Button <Button
size="icon" size="icon"
variant={value && value.minute === minute ? "default" : "ghost"} variant={value && value.minute === minute ? "default" : "ghost"}
class="sm:w-full shrink-0 aspect-square" class="sm:w-full shrink-0 aspect-square"
onclick={() => handleTimeChange("minute", minute)} onclick={() => handleTimeChange("minute", minute)}
> >
{minute.toString().padStart(2, '0')} {minute.toString().padStart(2, "0")}
</Button> </Button>
{/each} {/each}
</div> </div>
+3 -26
View File
@@ -1,29 +1,6 @@
import Root from "./input.svelte"; import Root from "./input.svelte";
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
};
export type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>;
};
export { export {
Root, Root,
// //
Root as Input, Root as Input,
}; };
+36 -39
View File
@@ -1,42 +1,39 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements"; import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { InputEvents } from "./index.js"; import { cn } from "@components/utils";
import { cn } from "$lib/components/utils.js"; import { type WithElementRef } from "bits-ui";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type $$Props = HTMLInputAttributes; type Props = WithElementRef<Omit<HTMLInputAttributes, "type"> & ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })>;
type $$Events = InputEvents; let { ref = $bindable(null), value = $bindable(), type, files = $bindable(), class: className, ...restProps }: Props = $props();
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
</script> </script>
<input {#if type === "file"}
class={cn( <input
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", bind:this={ref}
className data-slot="input"
)} class={cn(
bind:value "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
{readonly} "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
on:blur "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
on:change className
on:click )}
on:focus type="file"
on:focusin bind:files
on:focusout bind:value
on:keydown {...restProps}
on:keypress />
on:keyup {:else}
on:mouseover <input
on:mouseenter bind:this={ref}
on:mouseleave data-slot="input"
on:mousemove class={cn(
on:paste "border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
on:input "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
on:wheel|passive "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{...$$restProps} className
/> )}
{type}
bind:value
{...restProps}
/>
{/if}
+1 -71
View File
@@ -1,7 +1,4 @@
import { Dialog as SheetPrimitive } from "bits-ui"; import { Dialog as SheetPrimitive } from "bits-ui";
import { type VariantProps, tv } from "tailwind-variants";
import Portal from "./sheet-portal.svelte";
import Overlay from "./sheet-overlay.svelte"; import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte"; import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte"; import Header from "./sheet-header.svelte";
@@ -12,6 +9,7 @@ import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root; const Root = SheetPrimitive.Root;
const Close = SheetPrimitive.Close; const Close = SheetPrimitive.Close;
const Trigger = SheetPrimitive.Trigger; const Trigger = SheetPrimitive.Trigger;
const Portal = SheetPrimitive.Portal;
export { export {
Root, Root,
@@ -36,71 +34,3 @@ export {
Title as SheetTitle, Title as SheetTitle,
Description as SheetDescription, Description as SheetDescription,
}; };
export const sheetVariants = tv({
base: "bg-background fixed z-50 gap-4 p-6 shadow-lg",
variants: {
side: {
top: "inset-x-0 top-0 border-b",
bottom: "inset-x-0 bottom-0 border-t",
left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export const sheetTransitions = {
top: {
in: {
y: "-100%",
duration: 500,
opacity: 1,
},
out: {
y: "-100%",
duration: 300,
opacity: 1,
},
},
bottom: {
in: {
y: "100%",
duration: 500,
opacity: 1,
},
out: {
y: "100%",
duration: 300,
opacity: 1,
},
},
left: {
in: {
x: "-100%",
duration: 500,
opacity: 1,
},
out: {
x: "-100%",
duration: 300,
opacity: 1,
},
},
right: {
in: {
x: "100%",
duration: 500,
opacity: 1,
},
out: {
x: "100%",
duration: 300,
opacity: 1,
},
},
};
export type Side = VariantProps<typeof sheetVariants>["side"];
+43 -37
View File
@@ -1,47 +1,53 @@
<script lang="ts"> <script lang="ts" module>
import { Dialog as SheetPrimitive } from "bits-ui"; import { tv, type VariantProps } from "tailwind-variants";
import X from "lucide-svelte/icons/x"; export const sheetVariants = tv({
import { fly } from "svelte/transition"; base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
import { variants: {
SheetOverlay, side: {
SheetPortal, top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b",
type Side, bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t",
sheetTransitions, left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
sheetVariants, right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
} from "./index.js"; },
import { cn } from "$lib/components/utils.js"; },
defaultVariants: {
side: "right",
},
});
type $$Props = SheetPrimitive.ContentProps & { export type Side = VariantProps<typeof sheetVariants>["side"];
side?: Side;
};
let className: $$Props["class"] = undefined;
export let side: $$Props["side"] = "right";
export { className as class };
export let inTransition: $$Props["inTransition"] = fly;
export let inTransitionConfig: $$Props["inTransitionConfig"] =
sheetTransitions[side ?? "right"].in;
export let outTransition: $$Props["outTransition"] = fly;
export let outTransitionConfig: $$Props["outTransitionConfig"] =
sheetTransitions[side ?? "right"].out;
</script> </script>
<SheetPortal> <script lang="ts">
import { Dialog as SheetPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import X from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn } from "$lib/components/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content bind:ref class={cn(sheetVariants({ side }), className)} {...restProps}>
{inTransition} {@render children?.()}
{inTransitionConfig}
{outTransition}
{outTransitionConfig}
class={cn(sheetVariants({ side }), className)}
{...$$restProps}
>
<slot />
<SheetPrimitive.Close <SheetPrimitive.Close
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none" class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
> >
<X class="h-4 w-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPrimitive.Portal>
@@ -2,12 +2,15 @@
import { Dialog as SheetPrimitive } from "bits-ui"; import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
type $$Props = SheetPrimitive.DescriptionProps; let {
ref = $bindable(null),
let className: $$Props["class"] = undefined; class: className,
export { className as class }; ...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script> </script>
<SheetPrimitive.Description class={cn("text-muted-foreground text-sm", className)} {...$$restProps}> <SheetPrimitive.Description
<slot /> bind:ref
</SheetPrimitive.Description> class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
+10 -6
View File
@@ -1,16 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>; let {
ref = $bindable(null),
let className: $$Props["class"] = undefined; class: className,
export { className as class }; children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...$$restProps} {...restProps}
> >
<slot /> {@render children?.()}
</div> </div>
+13 -6
View File
@@ -1,13 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>; let {
ref = $bindable(null),
let className: $$Props["class"] = undefined; class: className,
export { className as class }; children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...$$restProps}> <div
<slot /> bind:this={ref}
class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div> </div>
+11 -11
View File
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import { Dialog as SheetPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
type $$Props = SheetPrimitive.OverlayProps; let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = fade;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
};
export { className as class }; export { className as class };
</script> </script>
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
{transition} bind:ref
{transitionConfig} class={cn(
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm ", className)} "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",
{...$$restProps} className
)}
{...restProps}
/> />
@@ -1,13 +0,0 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js";
type $$Props = SheetPrimitive.PortalProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SheetPrimitive.Portal class={cn(className)} {...$$restProps}>
<slot />
</SheetPrimitive.Portal>
+8 -8
View File
@@ -2,15 +2,15 @@
import { Dialog as SheetPrimitive } from "bits-ui"; import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/components/utils.js"; import { cn } from "$lib/components/utils.js";
type $$Props = SheetPrimitive.TitleProps; let {
ref = $bindable(null),
let className: $$Props["class"] = undefined; class: className,
export { className as class }; ...restProps
}: SheetPrimitive.TitleProps = $props();
</script> </script>
<SheetPrimitive.Title <SheetPrimitive.Title
bind:ref
class={cn("text-foreground text-lg font-semibold", className)} class={cn("text-foreground text-lg font-semibold", className)}
{...$$restProps} {...restProps}
> />
<slot />
</SheetPrimitive.Title>
@@ -0,0 +1,45 @@
---
title: WarShip Halloween Event 2025
key: 2025-halloween
description: Das WarShip Halloween Event 2025 für die Community
created: 2025-10-27T00:00:00.000Z
tags:
- event
- warship
---
Ahoi Community,
das diesjährige Halloween-Event nähert sich, die Tage werden langsam kürzer und die Nächte länger. Es geht auf dem Herbst zu und erinnert daran, dass das Jahr wieder halb vorbei ist. Dieses Mal im Spielmodus Warship. Das im Format 6 gegen 6 ausgetragen wird. Neben dem eigentlichen Turnier wird das Außendesign bewertet. Die Bewertung des Außendedigns wird zu 70% Das SW Builderteam übernehmen und 30% die Userbewertung. Die prozentuale Bewertung soll dazu dienen, dass große Teams Ihr eigenes Design nicht hoch puschen können.
Das Event findet am 08.11.2025 in der Version 1.21 mit dem aktuellen Regelwerk statt.
~~Anmelde + Einsendeschluss 03.11.2025~~
**Neue Fristen**:
Einsendeschluss: 06.11.2025 23:59 Uhr
Hotfixschluss: 07.11.2025 23:59 Uhr
Der Anmeldeschluss bleibt der 03.11.2025
zusätzlich wird es mit einem Designcontest begleitet.
Design Regel: Halloween
Arena: Lucifus
Design Bewertung
- Userbewertung (30%) wird über den Discord Community Server von SW organisiert. (Bilder vom Außendesign werden gepostet und per Abstimmung ausgelost)
- Builderbewertung (70%) läuft nach folgende Kriterien ab.
- Form des WS
- Farbgestaltung
- Muster
- Thematisierung: Thema Halloween / Grusel
Es wird also 3 Sieges- Plätze geben welch wie Folgt ermittelt wird.
- Gesamtsieger: Höchste Fight Platzierung und Design Platzierung im Durchschnitt
- Event- Sieger : Höchste Fight Platzierung
- Designsieger: Bestes Design
Das Warshipdesign vom Gesamtsieger wird bis zum nächsten Halloween in der Lobby ausgestellt. Wir freuen uns auf zahlreiche Anmeldungen und sind gespannt, welche Designs uns erwarten!
Das Serverteam
@@ -0,0 +1,45 @@
---
title: Oster-Event 2026
key: 2026-easter_event
description: Das Oster Event 2026 steht an. Wir freuen uns auf Eure Teilnahme!
created: 2026-03-10T00:00:00.000Z
tags:
- event
- warship
---
## Diese Version ist nicht die Aktuelle!
Bite schaue [hier](/events/2026-osterevent)!
Ahoi Matrosen,
der Winter zieht sich langsam zurück, die ersten Blumen kämpfen sich durch den Schnee und auch auf den Werften von SteamWar beginnt wieder geschäftiges Treiben.
Zu Ostern laden wir euch zu einem besonderen Warship-Event ein!
Packt eure Werkzeuge aus, bringt eure Kanonen auf Hochglanz und bereitet eure Schiffe für den Kampf vor.
Am 25. und 26. April treffen sich die Kapitäne der Flotte, um ihre neuesten Kreationen auf dem Schlachtfeld zu testen. Egal ob altgedienter Konstrukteur oder neuer Schiffsbauer jeder ist willkommen, seine Ideen zu Wasser zu lassen.
Ein Osterdesign ist gerne gesehen, aber keine Pflicht. Wer also sein Schiff mit Eiern, Frühlingsfarben oder kleinen Überraschungen schmücken möchte, darf seiner Kreativität freien Lauf lassen.
Termin
26. April
Version
Gespielt wird auf dem aktuellen 1.21 RW
Was euch erwartet
spannende Warship-Gefechte
neue Konstruktionen und kreative Designs
gemeinsames Event-Wochenende mit der Community
jede Menge Explosionen und epische Schlachten
Also: Werft die Maschinen an, hisst die Flaggen und macht eure Schiffe bereit.
Wir freuen uns auf ein spannendes Osterwochenende mit euch!

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