Compare commits
296 Commits
fe9610a970
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 446e4bb839 | |||
|
7f41765acb
|
|||
| 0d810f9a7e | |||
| 5d384bc336 | |||
| f95cf6cbfa | |||
|
972b8da9e6
|
|||
| cb41356351 | |||
| 276dc56627 | |||
| 0edec9cdf0 | |||
| 4703fde5a3 | |||
| 954a8cc318 | |||
| 1229edbf51 | |||
| 00bce50a49 | |||
| 5a44f2160c | |||
| 9b65d5d730 | |||
| 8397aace8d | |||
| c2b0bcc54e | |||
| 5c48f0cb85 | |||
| d30cceaad0 | |||
| 41be843be4 | |||
| 3768788f32 | |||
| 7e6f953e44 | |||
| cad3a795a7 | |||
| 48e8165417 | |||
| b11534490d | |||
| c0f4a852b5 | |||
| 54d49cca5b | |||
| 831ea3af11 | |||
| b6a0692c50 | |||
| 01394953d4 | |||
| c515b19e74 | |||
| 98199cc9a0 | |||
| 3f61564067 | |||
| 7b0f18f65d | |||
| 4ac5d2d2b2 | |||
| 8fd3e04116 | |||
| 3180ad1263 | |||
| f689415b98 | |||
| 894d0f8a05 | |||
| 16d377e3e4 | |||
| 1b2a05c204 | |||
| 04969e79c3 | |||
| a949237334 | |||
| 01a59d6de4 | |||
| 3daeb8b62d | |||
| aa72de70ef | |||
| 324025dd57 | |||
| 41b847b3e4 | |||
| a3b4a6d0c2 | |||
| 5f12a0cc7a | |||
| 7166575806 | |||
| 0055e9fb9c | |||
| fc5a209638 | |||
| c7cdc19102 | |||
| c6bbe8c9c8 | |||
| 1cec1b917e | |||
| 13805c7f3f | |||
| da668c574a | |||
| 2aab86573a | |||
| 5d7eb3b8fb | |||
| 6933af1554 | |||
| e607ea1343 | |||
| b0ae4e978e | |||
| 8fe273f3e0 | |||
| 1b48cbe1f4 | |||
| 7276552ed1 | |||
| a2ef92aaad | |||
| 8b85cd0729 | |||
| 2d024cf64b | |||
| 13d76d0a97 | |||
| e65fadb65c | |||
| 6b4693b7f1 | |||
| 92282006fe | |||
| 5457632598 | |||
| bed134f8e0 | |||
| 353a415990 | |||
| 3c6d0f8528 | |||
| 887235dc86 | |||
| a99a066f0d | |||
| e5e3c15b07 | |||
| fb74689c39 | |||
| 18b1f97a84 | |||
| 53b81db2c4 | |||
| 2314b4c5b5 | |||
| 6a81936f77 | |||
| a128de3213 | |||
| 6df661f885 | |||
| a32d84ed86 | |||
| e60cebc9a3 | |||
| 3576d5e034 | |||
| d5c7d8fc27 | |||
| ce895e9297 | |||
| 7c83ad0937 | |||
| 5e0a9d89b3 | |||
| 2a8b98ce5b | |||
| 427818d6bf | |||
| 8424c14ca9 | |||
| 602a7e1453 | |||
| 9f31c5ff0c | |||
| 8a41b98c58 | |||
| 9fc5c500f5 | |||
| bc879d7cad | |||
| 96f0019dc1 | |||
| 7418b608ab | |||
| 3802b9bc26 | |||
| 03effd2fd2 | |||
| a4669a897b | |||
| bd1c4f7f45 | |||
| eac0d5592d | |||
| bd9aea8f35 | |||
| 6e715cee07 | |||
| 4147a1d243 | |||
| 46dba2a6f9 | |||
| 3d8ad3a129 | |||
| 7d50a4db12 | |||
| df389b3acf | |||
| 4ecb5fa024 | |||
| 27f0b962c1 | |||
| e37583329c | |||
| 20b7a32b1b | |||
| dd7d701c48 | |||
| 3173b537bc | |||
| 5e2e4e2281 | |||
| da3699167b | |||
| 10ff84d410 | |||
| 7d75453be5 | |||
| 86bfaf4683 | |||
| f9212649ad | |||
| 4972ebf9bb | |||
| d5a2fc20e8 | |||
| 27c5698ac8 | |||
| fa5f25f37e | |||
| 260b7b24c4 | |||
| 4aea0c7fea | |||
| 314ff3e7c3 | |||
| 0205108d2d | |||
| 2bf3beb044 | |||
| b440456687 | |||
| 5277c9a3fc | |||
| 2f2c1be958 | |||
| 41c7df0d68 | |||
| cedf641039 | |||
| d9bdc636e3 | |||
| c8d05cb268 | |||
| cb2564c9ce | |||
| 80caf8fe6d | |||
| c4f8824115 | |||
| 1da279bb24 | |||
| fd3d621fd5 | |||
| 7d67ad0950 | |||
| 6377799e1b | |||
| b3598e1ee1 | |||
| b9db5be858 | |||
| 3e54934806 | |||
| 98638f94fc | |||
| 4da8fe50c0 | |||
| 7757978668 | |||
| 9eea0b2b3f | |||
| 063638d016 | |||
| f5a778d9b4 | |||
| 1b391b193e | |||
| c05c032e3f | |||
| da6f741806 | |||
| 6b54791331 | |||
| 36685bffd1 | |||
| caf9ea6cf1 | |||
| d505265910 | |||
| 78e1a7b726 | |||
| cd485e8dda | |||
| 182c402c7e | |||
| 098f5b9270 | |||
| cf0c66c910 | |||
| c8156ea47e | |||
| 20a47ca6b6 | |||
| 2d601b9c4d | |||
| 48586f1a50 | |||
| 7153cacbab | |||
| 73cee211f2 | |||
| 83074df7ef | |||
| d1c926c093 | |||
| f8a16acfeb | |||
| 9ca63cd286 | |||
| a2456c8b46 | |||
| 0952035091 | |||
| 9c8c02f679 | |||
| 3b5fdc57c0 | |||
| 733c63946f | |||
| fd846250ab | |||
| 17460772e9 | |||
| 9a20860072 | |||
| 8f51723a3b | |||
| 8ad2f283aa | |||
| 39f1af8b73 | |||
| 266c4cb4ea | |||
| f3df3c0000 | |||
| cb78fc598b | |||
| ba7ecc1a8e | |||
| 6ea92f9383 | |||
| 998770bf59 | |||
| a231032555 | |||
| 3aa3731bcb | |||
| 5e80c95bfd | |||
| 09dc28b6da | |||
| fd7cf716ca | |||
| 73bd6a5e96 | |||
| 9c02cc1f4d | |||
| de8457fe45 | |||
| 4fbe01f987 | |||
| 86d90e3fd2 | |||
| bccd5eb5a0 | |||
| 53afe70b27 | |||
| 4bbdaa06a9 | |||
| f03867b9a7 | |||
| 23e10eef0f | |||
| 4c72f4f26b | |||
| 624ba7f296 | |||
| d7d20e4347 | |||
| 43bd8f4a7c | |||
| 18e8627b54 | |||
| 0efc46c7e2 | |||
| 62fff0c0b2 | |||
| 86b479fb28 | |||
| 489402292d | |||
| b53ce04a75 | |||
| 069a9973a4 | |||
| c3410de1d7 | |||
| a23c514102 | |||
| bf8110af6c | |||
| 349f71af1c | |||
| dda37127ca | |||
| 6d210eb0ff | |||
| cfede8f299 | |||
| 597153ed39 | |||
| 697e903a26 | |||
| 1433784369 | |||
| 2c63a33bda | |||
| 87265e5ccc | |||
| 75f1a6528b | |||
| 23f35a35c4 | |||
| 973f469c7b | |||
| 107caafc26 | |||
| 7f26845802 | |||
| 37b2e82e05 | |||
| 7e2ba9dbce | |||
| 69426da5be | |||
| fd2ad65ad4 | |||
| a728651cca | |||
| b9e73ed7d0 | |||
| f1d55b3c99 | |||
| 9b49a0f81c | |||
| 11144043c1 | |||
| e8f866ce8a | |||
| e57a90feaf | |||
| 3aa8fea1fd | |||
| 95b327951c | |||
| 86b99b4e76 | |||
| 14b31be465 | |||
| 341e629aaf | |||
| 694ded4c61 | |||
| a75b5b7c09 | |||
| 9146f65455 | |||
| 254807efa6 | |||
| 36931aabb1 | |||
| 628599f019 | |||
| 0a6c61bd88 | |||
| 8bbad8b3cc | |||
| 5af6176889 | |||
| 9250dd5088 | |||
| 276e19409d | |||
| 11fa9fa126 | |||
| 17ec6023a9 | |||
| 3c7c899868 | |||
| 9cb161e470 | |||
| 7fc7c2a6eb | |||
| 2fce94d46b | |||
| 6356c9911a | |||
| 2402896fd5 | |||
| 2940304492 | |||
| 4c0a237b27 | |||
| 77b8b41afb | |||
| 163d049829 | |||
| a321b12680 | |||
| feba5a5b4a | |||
| faaf5f1852 | |||
| 18997e1384 | |||
| fdc7bb93dd | |||
| 437dfa223b | |||
| 012a56e177 | |||
| 9f60071e48 | |||
| 3bcadde949 | |||
| f5332411d2 | |||
| b546c7b2b2 | |||
| 7716aa1e89 | |||
| a7e961fc0c | |||
| 8c6f5f5729 | |||
| 41a3b75c97 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ pnpm-debug.log*
|
||||
/src/env.d.ts
|
||||
/src/pages/en/
|
||||
/.idea
|
||||
pnpm-lock.yaml
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import {defineConfig, sharpImageService} from "astro/config";
|
||||
import { defineConfig, sharpImageService } from "astro/config";
|
||||
import svelte from "@astrojs/svelte";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import configureI18n from "./astro-i18n.adapter";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
|
||||
import robotsTxt from "astro-robots-txt";
|
||||
import {resolve} from "node:url";
|
||||
import path from "node:path";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import pagefind from "astro-pagefind";
|
||||
|
||||
import starlight from "@astrojs/starlight";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@ -20,15 +20,40 @@ export default defineConfig({
|
||||
site: "https://steamwar.de",
|
||||
integrations: [
|
||||
svelte(),
|
||||
tailwind({
|
||||
configFile: "./tailwind.config.cjs",
|
||||
starlight({
|
||||
disable404Route: true,
|
||||
title: "SteamWar Docs",
|
||||
defaultLocale: "de",
|
||||
logo: {
|
||||
src: "./src/images/logo.png",
|
||||
},
|
||||
social: [
|
||||
{ icon: "discord", label: "Discord", href: "https://steamwar.de/discord" },
|
||||
{ icon: "document", label: "Gitea", href: "https://git.steamwar.de" },
|
||||
],
|
||||
sidebar: [
|
||||
{ label: "Startseite", slug: "docs" },
|
||||
{ label: "Bau", badge: "WIP", items: ["docs/bausystem", { label: "Script System", items: ["docs/bausystem/script"] }] },
|
||||
{ label: "Kampfsystem", badge: "WIP", items: ["docs/fightsystem"] },
|
||||
{ label: "Minigames", badge: "WIP", items: ["docs/minigames"] },
|
||||
{ label: "Schematicsystem", badge: "WIP", items: ["docs/schematicsystem"] },
|
||||
{ label: "API", badge: "WIP", items: ["docs/api"] },
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: "https://git.steamwar.de/SteamWar/Website/src/branch/master/",
|
||||
},
|
||||
}),
|
||||
tailwind({
|
||||
configFile: "./tailwind.config.js",
|
||||
applyBaseStyles: false,
|
||||
}),
|
||||
pagefind(),
|
||||
configureI18n(),
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: "en", locales: {
|
||||
en: "en-US", de: "de-DE",
|
||||
defaultLocale: "en",
|
||||
locales: {
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@ -52,7 +77,7 @@ export default defineConfig({
|
||||
{ userAgent: "omgili", disallow: "/" },
|
||||
{ userAgent: "OmigliBot", disallow: "/" },
|
||||
{ userAgent: "PerplexityBot", disallow: "/" },
|
||||
{ userAgent: "Timpibot", disallow: "/" }
|
||||
{ userAgent: "Timpibot", disallow: "/" },
|
||||
],
|
||||
}),
|
||||
mdx(),
|
||||
@ -69,7 +94,8 @@ export default defineConfig({
|
||||
"@layouts": path.resolve("./src/layouts"),
|
||||
"@repo": path.resolve("./src/components/repo"),
|
||||
"@stores": path.resolve("./src/components/stores"),
|
||||
$lib: path.resolve("./src"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src\\styles\\app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/components/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
}
|
||||
160
package.json
160
package.json
@ -1,69 +1,95 @@
|
||||
{
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm run clean:dist && pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^6.0.2",
|
||||
"@astrojs/tailwind": "^5.1.2",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cssnano": "^7.0.6",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.81.0",
|
||||
"svelte": "^5.2.7",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^3.1.9",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@ddietr/codemirror-themes": "^1.4.4",
|
||||
"astro": "^4.16.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-pagefind": "^1.6.0",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.6",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.18.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.3",
|
||||
"flowbite-svelte-icons": "^2.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte-awesome": "^3.3.5",
|
||||
"svelte-codemirror-editor": "^1.4.1",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^7.1.0",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.488.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bits-ui": "1.3.4",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"cssnano": "^7.0.7",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"formsnap": "1.0.1",
|
||||
"lucide-svelte": "^0.476.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.89.0",
|
||||
"svelte": "^5.33.4",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vaul-svelte": "^0.3.2",
|
||||
"zod": "^3.25.31"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@astrojs/starlight": "^0.34.4",
|
||||
"@astrojs/starlight-tailwind": "^4.0.1",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/view": "^6.36.8",
|
||||
"@ddietr/codemirror-themes": "^1.5.1",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"astro": "5.7.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.20.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.4",
|
||||
"flowbite-svelte-icons": "^2.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"qs": "^6.14.0",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte-awesome": "^3.3.5",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
7424
pnpm-lock.yaml
generated
7424
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
3
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 406 KiB |
21
public/site.webmanifest
Normal file
21
public/site.webmanifest
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@ -18,13 +18,13 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {twMerge} from "tailwind-merge";
|
||||
import {onMount} from "svelte";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let cardElement: HTMLDivElement = $state();
|
||||
|
||||
function rotateElement(event: MouseEvent) {
|
||||
if(!hoverEffect) return;
|
||||
if (!hoverEffect) return;
|
||||
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
@ -36,23 +36,23 @@
|
||||
const rotateX = (centerY - y) / 20;
|
||||
const rotateY = -(centerX - x) / 20;
|
||||
|
||||
cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
|
||||
cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
|
||||
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`);
|
||||
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`);
|
||||
}
|
||||
|
||||
function resetElement() {
|
||||
cardElement.style.setProperty('--rotate-x', "0");
|
||||
cardElement.style.setProperty('--rotate-y', "0");
|
||||
cardElement.style.setProperty("--rotate-x", "0");
|
||||
cardElement.style.setProperty("--rotate-y", "0");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hoverEffect?: boolean;
|
||||
extraClasses?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
interface Props {
|
||||
hoverEffect?: boolean;
|
||||
extraClasses?: string;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { hoverEffect = true, extraClasses = '', children }: Props = $props();
|
||||
let classes = $derived(twMerge("w-72 border-2 bg-zinc-50 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg dark:bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses))
|
||||
let { hoverEffect = true, extraClasses = "", children }: Props = $props();
|
||||
let classes = $derived(twMerge("w-72 border-2 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses));
|
||||
</script>
|
||||
|
||||
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
|
||||
@ -61,20 +61,20 @@
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
||||
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
||||
|
||||
transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
|
||||
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6);
|
||||
|
||||
:global(h1) {
|
||||
@apply text-xl font-bold mt-4;
|
||||
}
|
||||
:global(h1) {
|
||||
@apply text-xl font-bold mt-4;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl
|
||||
}
|
||||
:global(svg) {
|
||||
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.hoverEffect:hover {
|
||||
scale: 105%;
|
||||
scale: 105%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
<script lang="ts">
|
||||
import {t} from "astro-i18n";
|
||||
import {statsRepo} from "@repo/stats.ts";
|
||||
import "@styles/table.css"
|
||||
|
||||
|
||||
interface Props {
|
||||
@ -64,7 +65,3 @@
|
||||
<p>{error.message}</p>
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
@import "../styles/table.css";
|
||||
</style>
|
||||
|
||||
|
||||
@ -19,8 +19,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import FightStatsChart from "./FightStatsChart.svelte";
|
||||
import {t} from "astro-i18n";
|
||||
import {statsRepo} from "@repo/stats.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import { statsRepo } from "@repo/stats.ts";
|
||||
|
||||
let request = getStats();
|
||||
|
||||
@ -35,4 +35,4 @@
|
||||
<FightStatsChart data={stats} />
|
||||
{:catch error}
|
||||
<p>error: {error}</p>
|
||||
{/await}
|
||||
{/await}
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
})
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
@ -105,5 +107,5 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
<canvas height="500" bind:this={canvas}></canvas>
|
||||
</div>
|
||||
@ -19,30 +19,32 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./util.ts";
|
||||
import {astroI18n, t} from "astro-i18n";
|
||||
import type {EventFight, ExtendedEvent} from "@type/event";
|
||||
import { window } from "./utils.ts";
|
||||
import { astroI18n, t } from "astro-i18n";
|
||||
import type { EventFight, ExtendedEvent } from "@type/event";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
|
||||
function getWinner(fight: EventFight) {
|
||||
if (!fight.hasFinished) {
|
||||
return t("announcements.table.notPlayed");
|
||||
}
|
||||
|
||||
switch (fight.ergebnis) {
|
||||
case 1:
|
||||
return fight.blueTeam.kuerzel;
|
||||
case 2:
|
||||
return fight.redTeam.kuerzel;
|
||||
case 3:
|
||||
return t("announcements.table.draw");
|
||||
default:
|
||||
return t("announcements.table.notPlayed");
|
||||
return t("announcements.table.draw");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="font-bold border-b">
|
||||
@ -55,13 +57,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each window(event.fights.filter(f => f.group === group), rows) as fights}
|
||||
{#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows ) as fights}
|
||||
<tr>
|
||||
{#each fights as fight (fight.id)}
|
||||
<td>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(new Date(fight.start))}</td>
|
||||
<td
|
||||
>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(new Date(fight.start))}</td
|
||||
>
|
||||
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
|
||||
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
|
||||
<td>{getWinner(fight)}</td>
|
||||
@ -71,9 +75,3 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
@apply p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto;
|
||||
}
|
||||
</style>
|
||||
@ -19,37 +19,33 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./util.ts";
|
||||
import {t} from "astro-i18n";
|
||||
import type {ExtendedEvent} from "@type/event.ts";
|
||||
import "@styles/table.css"
|
||||
import { window } from "./utils.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let rows: number = 1;
|
||||
let {
|
||||
event,
|
||||
group,
|
||||
rows = 1,
|
||||
}: {
|
||||
event: ExtendedEvent;
|
||||
group: number;
|
||||
rows?: number;
|
||||
} = $props();
|
||||
|
||||
$: teamPoints = event.teams.map(team => {
|
||||
const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 3) {
|
||||
return acc + 1;
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, 0);
|
||||
return {
|
||||
team,
|
||||
points,
|
||||
};
|
||||
}).sort((a, b) => b.points - a.points);
|
||||
let teamPoints = $derived(
|
||||
Object.entries(event.groups.find((g) => g.id === group)?.points ?? {})
|
||||
.map(([teamId, points]) => ({
|
||||
team: event.teams.find((t) => t.id === Number(teamId))!!,
|
||||
points: points,
|
||||
}))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<table>
|
||||
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="font-bold border-b">
|
||||
{#each Array(rows) as i (i)}
|
||||
@ -70,12 +66,3 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
table {
|
||||
@apply w-full;
|
||||
}
|
||||
div {
|
||||
@apply p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto;
|
||||
}
|
||||
</style>
|
||||
@ -31,8 +31,7 @@
|
||||
let error: string = $state("");
|
||||
|
||||
async function login() {
|
||||
let {tokenStore} = await import("./repo/repo.ts");
|
||||
let {authRepo} = await import("./repo/auth.ts");
|
||||
let {authV2Repo} = await import("./repo/authv2.ts");
|
||||
if (username === "" || pw === "") {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
@ -40,15 +39,14 @@
|
||||
}
|
||||
|
||||
try {
|
||||
let auth = await get(authRepo).login(username, pw);
|
||||
if (auth == undefined) {
|
||||
let auth = await get(authV2Repo).login(username, pw);
|
||||
if (!auth) {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
return;
|
||||
}
|
||||
|
||||
tokenStore.set(auth);
|
||||
navigate(l("/dashboard"));
|
||||
await navigate(l("/dashboard"));
|
||||
} catch (e: any) {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
@ -75,9 +73,7 @@
|
||||
|
||||
<style lang="postcss">
|
||||
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;
|
||||
@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;
|
||||
}
|
||||
|
||||
label {
|
||||
|
||||
@ -19,39 +19,54 @@
|
||||
|
||||
<script lang="ts">
|
||||
import "../styles/button.css";
|
||||
import {CaretDownOutline, SearchOutline} from "flowbite-svelte-icons";
|
||||
import {t} from "astro-i18n";
|
||||
import {l} from "../util/util";
|
||||
import {onMount} from "svelte";
|
||||
interface Props {
|
||||
logo?: import('svelte').Snippet;
|
||||
}
|
||||
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons";
|
||||
import { t } from "astro-i18n";
|
||||
import { l } from "../util/util";
|
||||
import { onMount } from "svelte";
|
||||
import { loggedIn } from "@repo/authv2.ts";
|
||||
interface Props {
|
||||
logo?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { logo }: Props = $props();
|
||||
let { logo }: Props = $props();
|
||||
|
||||
let navbar: HTMLDivElement = $state();
|
||||
let navbar = $state<HTMLElement>();
|
||||
let searchOpen = $state(false);
|
||||
|
||||
let accountBtn = $state<HTMLAnchorElement>();
|
||||
|
||||
$effect(() => {
|
||||
if ($loggedIn) {
|
||||
accountBtn!.href = l("/dashboard");
|
||||
} else {
|
||||
accountBtn!.href = l("/login");
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
handleScroll();
|
||||
})
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
if (window.scrollY > 0) {
|
||||
navbar.classList.add("before:scale-y-100");
|
||||
navbar!.classList.add("before:scale-y-100");
|
||||
} else {
|
||||
navbar.classList.remove("before:scale-y-100");
|
||||
navbar!.classList.remove("before:scale-y-100");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={handleScroll}/>
|
||||
<svelte:window onscroll={handleScroll} />
|
||||
|
||||
<nav data-pagefind-ignore class="fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors z-10 flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top" bind:this={navbar}>
|
||||
<nav
|
||||
data-pagefind-ignore
|
||||
class="z-20 fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top"
|
||||
bind:this={navbar}
|
||||
>
|
||||
<div class="flex flex-row items-center justify-evenly md:justify-between match">
|
||||
<a class="flex items-center" href={l("/")}>
|
||||
{@render logo?.()}
|
||||
<span class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block">
|
||||
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block">
|
||||
{t("navbar.title")}
|
||||
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span>
|
||||
</span>
|
||||
@ -62,7 +77,7 @@
|
||||
<a href={l("/")}>
|
||||
<span class="btn__text">{t("navbar.links.home.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto"/>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
|
||||
@ -76,7 +91,7 @@
|
||||
<a rel="prefetch" href={l("/rules")}>
|
||||
<span class="btn__text">{t("navbar.links.rules.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto"/>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a>
|
||||
@ -85,11 +100,11 @@
|
||||
<a href={l("/rules/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a>
|
||||
<a href={l("/rules/quickgear")} class="btn btn-gray">{t("navbar.links.rules.qg")}</a>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
|
||||
<a href={l("/rules/megawargear")}
|
||||
class="btn btn-gray">{t("navbar.links.rules.megawg")}</a>
|
||||
<a href={l("/rules/microwargear")}
|
||||
class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
|
||||
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</a>
|
||||
<a href={l("/rules/microwargear")} class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
|
||||
<a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
|
||||
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Add help center
|
||||
@ -106,7 +121,7 @@
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
<a class="btn" href={l("/login")}>
|
||||
<a class="btn" href={l("/login")} bind:this={accountBtn}>
|
||||
<span class="btn__text">{t("navbar.links.account")}</span>
|
||||
</a>
|
||||
<!--
|
||||
@ -128,4 +143,4 @@
|
||||
.match {
|
||||
width: min(100vw, 70em);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -1,50 +1,60 @@
|
||||
---
|
||||
import {CollectionEntry} from "astro:content";
|
||||
import {l} from "../util/util";
|
||||
import {astroI18n} from "astro-i18n";
|
||||
import {Image} from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { l } from "../util/util";
|
||||
import { astroI18n } from "astro-i18n";
|
||||
import { Image } from "astro:assets";
|
||||
import TagComponent from "./TagComponent.astro";
|
||||
import P from "./P.astro";
|
||||
import Card from "@components/Card.svelte";
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"announcements">
|
||||
post: CollectionEntry<"announcements">;
|
||||
}
|
||||
|
||||
const { post, slim }: {
|
||||
post: CollectionEntry<"announcements">,
|
||||
slim: boolean,
|
||||
const {
|
||||
post,
|
||||
slim,
|
||||
}: {
|
||||
post: CollectionEntry<"announcements">;
|
||||
slim: boolean;
|
||||
} = Astro.props as Props;
|
||||
|
||||
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
|
||||
---
|
||||
|
||||
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
|
||||
<div class={`flex flex-row ${slim ? "":"p-4"}`}>
|
||||
{post.data.image != null
|
||||
? (
|
||||
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}>
|
||||
<div class={`flex flex-row ${slim ? "" : "p-4"}`}>
|
||||
{
|
||||
post.data.image != null ? (
|
||||
<a href={postUrl}>
|
||||
<div class="flex-shrink-0 pr-2">
|
||||
<Image transition:name={post.data.title + "-image"} src={post.data.image} alt="Post Image" class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105" />
|
||||
<Image
|
||||
transition:name={post.data.title + "-image"}
|
||||
src={post.data.image}
|
||||
alt="Post Image"
|
||||
class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
) : null
|
||||
}
|
||||
<div>
|
||||
<a href={postUrl} class="flex flex-col items-start">
|
||||
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
|
||||
<P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)}</P>
|
||||
<P class="text-gray-500"
|
||||
>{
|
||||
Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)
|
||||
}</P
|
||||
>
|
||||
<P>{post.data.description}</P>
|
||||
</a>
|
||||
<div class="mt-1" transition:name={post.data.title + "-tags"}>
|
||||
{post.data.tags.map((tag) => (
|
||||
<TagComponent tag={tag} />
|
||||
))}
|
||||
{post.data.tags.map((tag) => <TagComponent tag={tag} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
@ -22,38 +22,40 @@
|
||||
import wrap from "svelte-spa-router/wrap";
|
||||
import Router, {replace} from "svelte-spa-router";
|
||||
import {get} from "svelte/store";
|
||||
import {tokenStore} from "@repo/repo";
|
||||
import {loggedIn} from "@repo/authv2.ts";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(tokenStore) != ""}),
|
||||
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(loggedIn)}),
|
||||
"/perms": wrap({
|
||||
asyncComponent: () => import("./pages/Perms.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/login": wrap({
|
||||
asyncComponent: () => import("./pages/Login.svelte"),
|
||||
conditions: detail => get(tokenStore) == ""
|
||||
conditions: detail => !get(loggedIn)
|
||||
}),
|
||||
"/event/:id": wrap({
|
||||
asyncComponent: () => import("./pages/Event.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/event/:id/generate": wrap({
|
||||
asyncComponent: () => import("./pages/Generate.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/edit": wrap({
|
||||
asyncComponent: () => import("./pages/Edit.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/display/:event": wrap({
|
||||
asyncComponent: () => import("./pages/Display.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")})
|
||||
};
|
||||
|
||||
function conditionsFailed(event: ConditionsFailedEvent) {
|
||||
console.log(event)
|
||||
|
||||
if (event.detail.location === "/login") {
|
||||
replace("/");
|
||||
} else {
|
||||
|
||||
@ -168,11 +168,11 @@
|
||||
</div>
|
||||
<div>
|
||||
{#if selectedBranch !== "master"}
|
||||
<Button onclick={createFile} color="alternative" disabled={!selectedPath}>Create File
|
||||
<Button onclick={() => createFile()} color="alternative" disabled={!selectedPath}>Create File
|
||||
</Button>
|
||||
<Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button>
|
||||
{:else}
|
||||
<Button onclick={createBranch}>Create Branch</Button>
|
||||
<Button onclick={() => createBranch()}>Create Branch</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
</NavBrand>
|
||||
<NavHamburger onclick={toggle}/>
|
||||
<NavUl {hidden}>
|
||||
<NavLi href="/admin/new">New UI</NavLi>
|
||||
<NavLi href="#/edit">Edit Pages</NavLi>
|
||||
<NavLi href="#/perms">Permissions</NavLi>
|
||||
</NavUl>
|
||||
|
||||
@ -18,23 +18,22 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte";
|
||||
import {json} from "@codemirror/lang-json";
|
||||
import CodeMirror from "svelte-codemirror-editor";
|
||||
import {base64ToBytes} from "../../util.ts";
|
||||
import type {Page} from "@type/page.ts";
|
||||
import {materialDark} from "@ddietr/codemirror-themes/material-dark";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { base64ToBytes } from "../../util.ts";
|
||||
import type { Page } from "@type/page.ts";
|
||||
import { materialDark } from "@ddietr/codemirror-themes/material-dark";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
let { pageId, branch, dirty = $bindable(false) }: Props = $props();
|
||||
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
|
||||
|
||||
let dispatcher = createEventDispatcher();
|
||||
|
||||
@ -71,35 +70,32 @@
|
||||
}
|
||||
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
|
||||
</script>
|
||||
<svelte:window onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}/>
|
||||
|
||||
<svelte:window
|
||||
onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#await pageFuture}
|
||||
<Spinner/>
|
||||
<Spinner />
|
||||
{:then p}
|
||||
<div>
|
||||
<div>
|
||||
<Toolbar class="!bg-gray-900">
|
||||
{#snippet end()}
|
||||
<ToolbarGroup >
|
||||
<ToolbarButton onclick={deletePage}>
|
||||
Delete
|
||||
</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>
|
||||
Save
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton onclick={deletePage}>Delete</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
</div>
|
||||
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty/>
|
||||
{:else}
|
||||
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} on:change={() => dirty = true}/>
|
||||
{/if}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty />
|
||||
{:else}{/if}
|
||||
</div>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
{/await}
|
||||
{/await}
|
||||
|
||||
@ -43,8 +43,6 @@
|
||||
let member = $state(event.maxTeamMembers);
|
||||
let schemType = $state(event.schemType);
|
||||
let publicOnly = $state(event.publicSchemsOnly);
|
||||
let addReferee: {name: string, id: number}[] = [];
|
||||
let removeReferee: {name: string, id: number}[] = [];
|
||||
|
||||
let errorOpen = $state(false);
|
||||
let error: any = $state(undefined);
|
||||
@ -69,9 +67,7 @@
|
||||
endDate.diff(dayjs(event.end)) !== 0 ||
|
||||
member !== event.maxTeamMembers ||
|
||||
schemType != event.schemType ||
|
||||
publicOnly !== event.publicSchemsOnly ||
|
||||
addReferee.length > 0 ||
|
||||
removeReferee.length > 0);
|
||||
publicOnly !== event.publicSchemsOnly);
|
||||
|
||||
|
||||
async function del() {
|
||||
@ -95,8 +91,8 @@
|
||||
publicSchemsOnly: publicOnly,
|
||||
schemType: schemType ?? "null",
|
||||
start: startDate,
|
||||
addReferee: addReferee.map((ref) => ref.id),
|
||||
removeReferee: removeReferee.map((ref) => ref.id)
|
||||
addReferee: [],
|
||||
removeReferee: []
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {EditOutline, TrashBinOutline} from "flowbite-svelte-icons";
|
||||
import {Button, Modal, Toolbar, ToolbarButton} from "flowbite-svelte";
|
||||
import {Button, Checkbox, Modal, Toolbar, ToolbarButton} from "flowbite-svelte";
|
||||
import type {EventFight, ExtendedEvent} from "@type/event.ts";
|
||||
import FightEditModal from "./modals/FightEditModal.svelte";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
@ -32,6 +32,8 @@
|
||||
i: number;
|
||||
selected?: boolean;
|
||||
hideEdit?: boolean;
|
||||
select: () => void;
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@ -39,29 +41,34 @@
|
||||
data = $bindable(),
|
||||
i,
|
||||
selected = false,
|
||||
hideEdit = false
|
||||
hideEdit = false,
|
||||
select,
|
||||
update,
|
||||
}: Props = $props();
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let editOpen = $state(false);
|
||||
|
||||
let dispatcher = createEventDispatcher();
|
||||
|
||||
function dispatchSelect() {
|
||||
setTimeout(() => {
|
||||
if (!deleteOpen && !editOpen) {
|
||||
dispatcher("select");
|
||||
select();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
|
||||
async function deleteFight() {
|
||||
await $fightRepo.deleteFight(fight.id);
|
||||
dispatcher("update");
|
||||
update();
|
||||
}
|
||||
|
||||
let isUneven = $derived(i % 2 === 0);
|
||||
</script>
|
||||
|
||||
<div class="flex h-16 {i % 2 === 0 ? 'bg-gray-800' : ''} mx-4 mt-6 rounded border {selected ? 'border-orange-700' : 'border-gray-700'} p-2 hover:bg-gray-700 transition justify-between shadow-lg cursor-pointer"
|
||||
<div class={{"flex h-16 mx-4 mt-6 rounded border p-2 hover:bg-gray-700 transition justify-between shadow-lg cursor-pointer": true,
|
||||
"bg-gray-800": isUneven,
|
||||
"border-orange-700": selected,
|
||||
"border-gray-700": !selected}}
|
||||
onclick={dispatchSelect} onkeypress={dispatchSelect} role="checkbox" aria-checked={selected} tabindex="0"
|
||||
>
|
||||
<div class="flex">
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
} else {
|
||||
groupFights.forEach(fight => selectedFights.add(fight));
|
||||
}
|
||||
selectedFights = selectedFights;
|
||||
selectedFights = new Set(selectedFights);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
@ -227,16 +227,17 @@
|
||||
<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)}
|
||||
<FightCard {fight} {i} {data} selected={selectedFights.has(fight)}
|
||||
on:select={() => {
|
||||
{@const isSelected = selectedFights.has(fight)}
|
||||
<FightCard {fight} {i} {data} selected={isSelected}
|
||||
select={() => {
|
||||
if (selectedFights.has(fight)) {
|
||||
selectedFights.delete(fight);
|
||||
} else {
|
||||
selectedFights.add(fight);
|
||||
}
|
||||
selectedFights = selectedFights;
|
||||
}}
|
||||
on:update={async () => fights = await $fightRepo.listFights(data.event.id)}
|
||||
|
||||
selectedFights = new Set(selectedFights);
|
||||
}} update={async () => fights = await $fightRepo.listFights(data.event.id)}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
@ -43,6 +43,14 @@
|
||||
async function addReferee() {
|
||||
if (selectedPlayer) {
|
||||
referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
|
||||
deadline: null,
|
||||
end: null,
|
||||
maxTeamMembers: null,
|
||||
name: null,
|
||||
publicSchemsOnly: null,
|
||||
removeReferee: null,
|
||||
schemType: null,
|
||||
start: null,
|
||||
addReferee: [selectedPlayer]
|
||||
})).referees;
|
||||
}
|
||||
@ -53,7 +61,15 @@
|
||||
function removeReferee(id: string) {
|
||||
return async () => {
|
||||
referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
|
||||
removeReferee: [id]
|
||||
deadline: null,
|
||||
end: null,
|
||||
maxTeamMembers: null,
|
||||
name: null,
|
||||
publicSchemsOnly: null,
|
||||
addReferee: null,
|
||||
schemType: null,
|
||||
start: null,
|
||||
removeReferee: [id],
|
||||
})).referees;
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,13 @@
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { fight = $bindable(), data, open = $bindable(false) }: Props = $props();
|
||||
let { fight = $bindable(), data = $bindable(), open = $bindable(false) }: Props = $props();
|
||||
|
||||
let redTeam = $state(fight.redTeam.id.toString());
|
||||
let blueTeam = $state(fight.blueTeam.id.toString());
|
||||
let start = $state(dayjs(fight.start).utc(true).toISOString().slice(0, -1));
|
||||
let spectatePort = $state(fight.spectatePort?.toString() ?? null);
|
||||
$inspect(spectatePort, fight.spectatePort)
|
||||
let gamemode = $state(fight.spielmodus);
|
||||
let map = $state(fight.map);
|
||||
let group = $state(fight.group);
|
||||
@ -62,6 +63,8 @@
|
||||
start: dayjs(start)
|
||||
};
|
||||
|
||||
console.log(update)
|
||||
|
||||
$fightRepo.updateFight(fight.id, update)
|
||||
.then(value => {
|
||||
open = false;
|
||||
|
||||
@ -18,12 +18,14 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte";
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
import { Card } from "@components/ui/card";
|
||||
|
||||
let { children }: Props = $props();
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
ondrop: (event: DragEvent) => void;
|
||||
}
|
||||
|
||||
let { children, ondrop }: Props = $props();
|
||||
|
||||
let dragover = $state(false);
|
||||
|
||||
@ -32,19 +34,16 @@
|
||||
dragover = true;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleDrop(ev: DragEvent) {
|
||||
ev.preventDefault();
|
||||
dragover = false;
|
||||
dispatch("drop", ev);
|
||||
ondrop(ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} ondrop={handleDrop}
|
||||
ondragover={handleDragOver} ondragleave={() => dragover = false} role="none">
|
||||
<Card class="w-56 p-4 rounded m-px {dragover ? 'border-white' : ''}" ondrop={handleDrop} ondragover={handleDragOver} ondragleave={() => (dragover = false)} role="none">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
div {
|
||||
|
||||
@ -18,28 +18,28 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { brightness, colorFromTeam, lighten } from "../../util";
|
||||
|
||||
const bubble = createBubbler();
|
||||
import type {Team} from "@type/team.ts";
|
||||
import {brightness, colorFromTeam, lighten} from "../../util";
|
||||
interface Props {
|
||||
team: Team;
|
||||
ondragstart: (event: DragEvent) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
team: Team;
|
||||
}
|
||||
|
||||
let { team }: Props = $props();
|
||||
let { team, ondragstart }: Props = $props();
|
||||
|
||||
let hover = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
||||
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
|
||||
class:text-black={brightness(colorFromTeam(team))} draggable="true"
|
||||
ondragstart={bubble('dragstart')}
|
||||
onmouseenter={() => hover = true}
|
||||
onmouseleave={() => hover = false}
|
||||
role="figure">
|
||||
<div
|
||||
class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
||||
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
|
||||
class:text-black={brightness(colorFromTeam(team))}
|
||||
draggable="true"
|
||||
{ondragstart}
|
||||
onmouseenter={() => (hover = true)}
|
||||
onmouseleave={() => (hover = false)}
|
||||
role="figure"
|
||||
>
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
let request = getRequest();
|
||||
|
||||
function getRequest() {
|
||||
return $statsRepo.getUserStats(user.id)
|
||||
return $statsRepo.getUserStats(user.uuid)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -43,8 +43,5 @@
|
||||
maximumFractionDigits: 2
|
||||
}).format(data.playtime)})}h</p>
|
||||
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
|
||||
{#if user.perms.includes("CHECK")}
|
||||
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
||||
{/await}
|
||||
@ -21,19 +21,21 @@
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {schemRepo} from "@repo/schem.ts";
|
||||
import SWModal from "@components/styled/SWModal.svelte";
|
||||
import {t} from "astro-i18n"
|
||||
import {t} from "astro-i18n";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
let {open = $bindable(false)}: Props = $props();
|
||||
|
||||
async function upload() {
|
||||
async function upload(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (uploadFile == null) {
|
||||
return
|
||||
error = "dashboard.schematic.errors.noFile";
|
||||
return;
|
||||
}
|
||||
let file = uploadFile[0];
|
||||
|
||||
@ -42,32 +44,46 @@
|
||||
let type = name.split(".").pop();
|
||||
|
||||
if (type !== "schem" && type !== "schematic") {
|
||||
return
|
||||
error = "dashboard.schematic.errors.invalidEnding";
|
||||
return;
|
||||
}
|
||||
|
||||
let content = await file.arrayBuffer();
|
||||
|
||||
// @ts-ignore
|
||||
let b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(content)));
|
||||
|
||||
await $schemRepo.uploadSchematic(name, b64);
|
||||
try {
|
||||
await $schemRepo.uploadSchematic(name, b64);
|
||||
|
||||
open = false;
|
||||
uploadFile = null;
|
||||
dispatch("reset")
|
||||
open = false;
|
||||
value = "";
|
||||
dispatch("reset");
|
||||
} catch (e) {
|
||||
error = "dashboard.schematic.errors.upload";
|
||||
}
|
||||
}
|
||||
|
||||
function reset(e: Event) {
|
||||
e.stopPropagation();
|
||||
open = false
|
||||
value = "";
|
||||
}
|
||||
|
||||
let uploadFile: FileList | null = $state(null);
|
||||
let value = $state("");
|
||||
let error = $state(null)
|
||||
</script>
|
||||
|
||||
<SWModal title={t("dashboard.schematic.title")} bind:open>
|
||||
<form>
|
||||
<input type="file" bind:files={uploadFile} />
|
||||
<label for="schem-upload">{t("dashboard.schematic.title")}</label>
|
||||
<input type="file" id="schem-upload" bind:files={uploadFile} class="overflow-ellipsis" bind:value accept=".schem, .schematic"/>
|
||||
{#if error !== null}
|
||||
<p class="text-red-400">{t(error)}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
|
||||
<button class="btn !ml-auto" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
||||
<button class="btn btn-gray" onclick={() => open = false}>{t("dashboard.schematic.cancel")}</button>
|
||||
|
||||
{/snippet}
|
||||
<button class="btn" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
||||
<button class="btn btn-gray" onclick={reset}>{t("dashboard.schematic.cancel")}</button>
|
||||
{/snippet}
|
||||
</SWModal>
|
||||
@ -18,24 +18,23 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {t} from "astro-i18n";
|
||||
import type {Player} from "@type/data.ts";
|
||||
import {l} from "@utils/util.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { Player } from "@type/data.ts";
|
||||
import { l } from "@utils/util.ts";
|
||||
import Statistics from "./Statistics.svelte";
|
||||
import {authRepo} from "@repo/auth.ts";
|
||||
import {tokenStore} from "@repo/repo.ts";
|
||||
import { authV2Repo } from "@repo/authv2.ts";
|
||||
import Card from "@components/Card.svelte";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user }: Props = $props();
|
||||
|
||||
async function logout() {
|
||||
await $authRepo.logout()
|
||||
tokenStore.set("")
|
||||
window.location.href = l("/login")
|
||||
await $authV2Repo.logout();
|
||||
await navigate(l("/login"));
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -44,19 +43,25 @@
|
||||
<Card>
|
||||
<figure>
|
||||
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
|
||||
<img src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`} class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl" alt={user.name + "s bust"} width="150" height="150" />
|
||||
<img
|
||||
src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`}
|
||||
class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl"
|
||||
alt={user.name + "s bust"}
|
||||
width="150"
|
||||
height="150"
|
||||
/>
|
||||
</figure>
|
||||
</Card>
|
||||
<div class="flex flex-wrap">
|
||||
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
|
||||
{#if user.perms.includes("MODERATION")}
|
||||
<a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a>
|
||||
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1>
|
||||
<p>{t("dashboard.rank", {rank: t("home.prefix." + user.prefix)})}</p>
|
||||
<h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1>
|
||||
<p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p>
|
||||
<Statistics {user} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
53
src/components/moderator/App.svelte
Normal file
53
src/components/moderator/App.svelte
Normal file
@ -0,0 +1,53 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { RouteDefinition } from "svelte-spa-router";
|
||||
import Router from "svelte-spa-router";
|
||||
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
|
||||
import Players from "@components/moderator/pages/players/Players.svelte";
|
||||
import Events from "@components/moderator/pages/events/Events.svelte";
|
||||
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
|
||||
import Event from "@components/moderator/pages/event/Event.svelte";
|
||||
import Pages from "@components/moderator/pages/pages/Pages.svelte";
|
||||
import Generator from "@components/moderator/pages/generators/Generator.svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": Dashboard,
|
||||
"/events": Events,
|
||||
"/players": Players,
|
||||
"/event/:id": Event,
|
||||
"/event/:id/generate": Generator,
|
||||
"/pages": Pages,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col bg-background min-w-full min-h-screen">
|
||||
<div class="border-b">
|
||||
<div class="flex h-16 items-center px-4">
|
||||
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
|
||||
<NavLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Router {routes} />
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
42
src/components/moderator/components/EventCard.svelte
Normal file
42
src/components/moderator/components/EventCard.svelte
Normal file
@ -0,0 +1,42 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type {ShortEvent} from "@type/event.ts";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@components/ui/card";
|
||||
|
||||
let { event }: { event: ShortEvent } = $props();
|
||||
|
||||
let sameDate = $derived(new Intl.DateTimeFormat().format(event.start) === new Intl.DateTimeFormat().format(event.end));
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{event.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !sameDate}
|
||||
<p>Startet: {new Intl.DateTimeFormat().format(event.start)}</p>
|
||||
<p>Endet: {new Intl.DateTimeFormat().format(event.end)}</p>
|
||||
{:else}
|
||||
<p>Am: {new Intl.DateTimeFormat().format(event.start)}</p>
|
||||
<p> </p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
170
src/components/moderator/components/FightEdit.svelte
Normal file
170
src/components/moderator/components/FightEdit.svelte
Normal file
@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import GroupSelector from "./GroupSelector.svelte";
|
||||
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import { Label } from "@components/ui/label";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
|
||||
import { ChevronsUpDown, Check } from "lucide-svelte";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { cn } from "@components/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import type { Snippet } from "svelte";
|
||||
import { Input } from "@components/ui/input";
|
||||
import TeamSelector from "./TeamSelector.svelte";
|
||||
import type { EventModel } from "../pages/event/eventmodel.svelte";
|
||||
|
||||
let {
|
||||
fight,
|
||||
actions,
|
||||
onSave,
|
||||
data,
|
||||
}: {
|
||||
fight: EventFight | null;
|
||||
actions: Snippet<[boolean, () => void]>;
|
||||
onSave: (fight: EventFightEdit) => void;
|
||||
data: EventModel;
|
||||
} = $props();
|
||||
|
||||
let fightModus = $state(fight?.spielmodus);
|
||||
let fightMap = $state(fight?.map);
|
||||
let fightBlueTeam = $state(fight?.blueTeam);
|
||||
let fightRedTeam = $state(fight?.redTeam);
|
||||
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let fightErgebnis = $state(fight?.ergebnis ?? 0);
|
||||
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
|
||||
let fightGroup = $state(fight?.group?.id ?? null);
|
||||
|
||||
let mapsStore = $derived(maps(fightModus ?? "null"));
|
||||
let gamemodeSelectOpen = $state(false);
|
||||
let mapSelectOpen = $state(false);
|
||||
|
||||
let dirty = $derived(
|
||||
fightModus !== fight?.spielmodus ||
|
||||
fightMap !== fight?.map ||
|
||||
fightBlueTeam?.id !== fight?.blueTeam?.id ||
|
||||
fightRedTeam?.id !== fight?.redTeam?.id ||
|
||||
fightStart.toDate().getTime() !== fight?.start ||
|
||||
fightErgebnis !== fight?.ergebnis ||
|
||||
fightSpectatePort !== (fight?.spectatePort?.toString() ?? null) ||
|
||||
fightGroup !== (fight?.group?.id ?? null)
|
||||
);
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
async function submit() {
|
||||
loading = true;
|
||||
try {
|
||||
await onSave({
|
||||
spielmodus: fightModus!,
|
||||
map: fightMap!,
|
||||
blueTeam: fightBlueTeam!,
|
||||
redTeam: fightRedTeam!,
|
||||
start: fightStart?.toDate().getTime(),
|
||||
ergebnis: fightErgebnis,
|
||||
spectatePort: fightSpectatePort ? +fightSpectatePort : null,
|
||||
group: fightGroup,
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="fight-modus">Modus</Label>
|
||||
<Popover bind:open={gamemodeSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{$gamemodes.find((value) => value === fightModus) || fightModus || "Select a modus type..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Fight Modus..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No fight modus found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{#each $gamemodes as modus}
|
||||
<CommandItem
|
||||
value={modus}
|
||||
onSelect={() => {
|
||||
fightModus = modus;
|
||||
gamemodeSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", modus !== fightModus && "text-transparent")} />
|
||||
{modus}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label for="fight-map">Map</Label>
|
||||
<Popover bind:open={mapSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{$mapsStore.find((value) => value === fightMap) || fightMap || "Select a map..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Maps..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No map found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{#each $mapsStore as map}
|
||||
<CommandItem
|
||||
value={map}
|
||||
onSelect={() => {
|
||||
fightMap = map;
|
||||
mapSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", map !== fightMap && "text-transparent")} />
|
||||
{map}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label>Blue Team</Label>
|
||||
<TeamSelector bind:selectedTeam={fightBlueTeam} {data} fightId={fight?.id} team="BLUE" />
|
||||
<Label>Red Team</Label>
|
||||
<TeamSelector bind:selectedTeam={fightRedTeam} {data} fightId={fight?.id} team="RED" />
|
||||
<Label>Start</Label>
|
||||
<DateTimePicker bind:value={fightStart} />
|
||||
{#if fight !== null}
|
||||
<Label for="fight-ergebnis">Ergebnis</Label>
|
||||
<Select type="single" value={fightErgebnis?.toString()} onValueChange={(v) => (fightErgebnis = +v)}>
|
||||
<SelectTrigger>
|
||||
{fightErgebnis === 0 ? "Unentschieden" : (fightErgebnis === 1 ? fightBlueTeam?.name : fightRedTeam?.name) + " gewinnt"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"0"}>Unentschieden</SelectItem>
|
||||
<SelectItem value={"1"}>{fightBlueTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
|
||||
<SelectItem value={"2"}>{fightRedTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<Label for="fight-group">Gruppe</Label>
|
||||
<GroupSelector event={data.event} bind:value={fightGroup} bind:groups={data.groups}></GroupSelector>
|
||||
<Label for="spectate-port">Spectate Port</Label>
|
||||
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
|
||||
</div>
|
||||
|
||||
{@render actions(dirty && !loading, submit)}
|
||||
78
src/components/moderator/components/GroupEdit.svelte
Normal file
78
src/components/moderator/components/GroupEdit.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ResponseGroups, GroupUpdateEdit } from "@type/event";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Input } from "@components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
|
||||
const {
|
||||
group,
|
||||
actions,
|
||||
onSave,
|
||||
}: {
|
||||
group: ResponseGroups | null;
|
||||
actions: Snippet<[boolean, () => void]>;
|
||||
onSave: (groupData: GroupUpdateEdit) => void;
|
||||
} = $props();
|
||||
|
||||
let groupName = $state(group?.name ?? "");
|
||||
let groupType = $state(group?.type ?? "GROUP_STAGE");
|
||||
let pointsPerWin = $state(group?.pointsPerWin ?? 3);
|
||||
let pointsPerLoss = $state(group?.pointsPerLoss ?? 0);
|
||||
let pointsPerDraw = $state(group?.pointsPerDraw ?? 1);
|
||||
|
||||
let canSave = $derived(groupName.length > 0 && (groupType === "GROUP_STAGE" || groupType === "ELIMINATION_STAGE") && pointsPerWin !== null && pointsPerLoss !== null && pointsPerDraw !== null);
|
||||
|
||||
let dirty = $derived(
|
||||
groupName !== (group ? group.name : "") ||
|
||||
groupType !== (group ? group.type : "GROUP_STAGE") ||
|
||||
pointsPerWin !== (group ? group.pointsPerWin : 3) ||
|
||||
pointsPerLoss !== (group ? group.pointsPerLoss : 0) ||
|
||||
pointsPerDraw !== (group ? group.pointsPerDraw : 1)
|
||||
);
|
||||
|
||||
function submit() {
|
||||
onSave({
|
||||
name: groupName,
|
||||
type: groupType,
|
||||
pointsPerWin: pointsPerWin,
|
||||
pointsPerLoss: pointsPerLoss,
|
||||
pointsPerDraw: pointsPerDraw,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="group-name">Name</Label>
|
||||
<Input id="group-name" bind:value={groupName} placeholder="z.B. Gruppenphase A" />
|
||||
|
||||
<Label for="group-type">Typ</Label>
|
||||
<Select
|
||||
value={groupType}
|
||||
type="single"
|
||||
onValueChange={(v) => {
|
||||
if (v) groupType = v as "GROUP_STAGE" | "ELIMINATION_STAGE";
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-type" placeholder="Wähle einen Gruppentyp">
|
||||
{groupType === "GROUP_STAGE" ? "Gruppenphase" : "Eliminierungsphase"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GROUP_STAGE">Gruppenphase</SelectItem>
|
||||
<SelectItem value="ELIMINATION_STAGE">Eliminierungsphase</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{#if groupType === "GROUP_STAGE" && group !== null}
|
||||
<Label for="points-win">Punkte pro Sieg</Label>
|
||||
<Input id="points-win" type="number" bind:value={pointsPerWin} placeholder="3" />
|
||||
|
||||
<Label for="points-loss">Punkte pro Niederlage</Label>
|
||||
<Input id="points-loss" type="number" bind:value={pointsPerLoss} placeholder="0" />
|
||||
|
||||
<Label for="points-draw">Punkte pro Unentschieden</Label>
|
||||
<Input id="points-draw" type="number" bind:value={pointsPerDraw} placeholder="1" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render actions(group === null ? canSave : dirty, submit)}
|
||||
103
src/components/moderator/components/GroupSelector.svelte
Normal file
103
src/components/moderator/components/GroupSelector.svelte
Normal file
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
|
||||
import { ChevronsUpDownIcon, PlusIcon, CheckIcon, MinusIcon } from "lucide-svelte";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { cn } from "@components/utils";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
|
||||
import GroupEdit from "./GroupEdit.svelte";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
|
||||
let {
|
||||
event,
|
||||
groups = $bindable(),
|
||||
value = $bindable(),
|
||||
}: {
|
||||
event: SWEvent;
|
||||
groups: ResponseGroups[];
|
||||
value: number | null;
|
||||
} = $props();
|
||||
|
||||
let selectedGroup = $derived(groups.find((group) => group.id === value));
|
||||
|
||||
let createOpen = $state(false);
|
||||
let groupSelectOpen = $state(false);
|
||||
|
||||
async function handleGroupSave(group: GroupUpdateEdit) {
|
||||
let g = await $eventRepo.createGroup(event.id, group);
|
||||
groups.push(g);
|
||||
value = g.id;
|
||||
createOpen = false;
|
||||
groupSelectOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={createOpen}>
|
||||
<Popover bind:open={groupSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button id="fight-group" variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{selectedGroup?.name || "Keine Gruppe"}
|
||||
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Gruppe suchen..." />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem value={"new"} onSelect={() => (createOpen = true)}>
|
||||
<PlusIcon class={"mr-2 size-4"} />
|
||||
Neue Gruppe
|
||||
</CommandItem>
|
||||
|
||||
<CommandGroup heading="Gruppen">
|
||||
<CommandItem
|
||||
value={"none"}
|
||||
onSelect={() => {
|
||||
value = null;
|
||||
groupSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
{#if value === null}
|
||||
<CheckIcon class={"mr-2 size-4"} />
|
||||
{:else}
|
||||
<MinusIcon class={"mr-2 size-4"} />
|
||||
{/if}
|
||||
Keine Gruppe
|
||||
</CommandItem>
|
||||
|
||||
{#each groups as group}
|
||||
<CommandItem
|
||||
value={group.id.toString()}
|
||||
onSelect={() => {
|
||||
value = group.id;
|
||||
groupSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<CheckIcon class={cn("mr-2 size-4", value !== group.id && "text-transparent")} />
|
||||
{group.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
|
||||
<DialogDescription>Hier kannst du eine neue Gruppe erstellen</DialogDescription>
|
||||
</DialogHeader>
|
||||
<GroupEdit group={null} onSave={handleGroupSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</GroupEdit>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
253
src/components/moderator/components/TeamSelector.svelte
Normal file
253
src/components/moderator/components/TeamSelector.svelte
Normal file
@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import type { ResponseRelation } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
|
||||
import { cn } from "@components/utils";
|
||||
import { Check, ChevronsUpDown, GitPullRequestArrow, Plus } from "lucide-svelte";
|
||||
import type { EventModel } from "../pages/event/eventmodel.svelte";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
|
||||
interface Props {
|
||||
selectedTeam: Team | undefined;
|
||||
open?: boolean;
|
||||
team: "BLUE" | "RED";
|
||||
data: EventModel;
|
||||
fightId?: number;
|
||||
onSelect?: (team: Team) => void;
|
||||
}
|
||||
|
||||
let { selectedTeam = $bindable(), data, team, open = $bindable(false), fightId, onSelect }: Props = $props();
|
||||
|
||||
const currentRelation = $derived(data.relations.find((r) => r.fight === fightId && r.team === team));
|
||||
|
||||
let fromType = $state<"FIGHT" | "GROUP">(currentRelation?.type ?? "FIGHT");
|
||||
|
||||
let fromFight = $state<string | undefined>(currentRelation?.fromFight?.id?.toString());
|
||||
|
||||
let fromFightData = $derived(data.fights.find((f) => f.id.toString() === fromFight));
|
||||
|
||||
let fromGroup = $state<string | undefined>(currentRelation?.fromGroup?.id?.toString());
|
||||
|
||||
let fromGroupData = $derived(data.groups.find((g) => g.id.toString() === fromGroup));
|
||||
|
||||
let fromPlace = $state<string | undefined>(currentRelation?.fromPlace?.toString());
|
||||
|
||||
let relationOpen = $state(false);
|
||||
|
||||
async function saveRelation() {
|
||||
relationOpen = false;
|
||||
if (currentRelation === undefined) {
|
||||
await $eventRepo.createRelation(data.event.id, {
|
||||
fightId: fightId!,
|
||||
team,
|
||||
fromType,
|
||||
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
|
||||
fromPlace: parseInt(fromPlace!),
|
||||
});
|
||||
} else {
|
||||
await $eventRepo.updateRelation(data.event.id, currentRelation.id, {
|
||||
from: {
|
||||
fromType,
|
||||
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
|
||||
fromPlace: parseInt(fromPlace!),
|
||||
},
|
||||
});
|
||||
}
|
||||
data.relations = await $eventRepo.listRelations(data.event.id);
|
||||
reset();
|
||||
}
|
||||
|
||||
async function clearRelation() {
|
||||
relationOpen = false;
|
||||
if (currentRelation !== undefined) {
|
||||
await $eventRepo.deleteRelation(data.event.id, currentRelation.id);
|
||||
data.relations = await $eventRepo.listRelations(data.event.id);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
fromType = currentRelation?.type ?? "FIGHT";
|
||||
fromFight = currentRelation?.fromFight?.id.toString();
|
||||
fromGroup = currentRelation?.fromGroup?.id.toString();
|
||||
fromPlace = currentRelation?.fromPlace.toString();
|
||||
}
|
||||
|
||||
let canSave = $derived(
|
||||
(fromType !== currentRelation?.type ||
|
||||
fromFight !== (currentRelation?.fromFight?.id.toString() ?? "") ||
|
||||
fromGroup !== (currentRelation?.fromGroup?.id.toString() ?? "") ||
|
||||
fromPlace !== (currentRelation?.fromPlace.toString() ?? "")) &&
|
||||
((fromType === "FIGHT" && fromFight !== "" && fromPlace !== "") || (fromType === "GROUP" && fromGroup !== "" && fromPlace !== ""))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Popover bind:open>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
|
||||
{#if selectedTeam?.id === -1}
|
||||
???
|
||||
{:else if selectedTeam?.id === 0}
|
||||
PUB
|
||||
{:else}
|
||||
{data.teams.find((v) => v.id === selectedTeam?.id)?.name || selectedTeam?.name || "Select a team..."}
|
||||
{/if}
|
||||
|
||||
{#if currentRelation !== undefined}
|
||||
({#if currentRelation.type === "FIGHT"}
|
||||
{currentRelation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von {currentRelation.fromFight?.blueTeam.name} vs {currentRelation.fromFight?.redTeam.name} ({new Date(
|
||||
currentRelation.fromFight?.start ?? 0
|
||||
).toLocaleTimeString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})})
|
||||
{:else}
|
||||
{currentRelation.fromPlace + 1}. Platz von {currentRelation.fromGroup?.name}
|
||||
{/if})
|
||||
{/if}
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
selectedTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
selectedTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each data.teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
selectedTeam = team;
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== selectedTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover bind:open={relationOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button {...props} size="icon" variant={currentRelation !== undefined ? "default" : "outline"} disabled={fightId === undefined}>
|
||||
<GitPullRequestArrow />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Kampfverbindung</TooltipContent>
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Tabs bind:value={fromType}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="FIGHT">Kampf</TabsTrigger>
|
||||
<TabsTrigger value="GROUP">Gruppe</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="FIGHT">
|
||||
<Label>Kampf</Label>
|
||||
<Select bind:value={fromFight} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromFightData
|
||||
? `${new Date(fromFightData.start).toLocaleString("de-DE", { timeStyle: "short" })}: ${fromFightData.blueTeam.kuerzel} vs. ${fromFightData.redTeam.kuerzel}`
|
||||
: "Kampf auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.fights.filter((v) => v.id !== fightId) as fight (fight.id)}
|
||||
<SelectItem value={fight.id.toString()}
|
||||
>{new Date(fight.start).toLocaleString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})}: {fight.blueTeam.kuerzel} vs. {fight.redTeam.kuerzel}</SelectItem
|
||||
>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Team</Label>
|
||||
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromPlace ? (fromPlace === "0" ? "Gewinner" : "Verlierer") : "Platz auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"0"}>Gewinner</SelectItem>
|
||||
<SelectItem value={"1"}>Verlierer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TabsContent>
|
||||
<TabsContent value="GROUP">
|
||||
<Label>Gruppe</Label>
|
||||
<Select bind:value={fromGroup} type="single" disabled={data.groups.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromGroupData ? fromGroupData.name : "Kampf auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.groups as group (group.id)}
|
||||
<SelectItem value={group.id.toString()}>{group.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Platz</Label>
|
||||
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromPlace ? `${parseInt(fromPlace) + 1}. Platz` : "Platz auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each Array(32) as _, i}
|
||||
<SelectItem value={i.toString()}>{i + 1}. Platz</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button onclick={clearRelation} variant="destructive">Löschen</Button>
|
||||
<Button onclick={saveRelation} disabled={!canSave}>Übernehmen</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
30
src/components/moderator/layout/NavLinks.svelte
Normal file
30
src/components/moderator/layout/NavLinks.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { location } from "svelte-spa-router";
|
||||
</script>
|
||||
|
||||
<nav class="flex items-center space-x-4 lg:space-x-6 mx-6">
|
||||
<a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/"}> Dashboard </a>
|
||||
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a>
|
||||
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
|
||||
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
|
||||
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
|
||||
</nav>
|
||||
22
src/components/moderator/pages/dashboard/Dashboard.svelte
Normal file
22
src/components/moderator/pages/dashboard/Dashboard.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<div class="p-4">
|
||||
<h1 class="font-bold text-xl">SteamWar Dashboard</h1>
|
||||
</div>
|
||||
51
src/components/moderator/pages/event/Event.svelte
Normal file
51
src/components/moderator/pages/event/Event.svelte
Normal file
@ -0,0 +1,51 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { eventRepo } from "@repo/event.ts";
|
||||
import EventView from "@components/moderator/pages/event/EventView.svelte";
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import { onMount } from "svelte";
|
||||
import { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
interface Props {
|
||||
params: { id: number };
|
||||
}
|
||||
|
||||
let { params }: Props = $props();
|
||||
|
||||
let id = params.id;
|
||||
let data: EventModel | undefined = $state(undefined);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
data = new EventModel(await $eventRepo.getEvent(id.toString()));
|
||||
loaded = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<EventView bind:event={data!!} {refresh} />
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
152
src/components/moderator/pages/event/EventEdit.svelte
Normal file
152
src/components/moderator/pages/event/EventEdit.svelte
Normal file
@ -0,0 +1,152 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Input } from "@components/ui/input";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import type { SWEvent } from "@type/event.ts";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import { Button, buttonVariants } from "@components/ui/button";
|
||||
import { ChevronsUpDown } from "lucide-svelte";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { schemTypes } from "@stores/stores.ts";
|
||||
import Check from "lucide-svelte/icons/check";
|
||||
import { cn } from "@components/utils.ts";
|
||||
import { Switch } from "@components/ui/switch";
|
||||
import { eventRepo } from "@repo/event.ts";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@components/ui/alert-dialog";
|
||||
|
||||
const { event }: { event: SWEvent } = $props();
|
||||
|
||||
let rootEvent: SWEvent = $state(event);
|
||||
|
||||
let eventName = $state(rootEvent.name);
|
||||
let eventDeadline = $state(fromAbsolute(rootEvent.deadline, "Europe/Berlin"));
|
||||
let eventStart = $state(fromAbsolute(rootEvent.start, "Europe/Berlin"));
|
||||
let eventEnd = $state(fromAbsolute(rootEvent.end, "Europe/Berlin"));
|
||||
let eventTeamSize = $state(rootEvent.maxTeamMembers);
|
||||
let eventSchematicType = $state(rootEvent.schemType);
|
||||
let eventPublicsOnly = $state(rootEvent.publicSchemsOnly);
|
||||
|
||||
let dirty = $derived(
|
||||
eventName !== rootEvent.name ||
|
||||
eventDeadline.toDate().getTime() !== rootEvent.deadline ||
|
||||
eventStart.toDate().getTime() !== rootEvent.start ||
|
||||
eventEnd.toDate().getTime() !== rootEvent.end ||
|
||||
eventTeamSize !== rootEvent.maxTeamMembers ||
|
||||
eventSchematicType !== rootEvent.schemType ||
|
||||
eventPublicsOnly !== rootEvent.publicSchemsOnly
|
||||
);
|
||||
|
||||
async function updateEvent() {
|
||||
rootEvent = await $eventRepo.updateEvent(event.id.toString(), {
|
||||
name: eventName,
|
||||
deadline: eventDeadline.toDate().getTime(),
|
||||
start: eventStart.toDate().getTime(),
|
||||
end: eventEnd.toDate().getTime(),
|
||||
maxTeamMembers: eventTeamSize,
|
||||
schemType: eventSchematicType,
|
||||
publicSchemsOnly: eventPublicsOnly,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="event-name">Name</Label>
|
||||
<Input id="event-name" bind:value={eventName} />
|
||||
<Label>Deadline</Label>
|
||||
<DateTimePicker bind:value={eventDeadline} />
|
||||
<Label>Start</Label>
|
||||
<DateTimePicker bind:value={eventStart} />
|
||||
<Label>End</Label>
|
||||
<DateTimePicker bind:value={eventEnd} />
|
||||
<Label for="event-size">Teamsize</Label>
|
||||
<Input id="event-size" bind:value={eventTeamSize} type="number" />
|
||||
<Label>Schematic Type</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{$schemTypes.find((value) => value.db === eventSchematicType)?.name || eventSchematicType || "Select a schematic type..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search schematic types..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No schematic type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"null"}
|
||||
onSelect={() => {
|
||||
eventSchematicType = null;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
|
||||
Keinen
|
||||
</CommandItem>
|
||||
{#each $schemTypes as type}
|
||||
<CommandItem
|
||||
value={type.db}
|
||||
onSelect={() => {
|
||||
eventSchematicType = type.db;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", eventSchematicType !== type.db && "text-transparent")} />
|
||||
{type.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label for="event-publics">Publics Schematics Only</Label>
|
||||
<Switch id="event-publics" bind:checked={eventPublicsOnly} />
|
||||
<div class="flex flex-row justify-end border-t pt-2 gap-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger class={buttonVariants({ variant: "destructive" })}>Delete</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction disabled>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button disabled={!dirty} onclick={updateEvent}>Update</Button>
|
||||
</div>
|
||||
</div>
|
||||
315
src/components/moderator/pages/event/EventFightList.svelte
Normal file
315
src/components/moderator/pages/event/EventFightList.svelte
Normal file
@ -0,0 +1,315 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import FightEditRow from "./FightEditRow.svelte";
|
||||
|
||||
import type { EventFightEdit } from "@type/event";
|
||||
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
|
||||
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
|
||||
import { columns } from "./columns";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
|
||||
import { Checkbox } from "@components/ui/checkbox";
|
||||
import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarTrigger, MenubarSub, MenubarSubTrigger, MenubarSubContent } from "@components/ui/menubar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
|
||||
import FightEdit from "@components/moderator/components/FightEdit.svelte";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import GroupEditDialog from "./GroupEditDialog.svelte";
|
||||
import GroupResultsDialog from "./GroupResultsDialog.svelte";
|
||||
import type { ResponseGroups } from "@type/event";
|
||||
import { EditIcon, GroupIcon, LinkIcon } from "lucide-svelte";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
|
||||
import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
|
||||
|
||||
let sorting = $state<SortingState>([]);
|
||||
let columnFilters = $state<ColumnFiltersState>([]);
|
||||
let selection = $state<RowSelectionState>({});
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data.fights;
|
||||
},
|
||||
initialState: {
|
||||
columnOrder: ["auswahl", "begegnung", "group"],
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters;
|
||||
},
|
||||
get grouping() {
|
||||
return ["group"];
|
||||
},
|
||||
get rowSelection() {
|
||||
return selection;
|
||||
},
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
sorting = updater(sorting);
|
||||
} else {
|
||||
sorting = updater;
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
columnFilters = updater(columnFilters);
|
||||
} else {
|
||||
columnFilters = updater;
|
||||
}
|
||||
},
|
||||
onRowSelectionChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
selection = updater(selection);
|
||||
} else {
|
||||
selection = updater;
|
||||
}
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getGroupedRowModel: getGroupedRowModel(),
|
||||
groupedColumnMode: "remove",
|
||||
getRowId: (row) => row.id.toString(),
|
||||
});
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editGroupOpen = $state(false);
|
||||
let selectedGroup: ResponseGroups | null = $state(null);
|
||||
let groupResultsOpen = $state(false);
|
||||
let selectedGroupForResults: ResponseGroups | null = $state(null);
|
||||
|
||||
let groupChangeOpen = $state(false);
|
||||
let groupChangeSelected: number | null = $state(null);
|
||||
|
||||
async function handleSave(fight: EventFightEdit) {
|
||||
await $eventRepo.createFight(data.event.id.toString(), {
|
||||
...fight,
|
||||
blueTeam: fight.blueTeam.id,
|
||||
redTeam: fight.redTeam.id,
|
||||
});
|
||||
|
||||
refresh();
|
||||
createOpen = false;
|
||||
}
|
||||
|
||||
function openGroupEditDialog(group: ResponseGroups) {
|
||||
selectedGroup = group;
|
||||
editGroupOpen = true;
|
||||
}
|
||||
|
||||
function openGroupResultsDialog(group: ResponseGroups) {
|
||||
selectedGroupForResults = group;
|
||||
groupResultsOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={createOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fight Erstellen</DialogTitle>
|
||||
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FightEdit fight={null} {data} onSave={handleSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</FightEdit>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{#if selectedGroup}
|
||||
<GroupEditDialog bind:open={editGroupOpen} group={selectedGroup} event={data.event} bind:groups={data.groups} />
|
||||
{/if}
|
||||
|
||||
{#if selectedGroupForResults}
|
||||
<GroupResultsDialog bind:open={groupResultsOpen} group={selectedGroupForResults} teams={data.teams} fights={data.fights} />
|
||||
{/if}
|
||||
|
||||
<Dialog bind:open={groupChangeOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gruppe Ändern</DialogTitle>
|
||||
<DialogDescription>Hier kannst du die Gruppe der ausgewählten Kämpfe ändern</DialogDescription>
|
||||
</DialogHeader>
|
||||
<GroupSelector event={data.event} bind:groups={data.groups} bind:value={groupChangeSelected} />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
groupChangeOpen = false;
|
||||
let group = data.groups.find((g) => g.id === groupChangeSelected);
|
||||
if (group) {
|
||||
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
for (const g of selectedGroups) {
|
||||
await $fightRepo.updateFight(data.event.id, g.id, {
|
||||
group: group.id,
|
||||
spielmodus: null,
|
||||
map: null,
|
||||
blueTeam: null,
|
||||
redTeam: null,
|
||||
start: null,
|
||||
spectatePort: null,
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
}}>Speichern</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
|
||||
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
|
||||
<MenubarItem disabled>Spectate Port Ändern</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Erstellen</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
|
||||
<MenubarGroup>
|
||||
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
|
||||
<a href="#/event/{data.event.id}/generate">
|
||||
<MenubarItem>Gruppenphase</MenubarItem>
|
||||
</a>
|
||||
</MenubarGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger disabled={!data.groups.length}>Gruppen</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{#each data.groups as group (group.id)}
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>
|
||||
{group.name}
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem onclick={() => openGroupEditDialog(group)}>Bearbeiten</MenubarItem>
|
||||
<MenubarItem onclick={() => openGroupResultsDialog(group)}>Gruppen Ergebnisse</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
{/each}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
<Button variant="outline" class="ml-4" onclick={refresh}>Neu laden</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<TableRow>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<TableHead>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
||||
{/if}
|
||||
</TableHead>
|
||||
{/each}
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each table.getRowModel().rows as groupRow (groupRow.id)}
|
||||
{#if groupRow.getIsGrouped()}
|
||||
{@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))}
|
||||
<TableRow class="font-bold">
|
||||
<TableCell colspan={columns.length - 1}>
|
||||
<Checkbox
|
||||
checked={groupRow.getIsSelected()}
|
||||
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
|
||||
onCheckedChange={() => groupRow.toggleSelected()}
|
||||
class="mr-4"
|
||||
/>
|
||||
{group?.name ?? "Keine Gruppe"}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{#if group}
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group!)}>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group!)}>
|
||||
<GroupIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<LinkIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onclick={() => navigator.clipboard.writeText(`<group-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
|
||||
>Punkte Tabelle</DropdownMenuItem
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onclick={() => navigator.clipboard.writeText(`<fight-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
|
||||
>Kampf Tabelle</DropdownMenuItem
|
||||
>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#each groupRow.subRows as row (row.id)}
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<TableCell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</TableCell>
|
||||
{/each}
|
||||
<TableCell class="text-right">
|
||||
<FightEditRow fight={row.original} {data} onupdate={(update) => (data._fights = data._fights.map((v) => (v.id === update.id ? update : v)))} {refresh}></FightEditRow>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{:else}
|
||||
<TableRow data-state={groupRow.getIsSelected() && "selected"}>
|
||||
{#each groupRow.getVisibleCells() as cell (cell.id)}
|
||||
<TableCell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</TableCell>
|
||||
{/each}
|
||||
</TableRow>
|
||||
{/if}
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={columns.length} class="h-24 text-center">No results.</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
46
src/components/moderator/pages/event/EventView.svelte
Normal file
46
src/components/moderator/pages/event/EventView.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
|
||||
import EventFightList from "@components/moderator/pages/event/EventFightList.svelte";
|
||||
import RefereesList from "@components/moderator/pages/event/RefereesList.svelte";
|
||||
import TeamTable from "@components/moderator/pages/event/TeamTable.svelte";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { event = $bindable(), refresh }: { event: EventModel; refresh: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col m-4 p-4 rounded-md border gap-4">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="md:w-1/3">
|
||||
<h1 class="text-2xl font-bold mb-4">{event.event.name}</h1>
|
||||
<EventEdit event={event.event} />
|
||||
</div>
|
||||
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
|
||||
<h2 class="text-xl font-bold mb-4">Teams</h2>
|
||||
<TeamTable bind:event />
|
||||
</div>
|
||||
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
|
||||
<h2 class="text-xl font-bold mb-4">Referees</h2>
|
||||
<RefereesList {event} />
|
||||
</div>
|
||||
</div>
|
||||
<EventFightList bind:data={event} {refresh} />
|
||||
</div>
|
||||
84
src/components/moderator/pages/event/FightEditRow.svelte
Normal file
84
src/components/moderator/pages/event/FightEditRow.svelte
Normal file
@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { EditIcon, CopyIcon } from "lucide-svelte";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
|
||||
import FightEdit from "@components/moderator/components/FightEdit.svelte";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { fight, onupdate, refresh, data }: { fight: EventFight; onupdate: (update: EventFight) => void; refresh: () => void; data: EventModel } = $props();
|
||||
|
||||
let editOpen = $state(false);
|
||||
let duplicateOpen = $state(false);
|
||||
|
||||
async function handleSave(fightData: EventFightEdit) {
|
||||
let f = await $fightRepo.updateFight(data.event.id, fight.id, {
|
||||
...fightData,
|
||||
blueTeam: fightData.blueTeam.id,
|
||||
redTeam: fightData.redTeam.id,
|
||||
group: fightData.group ?? -1,
|
||||
});
|
||||
|
||||
onupdate(f);
|
||||
|
||||
editOpen = false;
|
||||
}
|
||||
|
||||
async function handlyCopy(fightData: EventFightEdit) {
|
||||
await $eventRepo.createFight(data.event.id.toString(), {
|
||||
...fightData,
|
||||
blueTeam: fightData.blueTeam.id,
|
||||
redTeam: fightData.redTeam.id,
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
duplicateOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Dialog bind:open={editOpen}>
|
||||
<DialogTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EditIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fight bearbeiten</DialogTitle>
|
||||
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FightEdit {fight} {data} onSave={handleSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</FightEdit>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog bind:open={duplicateOpen}>
|
||||
<DialogTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fight duplizieren</DialogTitle>
|
||||
<DialogDescription>Hier kannst du die Daten des duplizierten Fights ändern</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FightEdit {fight} {data} onSave={handlyCopy}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button onclick={submit}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</FightEdit>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
45
src/components/moderator/pages/event/GroupEditDialog.svelte
Normal file
45
src/components/moderator/pages/event/GroupEditDialog.svelte
Normal file
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
|
||||
import GroupEdit from "@components/moderator/components/GroupEdit.svelte";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { eventRepo } from "@repo/event";
|
||||
|
||||
let { group, groups = $bindable(), open = $bindable(), event }: { group: ResponseGroups; groups: ResponseGroups[]; open?: boolean; event: SWEvent } = $props();
|
||||
|
||||
async function handleSave(groupData: GroupUpdateEdit) {
|
||||
if (!group) return;
|
||||
const updatedGroup = await $eventRepo.updateGroup(event.id.toString(), group.id.toString(), groupData);
|
||||
groups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!group) return;
|
||||
await $eventRepo.deleteGroup(event.id.toString(), group.id.toString());
|
||||
groups = groups.filter((g) => g.id !== group.id);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if group}
|
||||
<Dialog bind:open>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gruppe Bearbeiten: {group.name}</DialogTitle>
|
||||
<DialogDescription>Hier kannst du die Gruppendetails bearbeiten.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<GroupEdit {group} onSave={handleSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter class="flex justify-between">
|
||||
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</GroupEdit>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/if}
|
||||
@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseTeam } from "@type/event";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
|
||||
import { Button } from "@components/ui/button";
|
||||
import type { Team } from "@components/types/team";
|
||||
|
||||
let { open = $bindable(), group, teams, fights }: { open?: boolean; group: ResponseGroups; teams: Team[]; fights: EventFight[] } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<DialogContent class="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ergebnisse: {group?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Punkte: Sieg: {group?.pointsPerWin}, Unentschieden: {group?.pointsPerDraw}, Niederlage: {group?.pointsPerLoss}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{#if group.points !== null}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead class="text-right">Spiele</TableHead>
|
||||
<TableHead class="text-right">Punkte</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each Object.entries(group.points).toSorted((a, b) => b[1] - a[1]) as [teamIdString, points] (teamIdString)}
|
||||
{@const teamId = Number(teamIdString)}
|
||||
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam | undefined}
|
||||
{@const playedGames = fights.filter((f) => f.hasFinished && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
|
||||
<TableRow>
|
||||
<TableCell>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
|
||||
<TableCell class="text-right">{playedGames}</TableCell>
|
||||
<TableCell class="text-right font-bold">{points}</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else}
|
||||
<p class="text-center py-4">Noch keine Ergebnisse für diese Gruppe vorhanden oder keine Spiele zugeordnet.</p>
|
||||
{/if}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Schließen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
86
src/components/moderator/pages/event/RefereesList.svelte
Normal file
86
src/components/moderator/pages/event/RefereesList.svelte
Normal file
@ -0,0 +1,86 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command/index.js";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover/index.js";
|
||||
import { Button } from "@components/ui/button/index.js";
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import { eventRepo } from "@repo/event";
|
||||
import { players } from "@stores/stores";
|
||||
|
||||
const { event }: { event: ExtendedEvent } = $props();
|
||||
|
||||
let referees = $state(event.referees);
|
||||
|
||||
async function addReferee(value: string) {
|
||||
await $eventRepo.updateReferees(event.event.id.toString(), [value]);
|
||||
referees = await $eventRepo.listReferees(event.event.id.toString());
|
||||
}
|
||||
|
||||
async function removeReferee(value: string) {
|
||||
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
|
||||
referees = await $eventRepo.listReferees(event.event.id.toString());
|
||||
}
|
||||
|
||||
let playerSearch = $state("");
|
||||
</script>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each referees as referee (referee.uuid)}
|
||||
<TableRow>
|
||||
<TableCell>{referee.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
<Popover>
|
||||
<TableCaption>
|
||||
<PopoverTrigger>
|
||||
<Button>Hinzufügen</Button>
|
||||
</PopoverTrigger>
|
||||
</TableCaption>
|
||||
<PopoverContent class="p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput bind:value={playerSearch} placeholder="Search players..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No Players found :(</CommandEmpty>
|
||||
<CommandGroup heading="Players">
|
||||
{#each $players
|
||||
.filter((v) => v.name.toLowerCase().includes(playerSearch.toLowerCase()))
|
||||
.filter((v, i) => i < 50)
|
||||
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
|
||||
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Table>
|
||||
91
src/components/moderator/pages/event/TeamTable.svelte
Normal file
91
src/components/moderator/pages/event/TeamTable.svelte
Normal file
@ -0,0 +1,91 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
|
||||
import { eventRepo } from "@repo/event";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { teams } from "@components/stores/stores";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { event = $bindable() }: { event: EventModel } = $props();
|
||||
|
||||
async function addTeam(value: number) {
|
||||
await $eventRepo.updateTeams(event.event.id.toString(), [value]);
|
||||
event.teams = await $eventRepo.listTeams(event.event.id.toString());
|
||||
}
|
||||
|
||||
async function removeTeam(value: number) {
|
||||
await $eventRepo.deleteTeams(event.event.id.toString(), [value]);
|
||||
event.teams = await $eventRepo.listTeams(event.event.id.toString());
|
||||
}
|
||||
|
||||
let teamSearch = $state("");
|
||||
</script>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each event.teams as t (t.id)}
|
||||
<TableRow>
|
||||
<TableCell>{t.kuerzel}</TableCell>
|
||||
<TableCell>{t.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button onclick={() => removeTeam(t.id)} variant="outline" size="sm">{t.name} abmelden</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{#if event.teams.length === 0}
|
||||
<TableRow>
|
||||
<TableCell class="text-center col-span-3">No teams available</TableCell>
|
||||
</TableRow>
|
||||
{/if}
|
||||
</TableBody>
|
||||
<Popover>
|
||||
<TableCaption>
|
||||
<PopoverTrigger>
|
||||
<Button>Team Anmelden</Button>
|
||||
</PopoverTrigger>
|
||||
</TableCaption>
|
||||
<PopoverContent class="p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput bind:value={teamSearch} placeholder="Search teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No teams found :(</CommandEmpty>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each $teams
|
||||
.filter((v) => v.name.includes(teamSearch))
|
||||
.filter((v) => !event.teams.some((k) => k.id === v.id))
|
||||
.filter((v, i) => i < 50) as t (t.id)}
|
||||
<CommandItem value={t.id.toString()} onSelect={() => addTeam(t.id)} keywords={[t.name, t.kuerzel]}>{t.name}</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Table>
|
||||
104
src/components/moderator/pages/event/columns.ts
Normal file
104
src/components/moderator/pages/event/columns.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Checkbox } from "@components/ui/checkbox";
|
||||
import { renderComponent } from "@components/ui/data-table";
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { EventFightModel } from "./eventmodel.svelte";
|
||||
|
||||
export const columns: ColumnDef<EventFightModel>[] = [
|
||||
{
|
||||
id: "auswahl",
|
||||
header: ({ table }) => {
|
||||
return renderComponent(Checkbox, {
|
||||
checked: table.getIsAllRowsSelected(),
|
||||
indeterminate: table.getIsSomeRowsSelected(),
|
||||
onCheckedChange: () => {
|
||||
if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
|
||||
const now = new Date();
|
||||
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.start) > now);
|
||||
|
||||
if (rows.length > 0) {
|
||||
rows.forEach((row) => {
|
||||
row.toggleSelected();
|
||||
});
|
||||
} else {
|
||||
table.toggleAllRowsSelected(true);
|
||||
}
|
||||
} else if (table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
|
||||
table.toggleAllRowsSelected(true);
|
||||
} else {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(Checkbox, {
|
||||
checked: row.getIsSelected(),
|
||||
onCheckedChange: row.getToggleSelectedHandler(),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (r) => r.blueTeam.nameWithRelation + " vs " + r.redTeam.nameWithRelation,
|
||||
id: "begegnung",
|
||||
header: "Begegnung",
|
||||
},
|
||||
{
|
||||
header: "Gruppe",
|
||||
accessorKey: "group.id",
|
||||
id: "group",
|
||||
},
|
||||
{
|
||||
header: "Datum",
|
||||
accessorKey: "start",
|
||||
id: "start",
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.getValue("start")).toLocaleString("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Spielmodus",
|
||||
accessorKey: "spielmodus",
|
||||
},
|
||||
{
|
||||
header: "Map",
|
||||
accessorKey: "map",
|
||||
},
|
||||
{
|
||||
header: "Ergebnis",
|
||||
accessorKey: "ergebnis",
|
||||
cell: ({ row }) => {
|
||||
const fight = row.original;
|
||||
if (!fight.hasFinished) {
|
||||
return "Noch nicht gespielt";
|
||||
} else if (fight.ergebnis === 1) {
|
||||
return fight.blueTeam.name + " hat gewonnen";
|
||||
} else if (fight.ergebnis === 2) {
|
||||
return fight.redTeam.name + " hat gewonnen";
|
||||
} else {
|
||||
return "Unentschieden";
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
68
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal file
68
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { ResponseUser } from "@components/repo/event";
|
||||
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export class EventModel {
|
||||
public event: SWEvent = $state({} as SWEvent);
|
||||
public teams: Array<Team> = $state([]);
|
||||
public groups: Array<ResponseGroups> = $state([]);
|
||||
public _fights: Array<EventFight> = $state([]);
|
||||
public referees: Array<ResponseUser> = $state([]);
|
||||
public relations: Array<ResponseRelation> = $state([]);
|
||||
|
||||
public fights = $derived(this.remapFights(this._fights, this.relations));
|
||||
|
||||
constructor(data: ExtendedEvent) {
|
||||
this.event = data.event;
|
||||
this.relations = data.relations;
|
||||
this.teams = data.teams;
|
||||
this.groups = data.groups;
|
||||
this._fights = data.fights;
|
||||
this.referees = data.referees;
|
||||
}
|
||||
|
||||
private remapFights(v: Array<EventFight>, rels: Array<ResponseRelation>) {
|
||||
return v.map((fight) => {
|
||||
let f = JSON.parse(JSON.stringify(fight)) as EventFight;
|
||||
|
||||
let blueTeamRelation = "";
|
||||
let redTeamRelation = "";
|
||||
|
||||
let relations = rels.filter((relation) => relation.fight === f.id);
|
||||
|
||||
relations.forEach((relation) => {
|
||||
let str = "";
|
||||
if (relation.type === "FIGHT") {
|
||||
str = `${relation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von ${relation.fromFight?.blueTeam.name} vs ${relation.fromFight?.redTeam.name} (${new Date(
|
||||
relation.fromFight?.start ?? 0
|
||||
).toLocaleTimeString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})})`;
|
||||
} else {
|
||||
str = `${relation.fromPlace + 1}. Platz von ${relation.fromGroup?.name}`;
|
||||
}
|
||||
|
||||
if (relation.team === "BLUE") {
|
||||
blueTeamRelation = str;
|
||||
} else {
|
||||
redTeamRelation = str;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...f,
|
||||
blueTeam: {
|
||||
...f.blueTeam,
|
||||
nameWithRelation: blueTeamRelation ? `${f.blueTeam.name} (${blueTeamRelation})` : f.blueTeam.name,
|
||||
},
|
||||
redTeam: {
|
||||
...f.redTeam,
|
||||
nameWithRelation: redTeamRelation ? `${f.redTeam.name} (${redTeamRelation})` : f.redTeam.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFightModel = (typeof EventModel.prototype.fights)[number];
|
||||
173
src/components/moderator/pages/events/Events.svelte
Normal file
173
src/components/moderator/pages/events/Events.svelte
Normal file
@ -0,0 +1,173 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { eventRepo } from "@repo/event.ts";
|
||||
import EventCard from "@components/moderator/components/EventCard.svelte";
|
||||
import { Button } from "@components/ui/button/index.js";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog/index.js";
|
||||
import { Input } from "@components/ui/input/index.js";
|
||||
import { Label } from "@components/ui/label/index.js";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { PlusIcon } from "lucide-svelte";
|
||||
import dayjs from "dayjs";
|
||||
import { fromAbsolute, now, ZonedDateTime } from "@internationalized/date";
|
||||
|
||||
let eventsFuture = $state($eventRepo.listEvents());
|
||||
let millis = Date.now();
|
||||
|
||||
let createOpen = $state(false);
|
||||
let newEventName = $state("");
|
||||
let newEventStart: ZonedDateTime = $state(now("Europe/Berlin"));
|
||||
let newEventEnd: ZonedDateTime = $state(
|
||||
now("Europe/Berlin").add({
|
||||
days: 1,
|
||||
})
|
||||
);
|
||||
let isSubmitting = $state(false);
|
||||
let errorMsg = $state("");
|
||||
|
||||
function resetFormFields() {
|
||||
newEventName = "";
|
||||
newEventStart = now("Europe/Berlin");
|
||||
newEventEnd = now("Europe/Berlin").add({
|
||||
days: 1,
|
||||
});
|
||||
errorMsg = "";
|
||||
isSubmitting = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (createOpen) {
|
||||
resetFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
const canSubmit = $derived(
|
||||
newEventName.trim() !== "" &&
|
||||
newEventStart &&
|
||||
newEventEnd &&
|
||||
dayjs(newEventStart.toDate()).isValid() &&
|
||||
dayjs(newEventEnd.toDate()).isValid() &&
|
||||
newEventStart.toDate() < newEventEnd.toDate() &&
|
||||
!isSubmitting
|
||||
);
|
||||
|
||||
async function submitCreateEvent() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
isSubmitting = true;
|
||||
errorMsg = "";
|
||||
|
||||
const payload = {
|
||||
name: newEventName.trim(),
|
||||
start: dayjs(newEventStart.toDate()),
|
||||
end: dayjs(newEventEnd.toDate()),
|
||||
};
|
||||
|
||||
try {
|
||||
await $eventRepo.createEvent(payload);
|
||||
eventsFuture = $eventRepo.listEvents(); // Refresh the list
|
||||
createOpen = false;
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message || "Failed to create event. Please try again.";
|
||||
console.error("Failed to create event:", e);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 min-h-screen">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-semibold">Events</h1>
|
||||
<Dialog bind:open={createOpen}>
|
||||
<DialogTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>
|
||||
<PlusIcon class="mr-2" />
|
||||
Create Event
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Event</DialogTitle>
|
||||
<DialogDescription>Fill in the details for the new event. Click create when you're done.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eventName" class="text-right">Name</Label>
|
||||
<Input id="eventName" bind:value={newEventName} class="col-span-3" placeholder="Event Name" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eventStart" class="text-right">Start</Label>
|
||||
<div class="col-span-3">
|
||||
<DateTimePicker bind:value={newEventStart} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eventEnd" class="text-right">End</Label>
|
||||
<div class="col-span-3">
|
||||
<DateTimePicker bind:value={newEventEnd} />
|
||||
</div>
|
||||
</div>
|
||||
{#if errorMsg}
|
||||
<p class="col-span-4 text-sm text-red-600 dark:text-red-500 text-center">{errorMsg}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>Cancel</Button>
|
||||
{/snippet}
|
||||
</DialogClose>
|
||||
<Button onclick={submitCreateEvent} disabled={!canSubmit}>
|
||||
{#if isSubmitting}
|
||||
Creating...
|
||||
{:else}
|
||||
Create Event
|
||||
{/if}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{#await eventsFuture}
|
||||
<p>Loading...</p>
|
||||
{:then events}
|
||||
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Upcoming</h1>
|
||||
<div class="grid gap-4 p-4 border-b" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||
{#each events.filter((e) => e.start > millis) as event (event.id)}
|
||||
<a href="#/event/{event.id}">
|
||||
<EventCard {event} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Past</h1>
|
||||
<div class="grid gap-4 p-4" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||
{#each events.filter((e) => e.start < millis).reverse() as event (event.id)}
|
||||
<a href="#/event/{event.id}">
|
||||
<EventCard {event} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
|
||||
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
|
||||
let {
|
||||
data,
|
||||
}: {
|
||||
data: ExtendedEvent;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="m-4">
|
||||
<Tabs value="group">
|
||||
<TabsList class="mb-4">
|
||||
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
|
||||
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="group">
|
||||
<GroupPhaseGenerator {data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
22
src/components/moderator/pages/generators/Generator.svelte
Normal file
22
src/components/moderator/pages/generators/Generator.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import FightsGenerator from "./FightsGenerator.svelte";
|
||||
|
||||
interface Props {
|
||||
params: { id: number };
|
||||
}
|
||||
|
||||
let { params }: Props = $props();
|
||||
|
||||
let id = params.id;
|
||||
|
||||
let future = $eventRepo.getEvent(id.toString());
|
||||
</script>
|
||||
|
||||
{#await future}
|
||||
<p>Loading...</p>
|
||||
{:then event}
|
||||
<FightsGenerator data={event} />
|
||||
{:catch error}
|
||||
<p class="text-red-500">Error loading event: {error.message}</p>
|
||||
{/await}
|
||||
@ -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>
|
||||
116
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal file
116
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal file
@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "@components/ui/separator";
|
||||
import { manager, OpenEditPage } from "./page.svelte";
|
||||
import { File, X } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import EasyMDE from "easymde";
|
||||
import "easymde/dist/easymde.min.css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark";
|
||||
import FrontmatterEditor from "./FrontmatterEditor.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { Button } from "@components/ui/button";
|
||||
|
||||
let codemirrorParent: HTMLElement | undefined = $state();
|
||||
let easyMdeParent: HTMLElement | undefined = $state();
|
||||
let easyMdeWrapper: HTMLElement | undefined = $state();
|
||||
|
||||
let easyMde: EasyMDE | null = $state(null);
|
||||
let view: EditorView | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
switch (manager.selectedPage?.fileType) {
|
||||
case "md":
|
||||
case "mdx":
|
||||
easyMdeWrapper?.classList.remove("hidden");
|
||||
codemirrorParent?.classList.add("hidden");
|
||||
break;
|
||||
case "json":
|
||||
easyMdeWrapper?.classList.add("hidden");
|
||||
codemirrorParent?.classList.remove("hidden");
|
||||
break;
|
||||
default:
|
||||
easyMdeWrapper?.classList.add("hidden");
|
||||
codemirrorParent?.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
function updatePage(page: OpenEditPage | undefined) {
|
||||
if (page) {
|
||||
easyMde?.value(page.content || "");
|
||||
view?.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: page.content || "" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => updatePage(manager.selectedPage));
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
doc: manager.selectedPage?.content || "",
|
||||
parent: codemirrorParent,
|
||||
extensions: [basicSetup, json(), materialDark],
|
||||
});
|
||||
easyMde = new EasyMDE({
|
||||
element: easyMdeParent,
|
||||
spellChecker: false,
|
||||
initialValue: manager.selectedPage?.content || "",
|
||||
});
|
||||
|
||||
easyMde.codemirror.on("change", () => {
|
||||
if (manager.selectedPage?.content !== easyMde?.value()) {
|
||||
manager.selectedPage!.dirty = true;
|
||||
}
|
||||
|
||||
manager.selectedPage!.content = easyMde?.value() || "";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<div class="h-8 flex">
|
||||
{#each manager.pages as tab, index}
|
||||
{@const isActive = manager.openPageIndex === index}
|
||||
<button
|
||||
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
|
||||
? 'text-primary bg-neutral-900'
|
||||
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
|
||||
onclick={() => (manager.openPageIndex = index)}
|
||||
>
|
||||
<File class="h-4 w-4 mr-2" />
|
||||
{tab.pageTitle}
|
||||
<span
|
||||
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
manager.closePage(index);
|
||||
}}><X /></span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex-1 flex flex-col">
|
||||
{#if manager.selectedPage}
|
||||
<div class="flex items-center justify-end p-2">
|
||||
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if manager.selectedPage.path.startsWith("src/content/announcements/")}
|
||||
<div class="border-b flex-1" transition:slide>
|
||||
<FrontmatterEditor />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<div bind:this={codemirrorParent} class="hidden h-full"></div>
|
||||
<div bind:this={easyMdeWrapper} class="hidden h-full">
|
||||
<textarea bind:this={easyMdeParent}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
122
src/components/moderator/pages/pages/FrontmatterEditor.svelte
Normal file
122
src/components/moderator/pages/pages/FrontmatterEditor.svelte
Normal file
@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { X } from "lucide-svelte";
|
||||
import { manager } from "./page.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
</script>
|
||||
|
||||
<details class="group">
|
||||
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800">
|
||||
<span class="font-medium">Frontmatter</span>
|
||||
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="p-3 border-t bg-neutral-900">
|
||||
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]}
|
||||
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onchange={(e) => {
|
||||
const newKey = (e.target as HTMLInputElement).value;
|
||||
if (newKey !== key) {
|
||||
manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key];
|
||||
delete manager.selectedPage?.frontmatter[key];
|
||||
manager.selectedPage!.dirty = true;
|
||||
}
|
||||
}}
|
||||
class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900"
|
||||
placeholder="Key"
|
||||
/>
|
||||
<span>:</span>
|
||||
{#if Array.isArray(value)}
|
||||
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span>
|
||||
{:else if value instanceof Date || key === "created"}
|
||||
<input
|
||||
type="date"
|
||||
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""}
|
||||
onchange={(e) => {
|
||||
const dateValue = (e.target as HTMLInputElement).value;
|
||||
manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : "";
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={manager.selectedPage!.frontmatter[key]}
|
||||
onchange={() => (manager.selectedPage!.dirty = true)}
|
||||
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
|
||||
placeholder="Value"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => {
|
||||
delete manager.selectedPage!.frontmatter[key];
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{#if Array.isArray(value)}
|
||||
<div class="ml-4 space-y-1">
|
||||
{#each value as item, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground w-6">[{index}]</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={manager.selectedPage!.frontmatter[key][index]}
|
||||
onchange={() => (manager.selectedPage!.dirty = true)}
|
||||
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
|
||||
placeholder="Array item"
|
||||
/>
|
||||
<button
|
||||
onclick={() => {
|
||||
manager.selectedPage!.frontmatter[key].splice(index, 1);
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => {
|
||||
manager.selectedPage!.frontmatter[key].push("");
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="text-xs text-blue-500 hover:text-blue-700 ml-8"
|
||||
>
|
||||
+ Add item
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
manager.selectedPage!.frontmatter[`new_key_${Object.keys(manager.selectedPage!.frontmatter).length}`] = "";
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="text-sm text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
+ Add field
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
manager.selectedPage!.frontmatter[`new_array_${Object.keys(manager.selectedPage!.frontmatter).length}`] = [];
|
||||
manager.selectedPage!.dirty = true;
|
||||
}}
|
||||
class="text-sm text-green-500 hover:text-green-700"
|
||||
>
|
||||
+ Add array
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
155
src/components/moderator/pages/pages/Pages.svelte
Normal file
155
src/components/moderator/pages/pages/Pages.svelte
Normal file
@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
|
||||
import { Separator } from "@components/ui/separator";
|
||||
import { manager } from "./page.svelte";
|
||||
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
|
||||
import PagesList from "./PagesList.svelte";
|
||||
import EditorWithTabs from "./EditorWithTabs.svelte";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { cn } from "@components/utils";
|
||||
import { pageRepo } from "@components/repo/page";
|
||||
|
||||
let branchSelectOpen = $state(false);
|
||||
let imageSelectOpen = $state(false);
|
||||
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
</script>
|
||||
|
||||
<div class="flex-grow flex flex-col">
|
||||
<ResizablePaneGroup direction="horizontal" class="flex-grow">
|
||||
<ResizablePane defaultSize={20}>
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="flex p-2 gap-2">
|
||||
<Popover bind:open={branchSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
|
||||
{manager.branch}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Branches..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No Branches Found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{#each manager.branches as branch}
|
||||
<CommandItem
|
||||
value={branch}
|
||||
onSelect={() => {
|
||||
if (manager.anyUnsavedChanges()) {
|
||||
if (!confirm("You have unsaved changes. Are you sure you want to switch branches?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
manager.branch = branch;
|
||||
manager.pages = [];
|
||||
branchSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", branch !== manager.branch && "text-transparent")} />
|
||||
{branch}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
<Popover bind:open={imageSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<FileImage />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
|
||||
{#await manager.imagesLoad}
|
||||
<p>Loading images...</p>
|
||||
{:then images}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="p-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={async (e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const base64 = event.target?.result?.toString().split(",")[1];
|
||||
if (base64) {
|
||||
await $pageRepo.createImage(file.name, base64, manager.branch);
|
||||
manager.reloadImages();
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
<Button onclick={() => fileInput?.click()} class="w-full">
|
||||
<Plus class="mr-2 size-4" />
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 p-2">
|
||||
{#each images as image}
|
||||
<button
|
||||
onclick={() => {
|
||||
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
|
||||
|
||||
const path = [...Array(backs).fill(".."), image.path.replace("src/", "")].join("/");
|
||||
|
||||
navigator.clipboard.writeText(path);
|
||||
imageSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
size="icon"
|
||||
onclick={async () => {
|
||||
const branchName = prompt("Enter branch name:");
|
||||
if (branchName) {
|
||||
await $pageRepo.createBranch(branchName);
|
||||
manager.reloadBranches();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
{#await manager.pagesLoad}
|
||||
<p>Loading pages...</p>
|
||||
{:then pages}
|
||||
{#each Object.values(pages.dirs) as page}
|
||||
<PagesList {page} path={page.name + "/"} />
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</ResizablePane>
|
||||
<ResizableHandle />
|
||||
<ResizablePane defaultSize={80}>
|
||||
<EditorWithTabs />
|
||||
</ResizablePane>
|
||||
</ResizablePaneGroup>
|
||||
</div>
|
||||
116
src/components/moderator/pages/pages/PagesList.svelte
Normal file
116
src/components/moderator/pages/pages/PagesList.svelte
Normal file
@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
|
||||
import type { DirTree } from "./page.svelte";
|
||||
import PagesList from "./PagesList.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import Button from "@components/ui/button/button.svelte";
|
||||
import { manager } from "./page.svelte";
|
||||
|
||||
const { page, depth = 0, path }: { page: DirTree; depth?: number; path: string } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let newPage = $state(false);
|
||||
let newPageName = $state("");
|
||||
|
||||
let newPageInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
function startNewPageCreate(e: Event) {
|
||||
e.stopPropagation();
|
||||
newPage = true;
|
||||
newPageName = "";
|
||||
open = true;
|
||||
|
||||
setTimeout(() => {
|
||||
newPageInput?.focus();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
function createNewPage(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (newPageName.trim() === "") {
|
||||
alert("Page name cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPageName.match(/^[a-zA-Z0-9_\-\.]+$/)) {
|
||||
alert("Invalid page name. Only alphanumeric characters, underscores, dashes, and dots are allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPageName.endsWith(".json") && !newPageName.endsWith(".md") && !newPageName.endsWith(".mdx")) {
|
||||
newPageName += ".md";
|
||||
}
|
||||
|
||||
manager
|
||||
.createPage(path + newPageName, newPageName)
|
||||
.then(() => {
|
||||
newPage = false;
|
||||
newPageName = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("Error creating page: " + error.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
|
||||
<div class="flex flex-row items-center">
|
||||
{#if open}
|
||||
<ChevronDown class="w-6 h-6" />
|
||||
{:else}
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
{/if}
|
||||
<Folder class="mr-2 w-4 h-4" />
|
||||
{page.name}/
|
||||
</div>
|
||||
<div class="flex-row items-center hidden group-hover:flex">
|
||||
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
|
||||
<FilePlus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 200, axis: "y" }}>
|
||||
<div>
|
||||
{#if newPage}
|
||||
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
|
||||
{#if newPageName.endsWith(".json")}
|
||||
<FileJson class="mr-2 w-4 h-4" />
|
||||
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
|
||||
<FileText class="mr-2 w-4 h-4" />
|
||||
{:else}
|
||||
<File class="mr-2 w-4 h-4" />
|
||||
{/if}
|
||||
<form onsubmit={createNewPage}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPageName}
|
||||
bind:this={newPageInput}
|
||||
onblur={() => (newPage = false)}
|
||||
placeholder="New page name"
|
||||
class="flex-grow bg-transparent border-none outline-none text-white"
|
||||
/>
|
||||
</form>
|
||||
</button>
|
||||
{/if}
|
||||
{#each Object.values(page.dirs) as subPage (subPage.name)}
|
||||
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
|
||||
{/each}
|
||||
{#each Object.values(page.files) as file (file.id)}
|
||||
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
|
||||
{#if file.name.endsWith(".json")}
|
||||
<FileJson class="mr-2 w-4 h-4" />
|
||||
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
|
||||
<FileText class="mr-2 w-4 h-4" />
|
||||
{:else}
|
||||
<File class="mr-2 w-4 h-4" />
|
||||
{/if}
|
||||
{file.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
226
src/components/moderator/pages/pages/page.svelte.ts
Normal file
226
src/components/moderator/pages/pages/page.svelte.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { base64ToBytes } from "@components/admin/util";
|
||||
import { pageRepo } from "@components/repo/page";
|
||||
import type { ListPage, PageList } from "@components/types/page";
|
||||
import { get } from "svelte/store";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export class OpenEditPage {
|
||||
public content: string = "";
|
||||
public frontmatter: { [key: string]: string | string[] | Date } = $state({});
|
||||
public dirty: boolean = $state(false);
|
||||
|
||||
public readonly fileType: string;
|
||||
|
||||
public constructor(
|
||||
private manager: PageManager,
|
||||
public readonly pageId: number,
|
||||
public readonly pageTitle: string,
|
||||
public readonly sha: string,
|
||||
public readonly originalContent: string,
|
||||
public readonly path: string
|
||||
) {
|
||||
this.fileType = this.path.split(".").pop() || "md";
|
||||
|
||||
this.content = this.removeFrontmatter(originalContent);
|
||||
this.frontmatter = this.parseFrontmatter(originalContent);
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
let contentToSave = "";
|
||||
if (this.frontmatter) {
|
||||
contentToSave += "---\n";
|
||||
contentToSave += yaml.dump(this.frontmatter);
|
||||
contentToSave += "---\n\n";
|
||||
}
|
||||
contentToSave += this.content;
|
||||
|
||||
await get(pageRepo).updatePage(this.pageId, contentToSave, this.sha, prompt("Was hast du geändert?", `Updated ${this.pageTitle}`) ?? `Updated ${this.pageTitle}`, this.manager.branch);
|
||||
this.dirty = false;
|
||||
this.manager.reloadImages();
|
||||
}
|
||||
|
||||
public focus(): boolean {
|
||||
let index = this.manager.pages.indexOf(this);
|
||||
|
||||
if (index === this.manager.openPageIndex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.manager.openPageIndex = this.manager.pages.indexOf(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
|
||||
const lines = content.split("\n");
|
||||
let inFrontmatter = false;
|
||||
const frontmatterLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "---") {
|
||||
if (inFrontmatter) {
|
||||
break; // End of frontmatter
|
||||
}
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) {
|
||||
frontmatterLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatterLines.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml
|
||||
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
|
||||
} catch (error) {
|
||||
console.error("Failed to parse YAML frontmatter:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private removeFrontmatter(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
let inFrontmatter = false;
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "---") {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
continue;
|
||||
}
|
||||
if (!inFrontmatter) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n").trim();
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirTree {
|
||||
name: string;
|
||||
dirs: { [key: string]: DirTree };
|
||||
files: { [key: string]: ListPage };
|
||||
}
|
||||
|
||||
export class PageManager {
|
||||
public reloadImages() {
|
||||
this.updater = this.updater + 1;
|
||||
}
|
||||
public branch: string = $state("master");
|
||||
public pages: OpenEditPage[] = $state([]);
|
||||
public branches: string[] = $state([]);
|
||||
|
||||
constructor() {
|
||||
this.reloadBranches();
|
||||
}
|
||||
|
||||
public reloadBranches() {
|
||||
get(pageRepo)
|
||||
.getBranches()
|
||||
.then((branches) => {
|
||||
this.branches = branches;
|
||||
});
|
||||
}
|
||||
|
||||
private updater = $state(0);
|
||||
|
||||
public openPageIndex: number = $state(-1);
|
||||
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater)));
|
||||
public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
|
||||
|
||||
private _t<T>(n: number): (v: T) => T {
|
||||
return (v: T) => v;
|
||||
}
|
||||
|
||||
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
|
||||
|
||||
private convertToTree(pages: PageList): DirTree {
|
||||
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
|
||||
|
||||
pages.forEach((page) => {
|
||||
const pathParts = page.path.split("/").filter((part) => part !== "");
|
||||
let current = tree;
|
||||
|
||||
// Navigate/create directory structure
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const dir = pathParts[i];
|
||||
if (!current.dirs[dir]) {
|
||||
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
|
||||
}
|
||||
current = current.dirs[dir];
|
||||
}
|
||||
|
||||
// Add file to the final directory
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
current.files[fileName] = page;
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
public async openPage(pageId: number) {
|
||||
const existingPage = this.existingPage(pageId);
|
||||
if (existingPage) {
|
||||
existingPage.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let r = await get(pageRepo).getPage(pageId, this.branch);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
|
||||
this.pages.push(newPage);
|
||||
newPage.focus();
|
||||
}
|
||||
|
||||
public existingPage(pageId: number): OpenEditPage | undefined {
|
||||
return this.pages.find((page) => page.pageId === pageId);
|
||||
}
|
||||
|
||||
public closePage(index: number) {
|
||||
if (index < 0 || index >= this.pages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = this.pages[index];
|
||||
if (page.dirty) {
|
||||
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.pages.splice(index, 1);
|
||||
if (this.openPageIndex >= index) {
|
||||
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
|
||||
}
|
||||
|
||||
if (this.openPageIndex < 0 && this.pages.length > 0) {
|
||||
this.openPageIndex = 0;
|
||||
}
|
||||
|
||||
if (this.pages.length === 0) {
|
||||
this.openPageIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public async createPage(path: string, newPageName: string): Promise<void> {
|
||||
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
|
||||
this.branch = this.branch;
|
||||
}
|
||||
|
||||
public anyUnsavedChanges() {
|
||||
return this.pages.some((page) => page.dirty);
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = $state(new PageManager());
|
||||
@ -0,0 +1,56 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {permissions, players} from "@stores/stores.ts";
|
||||
import {Select, SelectContent, SelectItem} from "@components/ui/select";
|
||||
import {SelectTrigger} from "@components/ui/select/index.js";
|
||||
import {permsRepo} from "@repo/perms.ts";
|
||||
|
||||
const {
|
||||
perms, uuid
|
||||
}: { perms: string[], uuid: string } = $props();
|
||||
|
||||
let value = $state(perms);
|
||||
let prevValue = $state(perms);
|
||||
|
||||
function onChange(change: string[]) {
|
||||
$permissions.perms.forEach(perm => {
|
||||
if (prevValue.includes(perm) && !change.includes(perm)) {
|
||||
$permsRepo.removePerm(uuid, perm)
|
||||
} else if (!prevValue.includes(perm) && change.includes(perm)) {
|
||||
$permsRepo.addPerm(uuid, perm)
|
||||
}
|
||||
});
|
||||
|
||||
prevValue = change;
|
||||
value = change;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select type="multiple" bind:value onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
{value.length} Permissions
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each $permissions.perms as permission (permission)}
|
||||
<SelectItem value={permission}>{permission}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
32
src/components/moderator/pages/players/Players.svelte
Normal file
32
src/components/moderator/pages/players/Players.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script>
|
||||
import Table from "@components/moderator/pages/players/Table.svelte";
|
||||
|
||||
import {dataRepo} from "@repo/data";
|
||||
|
||||
let playersFuture = $state($dataRepo.getPlayers())
|
||||
</script>
|
||||
|
||||
{#await playersFuture}
|
||||
<p>Loading...</p>
|
||||
{:then players}
|
||||
<Table data={players} />
|
||||
{/await}
|
||||
47
src/components/moderator/pages/players/PrefixDropdown.svelte
Normal file
47
src/components/moderator/pages/players/PrefixDropdown.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
|
||||
import {permissions} from "@stores/stores.ts";
|
||||
import {permsRepo} from "@repo/perms.ts";
|
||||
|
||||
const {
|
||||
prefix, uuid
|
||||
}: { prefix: string, uuid: string } = $props();
|
||||
|
||||
let value = $state(prefix);
|
||||
|
||||
function onChange(change: string) {
|
||||
$permsRepo.setPrefix(uuid, change);
|
||||
|
||||
value = $permissions.prefixes[change].chatPrefix;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select type="single" bind:value onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
{value === "" ? "None" : value}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each Object.entries($permissions.prefixes) as prefix (prefix[1].name)}
|
||||
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
174
src/components/moderator/pages/players/Table.svelte
Normal file
174
src/components/moderator/pages/players/Table.svelte
Normal file
@ -0,0 +1,174 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
getCoreRowModel, getFilteredRowModel,
|
||||
getPaginationRowModel, getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/table-core";
|
||||
import {
|
||||
createSvelteTable,
|
||||
FlexRender,
|
||||
} from "@components/ui/data-table/index";
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
|
||||
import {Button} from "@components/ui/button";
|
||||
import {Input} from "@components/ui/input";
|
||||
import {Select} from "@components/ui/select";
|
||||
import {SelectContent, SelectItem, SelectTrigger} from "@components/ui/select/index.js";
|
||||
import type {Player} from "@type/data";
|
||||
import { columns } from "./columns";
|
||||
|
||||
let { data }: { data: Player[] } = $props();
|
||||
|
||||
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 25 });
|
||||
let sorting = $state<SortingState>([]);
|
||||
let columnFilters = $state<ColumnFiltersState>([]);
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
state: {
|
||||
get pagination() {
|
||||
return pagination;
|
||||
},
|
||||
get sorting() {
|
||||
return sorting;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters;
|
||||
},
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
pagination = updater(pagination);
|
||||
} else {
|
||||
pagination = updater;
|
||||
}
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
sorting = updater(sorting);
|
||||
} else {
|
||||
sorting = updater;
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
columnFilters = updater(columnFilters);
|
||||
} else {
|
||||
columnFilters = updater;
|
||||
}
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border m-4">
|
||||
<div class="flex items-center p-4 border-b">
|
||||
<Input
|
||||
placeholder="Filter Players..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onchange={(e) => {
|
||||
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
oninput={(e) => {
|
||||
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<div class="flex items-center px-4">
|
||||
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => pagination = { pageSize: +e, pageIndex: 0 }}>
|
||||
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<TableRow>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<TableHead>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</TableHead>
|
||||
{/each}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<TableCell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</TableCell>
|
||||
{/each}
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={columns.length} class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="flex items-center justify-end space-x-2 p-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>{pagination.pageIndex + 1}/{table.getPageCount()}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
60
src/components/moderator/pages/players/columns.ts
Normal file
60
src/components/moderator/pages/players/columns.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ColumnDef} from "@tanstack/table-core";
|
||||
import type {Player} from "@type/data.ts";
|
||||
import { renderComponent } from "@components/ui/data-table";
|
||||
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
|
||||
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
|
||||
|
||||
export const columns: ColumnDef<Player[]> = [
|
||||
{
|
||||
accessorKey: "uuid",
|
||||
header: "UUID",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "prefix",
|
||||
header: "Prefix",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(
|
||||
PrefixDropdown, {
|
||||
prefix: row.getValue("prefix"),
|
||||
uuid: row.getValue("uuid"),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "perms",
|
||||
header: "Permissions",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(
|
||||
PermissionsDropdown,
|
||||
{
|
||||
perms: row.getValue("perms"),
|
||||
uuid: row.getValue("uuid"),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -22,7 +22,7 @@
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import {onDestroy, onMount} from "svelte";
|
||||
import { CollectionEntry } from "astro:content";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
interface Props {
|
||||
pub: CollectionEntry<"publics">;
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2023 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {derived} from "svelte/store";
|
||||
|
||||
export class AuthRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
|
||||
public async login(username: string, password: string): Promise<string> {
|
||||
return await fetchWithToken(this.token, "/auth/login", {
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
method: "POST",
|
||||
}).then(value => value.json()).then(value => value.token);
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await fetchWithToken(this.token, "/auth/tokens/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const authRepo = derived(tokenStore, ($token) => new AuthRepo($token));
|
||||
184
src/components/repo/authv2.ts
Normal file
184
src/components/repo/authv2.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readable, writable} from "svelte/store";
|
||||
import dayjs, {type Dayjs} from "dayjs";
|
||||
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
|
||||
|
||||
export class AuthV2Repo {
|
||||
private accessToken: string | undefined;
|
||||
private accessTokenExpires: Dayjs | undefined;
|
||||
private refreshToken: string | undefined;
|
||||
private refreshTokenExpires: Dayjs | undefined;
|
||||
|
||||
constructor() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accessToken = localStorage.getItem("sw-access-token") ?? undefined;
|
||||
if (this.accessToken) {
|
||||
this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? "");
|
||||
}
|
||||
|
||||
this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined;
|
||||
if (this.refreshToken) {
|
||||
loggedIn.set(true);
|
||||
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
async login(name: string, password: string) {
|
||||
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
|
||||
throw new Error("Already logged in");
|
||||
}
|
||||
|
||||
try {
|
||||
const login = await this.request("/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password,
|
||||
keepLoggedIn: true,
|
||||
}),
|
||||
}).then(value => value.json()).then(value => AuthTokenSchema.parse(value));
|
||||
|
||||
this.setLoginState(login);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.request("/auth", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
this.resetAccessToken();
|
||||
this.resetRefreshToken();
|
||||
}
|
||||
|
||||
private setLoginState(tokens: AuthToken) {
|
||||
this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires));
|
||||
this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires));
|
||||
loggedIn.set(true);
|
||||
}
|
||||
|
||||
private setAccessToken(token: string, expires: Dayjs) {
|
||||
this.accessToken = token;
|
||||
this.accessTokenExpires = expires;
|
||||
localStorage.setItem("sw-access-token", token);
|
||||
localStorage.setItem("sw-access-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetAccessToken() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.accessToken = undefined;
|
||||
this.accessTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-access-token");
|
||||
localStorage.removeItem("sw-access-token-expires");
|
||||
}
|
||||
|
||||
private setRefreshToken(token: string, expires: Dayjs) {
|
||||
this.refreshToken = token;
|
||||
this.refreshTokenExpires = expires;
|
||||
localStorage.setItem("sw-refresh-token", token);
|
||||
localStorage.setItem("sw-refresh-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetRefreshToken() {
|
||||
if (this.refreshToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.refreshToken = undefined;
|
||||
this.refreshTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-refresh-token");
|
||||
localStorage.removeItem("sw-refresh-token-expires");
|
||||
|
||||
loggedIn.set(false);
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.requestWithToken(this.refreshToken!, "/auth", {
|
||||
method: "PUT",
|
||||
}).then(value => {
|
||||
if (value.status === 401) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.json();
|
||||
}).then(value => AuthTokenSchema.parse(value));
|
||||
|
||||
this.setLoginState(response);
|
||||
}
|
||||
|
||||
async request(url: string, params: RequestInit = {}, retryCount: number = 0) {
|
||||
if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
return this.requestWithToken(this.accessToken ?? "", url, params, retryCount);
|
||||
}
|
||||
|
||||
private async requestWithToken(token: string, url: string, params: RequestInit = {}, retryCount: number = 0): Promise<Response> {
|
||||
if (retryCount >= 3) {
|
||||
throw new Error("Too many retries");
|
||||
}
|
||||
|
||||
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
|
||||
headers: {
|
||||
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
|
||||
"Content-Type": "application/json", ...params.headers,
|
||||
},
|
||||
})
|
||||
.then(async value => {
|
||||
if (value.status === 401 && url !== "/auth") {
|
||||
try {
|
||||
await this.refresh();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) { /* empty */ }
|
||||
|
||||
return this.request(url, params, retryCount + 1);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const loggedIn = writable(false);
|
||||
|
||||
export const authV2Repo = readable(new AuthV2Repo());
|
||||
@ -17,22 +17,38 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, Server} from "@type/data.ts";
|
||||
import {PlayerSchema, ServerSchema} from "@type/data.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {derived} from "svelte/store";
|
||||
import type { Player, Server } from "@type/data.ts";
|
||||
import { PlayerSchema, ServerSchema } from "@type/data.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { TeamSchema, type Team } from "@components/types/team.ts";
|
||||
|
||||
export class DataRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async getServer(): Promise<Server> {
|
||||
return await fetchWithToken(this.token, "/data/server").then(value => value.json()).then(ServerSchema.parse);
|
||||
return await fetchWithToken(this.token, "/data/server")
|
||||
.then((value) => value.json())
|
||||
.then(ServerSchema.parse);
|
||||
}
|
||||
|
||||
public async getMe(): Promise<Player> {
|
||||
return await fetchWithToken(this.token, "/data/me").then(value => value.json()).then(PlayerSchema.parse);
|
||||
return await fetchWithToken(this.token, "/data/me")
|
||||
.then((value) => value.json())
|
||||
.then(PlayerSchema.parse);
|
||||
}
|
||||
|
||||
public async getPlayers(): Promise<Player[]> {
|
||||
return await fetchWithToken(get(tokenStore), "/data/admin/users")
|
||||
.then((value) => value.json())
|
||||
.then(PlayerSchema.array().parse);
|
||||
}
|
||||
|
||||
public async getTeams(): Promise<Team[]> {
|
||||
return await fetchWithToken(get(tokenStore), "/data/admin/teams")
|
||||
.then((value) => value.json())
|
||||
.then(TeamSchema.array().parse);
|
||||
}
|
||||
}
|
||||
|
||||
export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token));
|
||||
export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token));
|
||||
|
||||
@ -17,12 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
|
||||
import {z} from "zod";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import {
|
||||
ExtendedEventSchema,
|
||||
ShortEventSchema,
|
||||
SWEventSchema,
|
||||
EventFightSchema,
|
||||
ResponseGroupsSchema,
|
||||
ResponseRelationSchema,
|
||||
ResponseTeamSchema,
|
||||
CreateEventGroupSchema,
|
||||
UpdateEventGroupSchema,
|
||||
CreateEventRelationSchema,
|
||||
UpdateEventRelationSchema,
|
||||
} from "@type/event.js";
|
||||
import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEventRelation } from "@type/event.js";
|
||||
import { z } from "zod";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { derived } from "svelte/store";
|
||||
import { ResponseUserSchema } from "@components/types/data";
|
||||
|
||||
export interface CreateEvent {
|
||||
name: string;
|
||||
@ -31,30 +45,36 @@ export interface CreateEvent {
|
||||
}
|
||||
|
||||
export interface UpdateEvent {
|
||||
name?: string;
|
||||
start?: Dayjs;
|
||||
end?: Dayjs;
|
||||
deadline?: Dayjs;
|
||||
maxTeamMembers?: number;
|
||||
name?: string | null;
|
||||
start?: Dayjs | number | null;
|
||||
end?: Dayjs | number | null;
|
||||
deadline?: Dayjs | number | null;
|
||||
maxTeamMembers?: number | null;
|
||||
schemType?: string | null;
|
||||
publicSchemsOnly?: boolean;
|
||||
addReferee?: string[];
|
||||
removeReferee?: string[];
|
||||
publicSchemsOnly?: boolean | null;
|
||||
addReferee?: string[] | null;
|
||||
removeReferee?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ResponseUser {
|
||||
name: string;
|
||||
uuid: string;
|
||||
prefix: string;
|
||||
perms: string[];
|
||||
}
|
||||
|
||||
export class EventRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listEvents(): Promise<ShortEvent[]> {
|
||||
return await fetchWithToken(this.token, "/events")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(ShortEventSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ShortEventSchema).parse(value));
|
||||
}
|
||||
|
||||
public async getEvent(id: string): Promise<ExtendedEvent> {
|
||||
return await fetchWithToken(this.token, `/events/${id}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(ExtendedEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -66,7 +86,8 @@ export class EventRepo {
|
||||
start: +event.start,
|
||||
end: +event.end,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -87,7 +108,8 @@ export class EventRepo {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -98,6 +120,154 @@ export class EventRepo {
|
||||
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Fights
|
||||
public async listFights(eventId: string): Promise<EventFight[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(EventFightSchema).parse(value));
|
||||
}
|
||||
public async createFight(eventId: string, fight: any): Promise<EventFight> {
|
||||
delete fight.ergebnis;
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fight),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Groups
|
||||
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseGroupsSchema).parse(value));
|
||||
}
|
||||
public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
|
||||
CreateEventGroupSchema.parse(group);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(ResponseGroupsSchema.parse);
|
||||
}
|
||||
public async getGroup(eventId: string, groupId: string): Promise<ResponseGroups> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`)
|
||||
.then((value) => value.json())
|
||||
.then(ResponseGroupsSchema.parse);
|
||||
}
|
||||
public async updateGroup(eventId: string, groupId: string, group: UpdateEventGroup): Promise<ResponseGroups> {
|
||||
UpdateEventGroupSchema.parse(group);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(group),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(ResponseGroupsSchema.parse);
|
||||
}
|
||||
public async deleteGroup(eventId: string, groupId: string): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Relations
|
||||
public async listRelations(eventId: number): Promise<ResponseRelation[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseRelationSchema).parse(value));
|
||||
}
|
||||
public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
|
||||
CreateEventRelationSchema.parse(relation);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(relation),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(ResponseRelationSchema.parse);
|
||||
}
|
||||
public async getRelation(eventId: string, relationId: string): Promise<ResponseRelation> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`)
|
||||
.then((value) => value.json())
|
||||
.then(ResponseRelationSchema.parse);
|
||||
}
|
||||
public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
|
||||
UpdateEventRelationSchema.parse(relation);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(relation),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(ResponseRelationSchema.parse);
|
||||
}
|
||||
public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Teams
|
||||
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseTeamSchema).parse(value));
|
||||
}
|
||||
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(teams),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(teams),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Referees
|
||||
public async listReferees(eventId: string): Promise<ResponseUser[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseUserSchema).parse(value));
|
||||
}
|
||||
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(refereeUuids),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.status === 204;
|
||||
}
|
||||
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(refereeUuids),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.status === 204;
|
||||
}
|
||||
}
|
||||
|
||||
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EventFight} from "@type/event.js";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {z} from "zod";
|
||||
import {EventFightSchema} from "@type/event.js";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { EventFight } from "@type/event.js";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import { z } from "zod";
|
||||
import { EventFightSchema } from "@type/event.js";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export interface CreateFight {
|
||||
spielmodus: string;
|
||||
@ -31,7 +31,7 @@ export interface CreateFight {
|
||||
redTeam: number;
|
||||
start: Dayjs;
|
||||
spectatePort: number | null;
|
||||
group: string | null;
|
||||
group: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateFight {
|
||||
@ -39,26 +39,24 @@ export interface UpdateFight {
|
||||
map: string | null;
|
||||
blueTeam: number | null;
|
||||
redTeam: number | null;
|
||||
start: Dayjs | null;
|
||||
start: number | null;
|
||||
spectatePort: number | null;
|
||||
group: string | null;
|
||||
group: number | null;
|
||||
}
|
||||
|
||||
export class FightRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listFights(eventId: number): Promise<EventFight[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(EventFightSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(EventFightSchema).parse(value));
|
||||
}
|
||||
|
||||
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, "/fights", {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: eventId,
|
||||
spielmodus: fight.spielmodus,
|
||||
map: fight.map,
|
||||
blueTeam: fight.blueTeam,
|
||||
@ -67,28 +65,25 @@ export class FightRepo {
|
||||
spectatePort: fight.spectatePort,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
spielmodus: fight.spielmodus,
|
||||
map: fight.map,
|
||||
blueTeam: fight.blueTeam,
|
||||
redTeam: fight.redTeam,
|
||||
...fight,
|
||||
start: fight.start?.valueOf(),
|
||||
spectatePort: fight.spectatePort ?? 0,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async deleteFight(fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async deleteFight(eventId: number, fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
@ -17,27 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Page, PageList} from "@type/page.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {PageListSchema, PageSchema} from "@type/page.ts";
|
||||
import {bytesToBase64} from "../admin/util.ts";
|
||||
import {z} from "zod";
|
||||
import {derived} from "svelte/store";
|
||||
import type { Page, PageList } from "@type/page.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { PageListSchema, PageSchema } from "@type/page.ts";
|
||||
import { bytesToBase64 } from "../admin/util.ts";
|
||||
import { z } from "zod";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export class PageRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listPages(branch: string = "master"): Promise<PageList> {
|
||||
return await fetchWithToken(this.token, `/page?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(PageListSchema.parse)
|
||||
.then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")})));
|
||||
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||
}
|
||||
|
||||
public async getPage(id: number, branch: string = "master"): Promise<Page> {
|
||||
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(PageSchema.parse);
|
||||
}
|
||||
|
||||
@ -46,42 +45,57 @@ export class PageRepo {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
content: bytesToBase64(new TextEncoder().encode(content)),
|
||||
sha, message,
|
||||
sha,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public async getBranches(): Promise<string[]> {
|
||||
return await fetchWithToken(this.token, "/page/branch")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(z.string()).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(z.string()).parse(value));
|
||||
}
|
||||
|
||||
public async createBranch(branch: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})});
|
||||
await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) });
|
||||
}
|
||||
|
||||
public async deleteBranch(branch: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})});
|
||||
await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) });
|
||||
}
|
||||
|
||||
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})});
|
||||
await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) });
|
||||
}
|
||||
|
||||
public async merge(branch: string, message: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch/merge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({branch, message}),
|
||||
body: JSON.stringify({ branch, message }),
|
||||
});
|
||||
}
|
||||
|
||||
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({message, sha}),
|
||||
body: JSON.stringify({ message, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
public async listImages(branch: string = "master"): Promise<PageList> {
|
||||
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
|
||||
.then((value) => value.json())
|
||||
.then(PageListSchema.parse)
|
||||
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||
}
|
||||
|
||||
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, data }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));
|
||||
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));
|
||||
|
||||
@ -17,31 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {writable} from "svelte/store";
|
||||
import {get, writable} from "svelte/store";
|
||||
import {authV2Repo} from "@repo/authv2.ts";
|
||||
|
||||
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) =>
|
||||
fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
|
||||
headers: {
|
||||
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
|
||||
"Content-Type": "application/json", ...params.headers,
|
||||
},
|
||||
})
|
||||
.then(value => {
|
||||
if (value.status === 401) {
|
||||
tokenStore.set("");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params);
|
||||
|
||||
export function getLocalStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return {
|
||||
getItem: () => "",
|
||||
setItem: () => {},
|
||||
};
|
||||
}
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
export const tokenStore = writable((getLocalStorage().getItem("sw-session") ?? ""));
|
||||
tokenStore.subscribe((value) => getLocalStorage().setItem("sw-session", value));
|
||||
export const tokenStore = writable("");
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {type Schematic, SchematicSchema} from "@type/schem.ts";
|
||||
import {derived} from "svelte/store";
|
||||
import {ResponseErrorSchema} from "@type/data.ts";
|
||||
|
||||
export class SchematicRepo {
|
||||
constructor(private token: string) {
|
||||
@ -40,7 +41,7 @@ export class SchematicRepo {
|
||||
name,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
}).then(value => value.json()).then(SchematicSchema.or(ResponseErrorSchema).parse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ export class StatsRepo {
|
||||
}
|
||||
|
||||
public async getUserStats(id: string): Promise<UserStats> {
|
||||
return await fetchWithToken(this.token, `/stats/user/${id}`).then(value => value.json()).then(UserStatsSchema.parse);
|
||||
return await fetchWithToken(this.token, `/stats/user`).then(value => value.json()).then(UserStatsSchema.parse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,20 +17,31 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readonly, writable} from "svelte/store";
|
||||
import { readonly, writable } from "svelte/store";
|
||||
|
||||
import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
|
||||
import type { Readable, Subscriber, Unsubscriber } from "svelte/store";
|
||||
|
||||
export interface Cached<T> extends Readable<T> {
|
||||
reload: () => void;
|
||||
future: Promise<T>;
|
||||
}
|
||||
|
||||
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
||||
const store = writable<T>(normal);
|
||||
const future = new Promise<T>((resolve) => {
|
||||
let f = false;
|
||||
store.subscribe((value) => {
|
||||
if (f) {
|
||||
resolve(value);
|
||||
} else {
|
||||
f = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let first = true;
|
||||
|
||||
const reload = () => {
|
||||
init().then(data => {
|
||||
init().then((data) => {
|
||||
store.set(data);
|
||||
});
|
||||
};
|
||||
@ -45,6 +56,7 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
||||
return store.subscribe(run, invalidate);
|
||||
},
|
||||
reload,
|
||||
future,
|
||||
};
|
||||
}
|
||||
|
||||
@ -58,7 +70,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
|
||||
let first = true;
|
||||
|
||||
const reload = () => {
|
||||
init(arg).then(data => {
|
||||
init(arg).then((data) => {
|
||||
store.set(data);
|
||||
});
|
||||
};
|
||||
|
||||
@ -17,33 +17,45 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, SchematicType} from "@type/data";
|
||||
import {PlayerSchema} from "@type/data.ts";
|
||||
import {cached, cachedFamily} from "./cached";
|
||||
import type {Team} from "@type/team.ts";
|
||||
import {TeamSchema} from "@type/team";
|
||||
import {derived, get, writable} from "svelte/store";
|
||||
import {z} from "zod";
|
||||
import {fetchWithToken, tokenStore} from "@repo/repo.ts";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import {dataRepo} from "@repo/data.ts";
|
||||
import type { Player, SchematicType } from "@type/data";
|
||||
import { PlayerSchema } from "@type/data.ts";
|
||||
import { cached, cachedFamily } from "./cached";
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { TeamSchema } from "@type/team";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { z } from "zod";
|
||||
import { fetchWithToken, tokenStore } from "@repo/repo.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
import { dataRepo } from "@repo/data.ts";
|
||||
import { permsRepo } from "@repo/perms.ts";
|
||||
|
||||
export const schemTypes = cached<SchematicType[]>([], () =>
|
||||
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
|
||||
.then(res => res.json()));
|
||||
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
|
||||
|
||||
export const players = cached<Player[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/users");
|
||||
return z.array(PlayerSchema).parse(await res.json());
|
||||
return get(dataRepo).getPlayers();
|
||||
});
|
||||
|
||||
export const teams = cached<Team[]>([], async () => {
|
||||
return get(dataRepo).getTeams();
|
||||
});
|
||||
|
||||
export const permissions = cached(
|
||||
{
|
||||
perms: [],
|
||||
prefixes: {},
|
||||
},
|
||||
async () => {
|
||||
return get(permsRepo).listPerms();
|
||||
}
|
||||
);
|
||||
|
||||
export const gamemodes = cached<string[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
|
||||
return z.array(z.string()).parse(await res.json());
|
||||
});
|
||||
|
||||
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
|
||||
if (get(gamemodes).every(value => value !== gamemode)) return [];
|
||||
if ((await gamemodes.future).every((value) => value !== gamemode)) return [];
|
||||
|
||||
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
|
||||
if (!res.ok) {
|
||||
@ -58,17 +70,12 @@ export const groups = cached<string[]>([], async () => {
|
||||
return z.array(z.string()).parse(await res.json());
|
||||
});
|
||||
|
||||
export const teams = cached<Team[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/team");
|
||||
return z.array(TeamSchema).parse(await res.json());
|
||||
});
|
||||
|
||||
export const branches = cached<string[]>([], async () => {
|
||||
const res = await get(pageRepo).getBranches();
|
||||
return z.array(z.string()).parse(res);
|
||||
});
|
||||
|
||||
export const server = derived(dataRepo, $dataRepo => $dataRepo.getServer());
|
||||
export const server = derived(dataRepo, ($dataRepo) => $dataRepo.getServer());
|
||||
|
||||
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte";
|
||||
import {stopPropagation} from "@components/util.ts";
|
||||
import {stopPropagation} from "@components/utils.ts";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@ -68,16 +68,18 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full max-w-md w-full rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
|
||||
<div onclick={stopPropagation(onclick)} aria-hidden="true">
|
||||
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full min-w-md w-fit rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
|
||||
<div onclick={stopPropagation(onclick)} aria-hidden="true" class="w-fit">
|
||||
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h1 class="text-4xl font-bold">{title}</h1>
|
||||
</div>
|
||||
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<div class="flex mx-4 my-2 p-6" onclick={() => dialog.close()} aria-hidden="true">
|
||||
{@render footer?.()}
|
||||
<div class="mx-4 my-2 p-6">
|
||||
<div class="ml-auto flex justify-end" onclick={() => dialog.close()} aria-hidden="true">
|
||||
{@render footer?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2023 SteamWar.de-Serverteam
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
@ -17,17 +17,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function window<T>(arr: T[], len: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += len) {
|
||||
result.push(arr.slice(i, i + len));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import {z} from "zod";
|
||||
|
||||
export function stopPropagation(a: any) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
a(e);
|
||||
};
|
||||
}
|
||||
export const TokenSchema = z.object({
|
||||
token: z.string(),
|
||||
expires: z.string(),
|
||||
});
|
||||
|
||||
export type Token = z.infer<typeof TokenSchema>;
|
||||
|
||||
export const AuthTokenSchema = z.object({
|
||||
accessToken: TokenSchema,
|
||||
refreshToken: TokenSchema,
|
||||
});
|
||||
|
||||
export type AuthToken = z.infer<typeof AuthTokenSchema>;
|
||||
@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const SchematicTypeSchema = z.object({
|
||||
name: z.string(),
|
||||
@ -50,3 +50,19 @@ export const ServerSchema = z.object({
|
||||
});
|
||||
|
||||
export type Server = z.infer<typeof ServerSchema>;
|
||||
|
||||
export const ResponseErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export type ResponseError = z.infer<typeof ResponseErrorSchema>;
|
||||
|
||||
export const ResponseUserSchema = z.object({
|
||||
name: z.string(),
|
||||
uuid: z.string(),
|
||||
prefix: z.string(),
|
||||
perms: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ResponseUser = z.infer<typeof ResponseUserSchema>;
|
||||
|
||||
@ -17,9 +17,58 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import {TeamSchema} from "./team.js";
|
||||
import {PlayerSchema} from "./data.js";
|
||||
import { z } from "zod";
|
||||
import { TeamSchema } from "./team.js";
|
||||
import { PlayerSchema, ResponseUserSchema } from "./data.js";
|
||||
|
||||
export const ResponseGroupsSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
pointsPerWin: z.number(),
|
||||
pointsPerLoss: z.number(),
|
||||
pointsPerDraw: z.number(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
|
||||
points: z.record(z.string(), z.number()).nullable(),
|
||||
});
|
||||
|
||||
export const EventFightSchema = z.object({
|
||||
id: z.number(),
|
||||
spielmodus: z.string(),
|
||||
map: z.string(),
|
||||
blueTeam: TeamSchema,
|
||||
redTeam: TeamSchema,
|
||||
start: z.number(),
|
||||
ergebnis: z.number(),
|
||||
spectatePort: z.number().nullable(),
|
||||
group: ResponseGroupsSchema.nullable(),
|
||||
hasFinished: z.boolean(),
|
||||
});
|
||||
|
||||
export type EventFight = z.infer<typeof EventFightSchema>;
|
||||
|
||||
export const EventFightEditSchema = EventFightSchema.omit({
|
||||
id: true,
|
||||
group: true,
|
||||
hasFinished: true,
|
||||
}).extend({
|
||||
group: z.number().nullable(),
|
||||
});
|
||||
|
||||
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
|
||||
|
||||
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
|
||||
|
||||
export const ResponseRelationSchema = z.object({
|
||||
id: z.number(),
|
||||
fight: z.number(),
|
||||
team: z.enum(["RED", "BLUE"]),
|
||||
type: z.enum(["FIGHT", "GROUP"]),
|
||||
fromFight: EventFightSchema.optional(),
|
||||
fromGroup: ResponseGroupsSchema.optional(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
|
||||
export type ResponseRelation = z.infer<typeof ResponseRelationSchema>;
|
||||
|
||||
export const ShortEventSchema = z.object({
|
||||
id: z.number(),
|
||||
@ -35,29 +84,69 @@ export const SWEventSchema = ShortEventSchema.extend({
|
||||
maxTeamMembers: z.number(),
|
||||
schemType: z.string().nullable(),
|
||||
publicSchemsOnly: z.boolean(),
|
||||
referees: z.array(PlayerSchema),
|
||||
});
|
||||
|
||||
export type SWEvent = z.infer<typeof SWEventSchema>;
|
||||
|
||||
export const EventFightSchema = z.object({
|
||||
id: z.number(),
|
||||
spielmodus: z.string(),
|
||||
map: z.string(),
|
||||
blueTeam: TeamSchema,
|
||||
redTeam: TeamSchema,
|
||||
start: z.number(),
|
||||
ergebnis: z.number(),
|
||||
spectatePort: z.number().nullable(),
|
||||
group: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type EventFight = z.infer<typeof EventFightSchema>;
|
||||
|
||||
export const ExtendedEventSchema = z.object({
|
||||
event: SWEventSchema,
|
||||
teams: z.array(TeamSchema),
|
||||
groups: z.array(ResponseGroupsSchema),
|
||||
fights: z.array(EventFightSchema),
|
||||
referees: z.array(ResponseUserSchema),
|
||||
relations: z.array(ResponseRelationSchema),
|
||||
});
|
||||
|
||||
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
|
||||
|
||||
export const ResponseTeamSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
kuerzel: z.string(),
|
||||
color: z.string(),
|
||||
});
|
||||
|
||||
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
|
||||
|
||||
export const CreateEventGroupSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
|
||||
});
|
||||
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
|
||||
|
||||
export const UpdateEventGroupSchema = z.object({
|
||||
name: z.string().nullable().optional(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
|
||||
pointsPerWin: z.number().nullable().optional(),
|
||||
pointsPerLoss: z.number().nullable().optional(),
|
||||
pointsPerDraw: z.number().nullable().optional(),
|
||||
});
|
||||
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
|
||||
|
||||
export const GroupEditSchema = ResponseGroupsSchema.omit({
|
||||
id: true,
|
||||
points: true,
|
||||
});
|
||||
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
|
||||
|
||||
export const CreateEventRelationSchema = z.object({
|
||||
fightId: z.number(),
|
||||
team: z.enum(["RED", "BLUE"]),
|
||||
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||
fromId: z.number(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
|
||||
|
||||
export const UpdateFromRelationSchema = z.object({
|
||||
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||
fromId: z.number(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
|
||||
|
||||
export const UpdateEventRelationSchema = z.object({
|
||||
team: z.enum(["RED", "BLUE"]).nullable().optional(),
|
||||
from: UpdateFromRelationSchema.nullable().optional(),
|
||||
});
|
||||
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const PrefixSchema = z.object({
|
||||
name: z.string().startsWith("PREFIX_"),
|
||||
colorCode: z.string().length(2).startsWith("§"),
|
||||
colorCode: z.string().startsWith("§"),
|
||||
chatPrefix: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const TeamSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
kuerzel: z.string().min(1).max(4),
|
||||
kuerzel: z.string().min(1).max(16),
|
||||
color: z.string().max(1),
|
||||
});
|
||||
|
||||
|
||||
25
src/components/ui/accordion/accordion-content.svelte
Normal file
25
src/components/ui/accordion/accordion-content.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { slide } from "svelte/transition";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AccordionPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = slide;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
duration: 200,
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
class={cn("overflow-hidden text-sm transition-all", className)}
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
{...$$restProps}
|
||||
>
|
||||
<div class="pb-4 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
14
src/components/ui/accordion/accordion-item.svelte
Normal file
14
src/components/ui/accordion/accordion-item.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AccordionPrimitive.ItemProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item {value} class={cn("border-b", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</AccordionPrimitive.Item>
|
||||
26
src/components/ui/accordion/accordion-trigger.svelte
Normal file
26
src/components/ui/accordion/accordion-trigger.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import ChevronDown from "lucide-svelte/icons/chevron-down";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AccordionPrimitive.TriggerProps;
|
||||
type $$Events = AccordionPrimitive.TriggerEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let level: AccordionPrimitive.HeaderProps["level"] = 3;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
class={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
<ChevronDown class="h-4 w-4 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
17
src/components/ui/accordion/index.ts
Normal file
17
src/components/ui/accordion/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import Content from "./accordion-content.svelte";
|
||||
import Item from "./accordion-item.svelte";
|
||||
import Trigger from "./accordion-trigger.svelte";
|
||||
const Root = AccordionPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
};
|
||||
13
src/components/ui/alert-dialog/alert-dialog-action.svelte
Normal file
13
src/components/ui/alert-dialog/alert-dialog-action.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />
|
||||
17
src/components/ui/alert-dialog/alert-dialog-cancel.svelte
Normal file
17
src/components/ui/alert-dialog/alert-dialog-cancel.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
26
src/components/ui/alert-dialog/alert-dialog-content.svelte
Normal file
26
src/components/ui/alert-dialog/alert-dialog-content.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from "bits-ui";
|
||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: AlertDialogPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/components/ui/alert-dialog/alert-dialog-footer.svelte
Normal file
20
src/components/ui/alert-dialog/alert-dialog-footer.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
src/components/ui/alert-dialog/alert-dialog-header.svelte
Normal file
20
src/components/ui/alert-dialog/alert-dialog-header.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
src/components/ui/alert-dialog/alert-dialog-overlay.svelte
Normal file
19
src/components/ui/alert-dialog/alert-dialog-overlay.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
type $$Props = AlertDialogPrimitive.PortalProps;
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...$$restProps}>
|
||||
<slot />
|
||||
</AlertDialogPrimitive.Portal>
|
||||
18
src/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
18
src/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
class={cn("text-lg font-semibold", className)}
|
||||
{level}
|
||||
{...restProps}
|
||||
/>
|
||||
39
src/components/ui/alert-dialog/index.ts
Normal file
39
src/components/ui/alert-dialog/index.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import Title from "./alert-dialog-title.svelte";
|
||||
import Action from "./alert-dialog-action.svelte";
|
||||
import Cancel from "./alert-dialog-cancel.svelte";
|
||||
import Footer from "./alert-dialog-footer.svelte";
|
||||
import Header from "./alert-dialog-header.svelte";
|
||||
import Overlay from "./alert-dialog-overlay.svelte";
|
||||
import Content from "./alert-dialog-content.svelte";
|
||||
import Description from "./alert-dialog-description.svelte";
|
||||
|
||||
const Root = AlertDialogPrimitive.Root;
|
||||
const Trigger = AlertDialogPrimitive.Trigger;
|
||||
const Portal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user