Compare commits
284 Commits
add-tutori
...
0d810f9a7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ pnpm-debug.log*
|
|||||||
/src/env.d.ts
|
/src/env.d.ts
|
||||||
/src/pages/en/
|
/src/pages/en/
|
||||||
/.idea
|
/.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 svelte from "@astrojs/svelte";
|
||||||
import tailwind from "@astrojs/tailwind";
|
import tailwind from "@astrojs/tailwind";
|
||||||
import configureI18n from "./astro-i18n.adapter";
|
import configureI18n from "./astro-i18n.adapter";
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
|
||||||
import robotsTxt from "astro-robots-txt";
|
import robotsTxt from "astro-robots-txt";
|
||||||
import {resolve} from "node:url";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import mdx from "@astrojs/mdx";
|
import mdx from "@astrojs/mdx";
|
||||||
import pagefind from "astro-pagefind";
|
|
||||||
|
import starlight from "@astrojs/starlight";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@ -20,15 +20,40 @@ export default defineConfig({
|
|||||||
site: "https://steamwar.de",
|
site: "https://steamwar.de",
|
||||||
integrations: [
|
integrations: [
|
||||||
svelte(),
|
svelte(),
|
||||||
tailwind({
|
starlight({
|
||||||
configFile: "./tailwind.config.cjs",
|
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(),
|
configureI18n(),
|
||||||
sitemap({
|
sitemap({
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: "en", locales: {
|
defaultLocale: "en",
|
||||||
en: "en-US", de: "de-DE",
|
locales: {
|
||||||
|
en: "en-US",
|
||||||
|
de: "de-DE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -52,7 +77,7 @@ export default defineConfig({
|
|||||||
{ userAgent: "omgili", disallow: "/" },
|
{ userAgent: "omgili", disallow: "/" },
|
||||||
{ userAgent: "OmigliBot", disallow: "/" },
|
{ userAgent: "OmigliBot", disallow: "/" },
|
||||||
{ userAgent: "PerplexityBot", disallow: "/" },
|
{ userAgent: "PerplexityBot", disallow: "/" },
|
||||||
{ userAgent: "Timpibot", disallow: "/" }
|
{ userAgent: "Timpibot", disallow: "/" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
mdx(),
|
mdx(),
|
||||||
@ -69,6 +94,7 @@ export default defineConfig({
|
|||||||
"@layouts": path.resolve("./src/layouts"),
|
"@layouts": path.resolve("./src/layouts"),
|
||||||
"@repo": path.resolve("./src/components/repo"),
|
"@repo": path.resolve("./src/components/repo"),
|
||||||
"@stores": path.resolve("./src/components/stores"),
|
"@stores": path.resolve("./src/components/stores"),
|
||||||
|
$lib: path.resolve("./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
84
package.json
84
package.json
@ -14,56 +14,82 @@
|
|||||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||||
"clean:dist": "rm -rf dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:node_modules": "rm -rf node_modules",
|
"clean:node_modules": "rm -rf node_modules",
|
||||||
"ci": "pnpm run clean:dist && pnpm install && pnpm run i18n:sync && pnpm run build"
|
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/svelte": "^6.0.2",
|
"@astrojs/svelte": "^7.1.0",
|
||||||
"@astrojs/tailwind": "^5.1.2",
|
"@astrojs/tailwind": "^5.1.5",
|
||||||
"@astropub/icons": "^0.2.0",
|
"@astropub/icons": "^0.2.0",
|
||||||
|
"@internationalized/date": "^3.8.1",
|
||||||
|
"@lucide/svelte": "^0.488.0",
|
||||||
"@types/color": "^4.2.0",
|
"@types/color": "^4.2.0",
|
||||||
"@types/node": "^22.9.3",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^22.15.23",
|
||||||
"@types/three": "^0.170.0",
|
"@types/three": "^0.170.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||||
"@typescript-eslint/parser": "^8.15.0",
|
"@typescript-eslint/parser": "^8.33.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"cssnano": "^7.0.6",
|
"bits-ui": "1.3.4",
|
||||||
"esbuild": "^0.24.0",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.15.0",
|
"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-astro": "^1.3.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-svelte": "^2.46.0",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
|
"formsnap": "1.0.1",
|
||||||
|
"lucide-svelte": "^0.476.0",
|
||||||
|
"mode-watcher": "^0.5.1",
|
||||||
|
"paneforge": "^0.0.6",
|
||||||
"postcss-nesting": "^13.0.1",
|
"postcss-nesting": "^13.0.1",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.89.0",
|
||||||
"svelte": "^5.16.0",
|
"svelte": "^5.33.4",
|
||||||
"tailwind-merge": "^2.5.5",
|
"svelte-sonner": "^0.3.28",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwind-variants": "^0.3.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"three": "^0.170.0",
|
"three": "^0.170.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.8.3",
|
||||||
|
"vaul-svelte": "^0.3.2",
|
||||||
|
"zod": "^3.25.31"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^3.1.9",
|
"@astrojs/mdx": "^4.3.0",
|
||||||
"@astrojs/sitemap": "^3.2.1",
|
"@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/lang-json": "^6.0.1",
|
||||||
"@ddietr/codemirror-themes": "^1.4.4",
|
"@codemirror/view": "^6.36.8",
|
||||||
"astro": "^4.16.14",
|
"@ddietr/codemirror-themes": "^1.5.1",
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
|
"astro": "5.7.14",
|
||||||
"astro-i18n": "^2.2.4",
|
"astro-i18n": "^2.2.4",
|
||||||
"astro-pagefind": "^1.6.0",
|
|
||||||
"astro-robots-txt": "^1.0.0",
|
"astro-robots-txt": "^1.0.0",
|
||||||
"astro-seo": "^0.8.4",
|
"astro-seo": "^0.8.4",
|
||||||
"chart.js": "^4.4.6",
|
"chart.js": "^4.4.9",
|
||||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||||
"chartjs-adapter-moment": "^1.0.1",
|
"chartjs-adapter-moment": "^1.0.1",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"easymde": "^2.18.0",
|
"easymde": "^2.20.0",
|
||||||
"flowbite": "^2.5.2",
|
"flowbite": "^2.5.2",
|
||||||
"flowbite-svelte": "^0.47.3",
|
"flowbite-svelte": "^0.47.4",
|
||||||
"flowbite-svelte-icons": "^2.0.2",
|
"flowbite-svelte-icons": "^2.2.0",
|
||||||
"qs": "^6.13.1",
|
"js-yaml": "^4.1.0",
|
||||||
|
"qs": "^6.14.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"svelte-awesome": "^3.3.5",
|
"svelte-awesome": "^3.3.5",
|
||||||
"svelte-codemirror-editor": "^1.4.1",
|
"svelte-spa-router": "^4.0.1"
|
||||||
"svelte-spa-router": "^4.0.1",
|
},
|
||||||
"zod": "^3.23.8"
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"esbuild",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8818
pnpm-lock.yaml
generated
8818
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">
|
<script lang="ts">
|
||||||
import {twMerge} from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import {onMount} from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let cardElement: HTMLDivElement = $state();
|
let cardElement: HTMLDivElement = $state();
|
||||||
|
|
||||||
function rotateElement(event: MouseEvent) {
|
function rotateElement(event: MouseEvent) {
|
||||||
if(!hoverEffect) return;
|
if (!hoverEffect) return;
|
||||||
|
|
||||||
const x = event.clientX;
|
const x = event.clientX;
|
||||||
const y = event.clientY;
|
const y = event.clientY;
|
||||||
@ -36,23 +36,23 @@
|
|||||||
const rotateX = (centerY - y) / 20;
|
const rotateX = (centerY - y) / 20;
|
||||||
const rotateY = -(centerX - x) / 20;
|
const rotateY = -(centerX - x) / 20;
|
||||||
|
|
||||||
cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
|
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`);
|
||||||
cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
|
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetElement() {
|
function resetElement() {
|
||||||
cardElement.style.setProperty('--rotate-x', "0");
|
cardElement.style.setProperty("--rotate-x", "0");
|
||||||
cardElement.style.setProperty('--rotate-y', "0");
|
cardElement.style.setProperty("--rotate-y", "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hoverEffect?: boolean;
|
hoverEffect?: boolean;
|
||||||
extraClasses?: string;
|
extraClasses?: string;
|
||||||
children?: import('svelte').Snippet;
|
children?: import("svelte").Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { hoverEffect = true, extraClasses = '', children }: Props = $props();
|
let { hoverEffect = true, extraClasses = "", children }: Props = $props();
|
||||||
let classes = $derived(twMerge("w-72 border-2 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 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>
|
</script>
|
||||||
|
|
||||||
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
|
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
|
||||||
@ -63,14 +63,14 @@
|
|||||||
div {
|
div {
|
||||||
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
||||||
|
|
||||||
transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
|
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6);
|
||||||
|
|
||||||
:global(h1) {
|
:global(h1) {
|
||||||
@apply text-xl font-bold mt-4;
|
@apply text-xl font-bold mt-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(svg) {
|
:global(svg) {
|
||||||
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl
|
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {t} from "astro-i18n";
|
import {t} from "astro-i18n";
|
||||||
import {statsRepo} from "@repo/stats.ts";
|
import {statsRepo} from "@repo/stats.ts";
|
||||||
|
import "@styles/table.css"
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -64,7 +65,3 @@
|
|||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
||||||
<style>
|
|
||||||
@import "../styles/table.css";
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FightStatsChart from "./FightStatsChart.svelte";
|
import FightStatsChart from "./FightStatsChart.svelte";
|
||||||
import {t} from "astro-i18n";
|
import { t } from "astro-i18n";
|
||||||
import {statsRepo} from "@repo/stats.ts";
|
import { statsRepo } from "@repo/stats.ts";
|
||||||
|
|
||||||
let request = getStats();
|
let request = getStats();
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,8 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: "time",
|
type: "time",
|
||||||
@ -105,5 +107,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<canvas bind:this={canvas}></canvas>
|
<canvas height="500" bind:this={canvas}></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -19,25 +19,27 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {window} from "./util.ts";
|
import { window } from "./utils.ts";
|
||||||
import {astroI18n, t} from "astro-i18n";
|
import { astroI18n, t } from "astro-i18n";
|
||||||
import type {EventFight, ExtendedEvent} from "@type/event";
|
import type { EventFight, ExtendedEvent } from "@type/event";
|
||||||
import "@styles/table.css";
|
import "@styles/table.css";
|
||||||
|
|
||||||
export let event: ExtendedEvent;
|
export let event: ExtendedEvent;
|
||||||
export let group: string;
|
export let group: number;
|
||||||
export let rows: number = 1;
|
export let rows: number = 1;
|
||||||
|
|
||||||
function getWinner(fight: EventFight) {
|
function getWinner(fight: EventFight) {
|
||||||
|
if (!fight.hasFinished) {
|
||||||
|
return t("announcements.table.notPlayed");
|
||||||
|
}
|
||||||
|
|
||||||
switch (fight.ergebnis) {
|
switch (fight.ergebnis) {
|
||||||
case 1:
|
case 1:
|
||||||
return fight.blueTeam.kuerzel;
|
return fight.blueTeam.kuerzel;
|
||||||
case 2:
|
case 2:
|
||||||
return fight.redTeam.kuerzel;
|
return fight.redTeam.kuerzel;
|
||||||
case 3:
|
|
||||||
return t("announcements.table.draw");
|
|
||||||
default:
|
default:
|
||||||
return t("announcements.table.notPlayed");
|
return t("announcements.table.draw");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -55,13 +57,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
{#each fights as fight (fight.id)}
|
{#each fights as fight (fight.id)}
|
||||||
<td>{Intl.DateTimeFormat(astroI18n.locale, {
|
<td
|
||||||
|
>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
}).format(new Date(fight.start))}</td>
|
}).format(new Date(fight.start))}</td
|
||||||
|
>
|
||||||
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
|
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
|
||||||
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
|
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
|
||||||
<td>{getWinner(fight)}</td>
|
<td>{getWinner(fight)}</td>
|
||||||
|
|||||||
@ -19,33 +19,29 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {window} from "./util.ts";
|
import { window } from "./utils.ts";
|
||||||
import {t} from "astro-i18n";
|
import { t } from "astro-i18n";
|
||||||
import type {ExtendedEvent} from "@type/event.ts";
|
import type { ExtendedEvent } from "@type/event.ts";
|
||||||
import "@styles/table.css"
|
import "@styles/table.css";
|
||||||
|
|
||||||
export let event: ExtendedEvent;
|
let {
|
||||||
export let group: string;
|
event,
|
||||||
export let rows: number = 1;
|
group,
|
||||||
|
rows = 1,
|
||||||
|
}: {
|
||||||
|
event: ExtendedEvent;
|
||||||
|
group: number;
|
||||||
|
rows?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
$: teamPoints = event.teams.map(team => {
|
let teamPoints = $derived(
|
||||||
const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
Object.entries(event.groups.find((g) => g.id === group)?.points ?? {})
|
||||||
const points = fights.reduce((acc, fight) => {
|
.map(([teamId, points]) => ({
|
||||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
team: event.teams.find((t) => t.id === Number(teamId))!!,
|
||||||
return acc + 3;
|
points: points,
|
||||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
}))
|
||||||
return acc + 3;
|
.sort((a, b) => b.points - a.points)
|
||||||
} else if (fight.ergebnis === 3) {
|
);
|
||||||
return acc + 1;
|
|
||||||
} else {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return {
|
|
||||||
team,
|
|
||||||
points,
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.points - a.points);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
||||||
|
|||||||
@ -31,8 +31,7 @@
|
|||||||
let error: string = $state("");
|
let error: string = $state("");
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
let {tokenStore} = await import("./repo/repo.ts");
|
let {authV2Repo} = await import("./repo/authv2.ts");
|
||||||
let {authRepo} = await import("./repo/auth.ts");
|
|
||||||
if (username === "" || pw === "") {
|
if (username === "" || pw === "") {
|
||||||
pw = "";
|
pw = "";
|
||||||
error = t("login.error");
|
error = t("login.error");
|
||||||
@ -40,15 +39,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let auth = await get(authRepo).login(username, pw);
|
let auth = await get(authV2Repo).login(username, pw);
|
||||||
if (auth == undefined) {
|
if (!auth) {
|
||||||
pw = "";
|
pw = "";
|
||||||
error = t("login.error");
|
error = t("login.error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenStore.set(auth);
|
await navigate(l("/dashboard"));
|
||||||
navigate(l("/dashboard"));
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
pw = "";
|
pw = "";
|
||||||
error = t("login.error");
|
error = t("login.error");
|
||||||
@ -75,9 +73,7 @@
|
|||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
input {
|
input {
|
||||||
@apply border-2 rounded-md p-2 shadow-2xl w-80
|
@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;
|
||||||
dark:bg-neutral-800
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
let { logo }: Props = $props();
|
let { logo }: Props = $props();
|
||||||
|
|
||||||
let navbar = $state<HTMLDivElement>();
|
let navbar = $state<HTMLElement>();
|
||||||
let searchOpen = $state(false);
|
let searchOpen = $state(false);
|
||||||
|
|
||||||
let accountBtn = $state<HTMLAnchorElement>();
|
let accountBtn = $state<HTMLAnchorElement>();
|
||||||
@ -60,23 +60,15 @@
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
data-pagefind-ignore
|
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"
|
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}
|
bind:this={navbar}
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex flex-row items-center justify-evenly md:justify-between match">
|
||||||
class="flex flex-row items-center justify-evenly md:justify-between match"
|
|
||||||
>
|
|
||||||
<a class="flex items-center" href={l("/")}>
|
<a class="flex items-center" href={l("/")}>
|
||||||
{@render logo?.()}
|
{@render logo?.()}
|
||||||
<span
|
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block">
|
||||||
class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block"
|
|
||||||
>
|
|
||||||
{t("navbar.title")}
|
{t("navbar.title")}
|
||||||
<span
|
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span>
|
||||||
class="before:scale-y-100"
|
|
||||||
style="display: none"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex justify-center flex-wrap">
|
<div class="flex justify-center flex-wrap">
|
||||||
@ -88,21 +80,10 @@
|
|||||||
<CaretDownOutline class="ml-2 mt-auto" />
|
<CaretDownOutline class="ml-2 mt-auto" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-gray" href={l("/announcements")}
|
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
|
||||||
>{t("navbar.links.home.announcements")}</a
|
<a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a>
|
||||||
>
|
<a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a>
|
||||||
<a class="btn btn-gray" href={l("/downloads")}
|
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a>
|
||||||
>{t("navbar.links.home.downloads")}</a
|
|
||||||
>
|
|
||||||
<a class="btn btn-gray" href={l("/tutorials")}
|
|
||||||
>{t("navbar.links.home.tutorials")}</a
|
|
||||||
>
|
|
||||||
<a class="btn btn-gray" href={l("/faq")}
|
|
||||||
>{t("navbar.links.home.faq")}</a
|
|
||||||
>
|
|
||||||
<a class="btn btn-gray" href={l("/code-of-conduct")}
|
|
||||||
>{t("navbar.links.rules.coc")}</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-dropdown">
|
<div class="btn-dropdown">
|
||||||
@ -113,35 +94,17 @@
|
|||||||
<CaretDownOutline class="ml-2 mt-auto" />
|
<CaretDownOutline class="ml-2 mt-auto" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<a href={l("/rules/wargear")} class="btn btn-gray"
|
<a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a>
|
||||||
>{t("navbar.links.rules.wg")}</a
|
<a href={l("/rules/miniwargear")} class="btn btn-gray">{t("navbar.links.rules.mwg")}</a>
|
||||||
>
|
<a href={l("/rules/warship")} class="btn btn-gray">{t("navbar.links.rules.ws")}</a>
|
||||||
<a href={l("/rules/miniwargear")} class="btn btn-gray"
|
<a href={l("/rules/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a>
|
||||||
>{t("navbar.links.rules.mwg")}</a
|
<a href={l("/rules/quickgear")} class="btn btn-gray">{t("navbar.links.rules.qg")}</a>
|
||||||
>
|
|
||||||
<a href={l("/rules/warship")} class="btn btn-gray"
|
|
||||||
>{t("navbar.links.rules.ws")}</a
|
|
||||||
>
|
|
||||||
<a href={l("/rules/airship")} class="btn btn-gray"
|
|
||||||
>{t("navbar.links.rules.as")}</a
|
|
||||||
>
|
|
||||||
<a href={l("/rules/quickgear")} class="btn btn-gray"
|
|
||||||
>{t("navbar.links.rules.qg")}</a
|
|
||||||
>
|
|
||||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
|
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
|
||||||
<a href={l("/rules/megawargear")} class="btn btn-gray"
|
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</a>
|
||||||
>{t("navbar.links.rules.megawg")}</a
|
<a href={l("/rules/microwargear")} class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
|
||||||
>
|
<a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a>
|
||||||
<a href={l("/rules/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>
|
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
|
||||||
<a href={l("/rangliste/missilewars")} class="btn btn-gray"
|
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a>
|
||||||
>{t("navbar.links.ranked.mw")}</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: Add help center
|
<!-- TODO: Add help center
|
||||||
|
|||||||
@ -1,49 +1,59 @@
|
|||||||
---
|
---
|
||||||
import {CollectionEntry} from "astro:content";
|
import type { CollectionEntry } from "astro:content";
|
||||||
import {l} from "../util/util";
|
import { l } from "../util/util";
|
||||||
import {astroI18n} from "astro-i18n";
|
import { astroI18n } from "astro-i18n";
|
||||||
import {Image} from "astro:assets";
|
import { Image } from "astro:assets";
|
||||||
import TagComponent from "./TagComponent.astro";
|
import TagComponent from "./TagComponent.astro";
|
||||||
import P from "./P.astro";
|
import P from "./P.astro";
|
||||||
import Card from "@components/Card.svelte";
|
import Card from "@components/Card.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
post: CollectionEntry<"announcements">
|
post: CollectionEntry<"announcements">;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post, slim }: {
|
const {
|
||||||
post: CollectionEntry<"announcements">,
|
post,
|
||||||
slim: boolean,
|
slim,
|
||||||
|
}: {
|
||||||
|
post: CollectionEntry<"announcements">;
|
||||||
|
slim: boolean;
|
||||||
} = Astro.props as Props;
|
} = Astro.props as Props;
|
||||||
|
|
||||||
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
|
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
|
<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"}`}>
|
<div class={`flex flex-row ${slim ? "" : "p-4"}`}>
|
||||||
{post.data.image != null
|
{
|
||||||
? (
|
post.data.image != null ? (
|
||||||
<a href={postUrl}>
|
<a href={postUrl}>
|
||||||
<div class="flex-shrink-0 pr-2">
|
<div class="flex-shrink-0 pr-2">
|
||||||
<Image 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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
) : null
|
||||||
: null}
|
}
|
||||||
<div>
|
<div>
|
||||||
<a href={postUrl} class="flex flex-col items-start">
|
<a href={postUrl} class="flex flex-col items-start">
|
||||||
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
|
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
|
||||||
<P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
|
<P class="text-gray-500"
|
||||||
|
>{
|
||||||
|
Intl.DateTimeFormat(astroI18n.locale, {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(post.data.created)}</P>
|
}).format(post.data.created)
|
||||||
|
}</P
|
||||||
|
>
|
||||||
<P>{post.data.description}</P>
|
<P>{post.data.description}</P>
|
||||||
</a>
|
</a>
|
||||||
<div class="mt-1" transition:name={post.data.title + "-tags"}>
|
<div class="mt-1" transition:name={post.data.title + "-tags"}>
|
||||||
{post.data.tags.map((tag) => (
|
{post.data.tags.map((tag) => <TagComponent tag={tag} />)}
|
||||||
<TagComponent tag={tag} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,38 +22,40 @@
|
|||||||
import wrap from "svelte-spa-router/wrap";
|
import wrap from "svelte-spa-router/wrap";
|
||||||
import Router, {replace} from "svelte-spa-router";
|
import Router, {replace} from "svelte-spa-router";
|
||||||
import {get} from "svelte/store";
|
import {get} from "svelte/store";
|
||||||
import {tokenStore} from "@repo/repo";
|
import {loggedIn} from "@repo/authv2.ts";
|
||||||
|
|
||||||
const routes: RouteDefinition = {
|
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({
|
"/perms": wrap({
|
||||||
asyncComponent: () => import("./pages/Perms.svelte"),
|
asyncComponent: () => import("./pages/Perms.svelte"),
|
||||||
conditions: detail => get(tokenStore) != ""
|
conditions: detail => get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"/login": wrap({
|
"/login": wrap({
|
||||||
asyncComponent: () => import("./pages/Login.svelte"),
|
asyncComponent: () => import("./pages/Login.svelte"),
|
||||||
conditions: detail => get(tokenStore) == ""
|
conditions: detail => !get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"/event/:id": wrap({
|
"/event/:id": wrap({
|
||||||
asyncComponent: () => import("./pages/Event.svelte"),
|
asyncComponent: () => import("./pages/Event.svelte"),
|
||||||
conditions: detail => get(tokenStore) != ""
|
conditions: detail => get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"/event/:id/generate": wrap({
|
"/event/:id/generate": wrap({
|
||||||
asyncComponent: () => import("./pages/Generate.svelte"),
|
asyncComponent: () => import("./pages/Generate.svelte"),
|
||||||
conditions: detail => get(tokenStore) != ""
|
conditions: detail => get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"/edit": wrap({
|
"/edit": wrap({
|
||||||
asyncComponent: () => import("./pages/Edit.svelte"),
|
asyncComponent: () => import("./pages/Edit.svelte"),
|
||||||
conditions: detail => get(tokenStore) != ""
|
conditions: detail => get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"/display/:event": wrap({
|
"/display/:event": wrap({
|
||||||
asyncComponent: () => import("./pages/Display.svelte"),
|
asyncComponent: () => import("./pages/Display.svelte"),
|
||||||
conditions: detail => get(tokenStore) != ""
|
conditions: detail => get(loggedIn)
|
||||||
}),
|
}),
|
||||||
"*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")})
|
"*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")})
|
||||||
};
|
};
|
||||||
|
|
||||||
function conditionsFailed(event: ConditionsFailedEvent) {
|
function conditionsFailed(event: ConditionsFailedEvent) {
|
||||||
|
console.log(event)
|
||||||
|
|
||||||
if (event.detail.location === "/login") {
|
if (event.detail.location === "/login") {
|
||||||
replace("/");
|
replace("/");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -168,11 +168,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{#if selectedBranch !== "master"}
|
{#if selectedBranch !== "master"}
|
||||||
<Button onclick={createFile} color="alternative" disabled={!selectedPath}>Create File
|
<Button onclick={() => createFile()} color="alternative" disabled={!selectedPath}>Create File
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button>
|
<Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button onclick={createBranch}>Create Branch</Button>
|
<Button onclick={() => createBranch()}>Create Branch</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
</NavBrand>
|
</NavBrand>
|
||||||
<NavHamburger onclick={toggle}/>
|
<NavHamburger onclick={toggle}/>
|
||||||
<NavUl {hidden}>
|
<NavUl {hidden}>
|
||||||
|
<NavLi href="/admin/new">New UI</NavLi>
|
||||||
<NavLi href="#/edit">Edit Pages</NavLi>
|
<NavLi href="#/edit">Edit Pages</NavLi>
|
||||||
<NavLi href="#/perms">Permissions</NavLi>
|
<NavLi href="#/perms">Permissions</NavLi>
|
||||||
</NavUl>
|
</NavUl>
|
||||||
|
|||||||
@ -18,15 +18,14 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte";
|
import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte";
|
||||||
import {json} from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import CodeMirror from "svelte-codemirror-editor";
|
import { base64ToBytes } from "../../util.ts";
|
||||||
import {base64ToBytes} from "../../util.ts";
|
import type { Page } from "@type/page.ts";
|
||||||
import type {Page} from "@type/page.ts";
|
import { materialDark } from "@ddietr/codemirror-themes/material-dark";
|
||||||
import {materialDark} from "@ddietr/codemirror-themes/material-dark";
|
import { createEventDispatcher } from "svelte";
|
||||||
import {createEventDispatcher} from "svelte";
|
|
||||||
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
|
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
|
||||||
import {pageRepo} from "@repo/page.ts";
|
import { pageRepo } from "@repo/page.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: number;
|
pageId: number;
|
||||||
@ -34,7 +33,7 @@
|
|||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { pageId, branch, dirty = $bindable(false) }: Props = $props();
|
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
let dispatcher = createEventDispatcher();
|
let dispatcher = createEventDispatcher();
|
||||||
|
|
||||||
@ -71,34 +70,31 @@
|
|||||||
}
|
}
|
||||||
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
|
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
|
||||||
</script>
|
</script>
|
||||||
<svelte:window onbeforeunload={() => {
|
|
||||||
|
<svelte:window
|
||||||
|
onbeforeunload={() => {
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
return "You have unsaved changes. Are you sure you want to leave?";
|
return "You have unsaved changes. Are you sure you want to leave?";
|
||||||
}
|
}
|
||||||
}}/>
|
}}
|
||||||
|
/>
|
||||||
{#await pageFuture}
|
{#await pageFuture}
|
||||||
<Spinner/>
|
<Spinner />
|
||||||
{:then p}
|
{:then p}
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<Toolbar class="!bg-gray-900">
|
<Toolbar class="!bg-gray-900">
|
||||||
{#snippet end()}
|
{#snippet end()}
|
||||||
<ToolbarGroup >
|
<ToolbarGroup>
|
||||||
<ToolbarButton onclick={deletePage}>
|
<ToolbarButton onclick={deletePage}>Delete</ToolbarButton>
|
||||||
Delete
|
<ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton>
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton color="primary" onclick={savePage}>
|
|
||||||
Save
|
|
||||||
</ToolbarButton>
|
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
|
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
|
||||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty/>
|
<MDEMarkdownEditor bind:value={pageContent} bind:dirty />
|
||||||
{:else}
|
{:else}{/if}
|
||||||
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} on:change={() => dirty = true}/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
|
|||||||
@ -18,12 +18,14 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {createEventDispatcher} from "svelte";
|
import { Card } from "@components/ui/card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import("svelte").Snippet;
|
||||||
|
ondrop: (event: DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children, ondrop }: Props = $props();
|
||||||
|
|
||||||
let dragover = $state(false);
|
let dragover = $state(false);
|
||||||
|
|
||||||
@ -32,19 +34,16 @@
|
|||||||
dragover = true;
|
dragover = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function handleDrop(ev: DragEvent) {
|
function handleDrop(ev: DragEvent) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
dragover = false;
|
dragover = false;
|
||||||
dispatch("drop", ev);
|
ondrop(ev);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} ondrop={handleDrop}
|
<Card class="w-56 p-4 rounded m-px {dragover ? 'border-white' : ''}" ondrop={handleDrop} ondragover={handleDragOver} ondragleave={() => (dragover = false)} role="none">
|
||||||
ondragover={handleDragOver} ondragleave={() => dragover = false} role="none">
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
div {
|
div {
|
||||||
|
|||||||
@ -18,28 +18,28 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createBubbler } from 'svelte/legacy';
|
import type { Team } from "@type/team.ts";
|
||||||
|
import { brightness, colorFromTeam, lighten } from "../../util";
|
||||||
const bubble = createBubbler();
|
|
||||||
import type {Team} from "@type/team.ts";
|
|
||||||
import {brightness, colorFromTeam, lighten} from "../../util";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
team: Team;
|
team: Team;
|
||||||
|
ondragstart: (event: DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { team }: Props = $props();
|
let { team, ondragstart }: Props = $props();
|
||||||
|
|
||||||
let hover = $state(false);
|
let hover = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
<div
|
||||||
|
class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
||||||
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
|
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
|
||||||
class:text-black={brightness(colorFromTeam(team))} draggable="true"
|
class:text-black={brightness(colorFromTeam(team))}
|
||||||
ondragstart={bubble('dragstart')}
|
draggable="true"
|
||||||
onmouseenter={() => hover = true}
|
{ondragstart}
|
||||||
onmouseleave={() => hover = false}
|
onmouseenter={() => (hover = true)}
|
||||||
role="figure">
|
onmouseleave={() => (hover = false)}
|
||||||
|
role="figure"
|
||||||
|
>
|
||||||
<span>{team.name}</span>
|
<span>{team.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
let request = getRequest();
|
let request = getRequest();
|
||||||
|
|
||||||
function getRequest() {
|
function getRequest() {
|
||||||
return $statsRepo.getUserStats(user.id)
|
return $statsRepo.getUserStats(user.uuid)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -43,8 +43,5 @@
|
|||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
}).format(data.playtime)})}h</p>
|
}).format(data.playtime)})}h</p>
|
||||||
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
|
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
|
||||||
{#if user.perms.includes("CHECK")}
|
|
||||||
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
||||||
{/if}
|
|
||||||
{:catch error}
|
|
||||||
{/await}
|
{/await}
|
||||||
@ -21,7 +21,7 @@
|
|||||||
import {createEventDispatcher} from "svelte";
|
import {createEventDispatcher} from "svelte";
|
||||||
import {schemRepo} from "@repo/schem.ts";
|
import {schemRepo} from "@repo/schem.ts";
|
||||||
import SWModal from "@components/styled/SWModal.svelte";
|
import SWModal from "@components/styled/SWModal.svelte";
|
||||||
import {t} from "astro-i18n"
|
import {t} from "astro-i18n";
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@ -29,11 +29,13 @@
|
|||||||
open?: boolean;
|
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) {
|
if (uploadFile == null) {
|
||||||
return
|
error = "dashboard.schematic.errors.noFile";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let file = uploadFile[0];
|
let file = uploadFile[0];
|
||||||
|
|
||||||
@ -42,32 +44,46 @@
|
|||||||
let type = name.split(".").pop();
|
let type = name.split(".").pop();
|
||||||
|
|
||||||
if (type !== "schem" && type !== "schematic") {
|
if (type !== "schem" && type !== "schematic") {
|
||||||
return
|
error = "dashboard.schematic.errors.invalidEnding";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = await file.arrayBuffer();
|
let content = await file.arrayBuffer();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
let b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(content)));
|
let b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(content)));
|
||||||
|
|
||||||
|
try {
|
||||||
await $schemRepo.uploadSchematic(name, b64);
|
await $schemRepo.uploadSchematic(name, b64);
|
||||||
|
|
||||||
open = false;
|
open = false;
|
||||||
uploadFile = null;
|
value = "";
|
||||||
dispatch("reset")
|
dispatch("reset");
|
||||||
|
} catch (e) {
|
||||||
|
error = "dashboard.schematic.errors.upload";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
open = false
|
||||||
|
value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploadFile: FileList | null = $state(null);
|
let uploadFile: FileList | null = $state(null);
|
||||||
|
let value = $state("");
|
||||||
|
let error = $state(null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SWModal title={t("dashboard.schematic.title")} bind:open>
|
<SWModal title={t("dashboard.schematic.title")} bind:open>
|
||||||
<form>
|
<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>
|
</form>
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
||||||
<button class="btn !ml-auto" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
<button class="btn btn-gray" onclick={reset}>{t("dashboard.schematic.cancel")}</button>
|
||||||
<button class="btn btn-gray" onclick={() => open = false}>{t("dashboard.schematic.cancel")}</button>
|
|
||||||
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SWModal>
|
</SWModal>
|
||||||
@ -18,13 +18,13 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {t} from "astro-i18n";
|
import { t } from "astro-i18n";
|
||||||
import type {Player} from "@type/data.ts";
|
import type { Player } from "@type/data.ts";
|
||||||
import {l} from "@utils/util.ts";
|
import { l } from "@utils/util.ts";
|
||||||
import Statistics from "./Statistics.svelte";
|
import Statistics from "./Statistics.svelte";
|
||||||
import {authRepo} from "@repo/auth.ts";
|
import { authV2Repo } from "@repo/authv2.ts";
|
||||||
import {tokenStore} from "@repo/repo.ts";
|
|
||||||
import Card from "@components/Card.svelte";
|
import Card from "@components/Card.svelte";
|
||||||
|
import { navigate } from "astro:transitions/client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: Player;
|
user: Player;
|
||||||
@ -33,9 +33,8 @@
|
|||||||
let { user }: Props = $props();
|
let { user }: Props = $props();
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await $authRepo.logout()
|
await $authV2Repo.logout();
|
||||||
tokenStore.set("")
|
await navigate(l("/login"));
|
||||||
window.location.href = l("/login")
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -44,19 +43,25 @@
|
|||||||
<Card>
|
<Card>
|
||||||
<figure>
|
<figure>
|
||||||
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
|
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
|
||||||
<img 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>
|
</figure>
|
||||||
</Card>
|
</Card>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
|
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
|
||||||
{#if user.perms.includes("MODERATION")}
|
{#if user.perms.includes("MODERATION")}
|
||||||
<a class="btn w-fit mt-2" href="/admin" 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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1>
|
<h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1>
|
||||||
<p>{t("dashboard.rank", {rank: t("home.prefix." + user.prefix)})}</p>
|
<p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p>
|
||||||
<Statistics {user} />
|
<Statistics {user} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
55
src/components/moderator/App.svelte
Normal file
55
src/components/moderator/App.svelte
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<!--
|
||||||
|
- This file is a part of the SteamWar software.
|
||||||
|
-
|
||||||
|
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||||
|
-
|
||||||
|
- This program is free software: you can redistribute it and/or modify
|
||||||
|
- it under the terms of the GNU Affero General Public License as published by
|
||||||
|
- the Free Software Foundation, either version 3 of the License, or
|
||||||
|
- (at your option) any later version.
|
||||||
|
-
|
||||||
|
- This program is distributed in the hope that it will be useful,
|
||||||
|
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
- GNU Affero General Public License for more details.
|
||||||
|
-
|
||||||
|
- You should have received a copy of the GNU Affero General Public License
|
||||||
|
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RouteDefinition } from "svelte-spa-router";
|
||||||
|
import Router from "svelte-spa-router";
|
||||||
|
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
|
||||||
|
import Players from "@components/moderator/pages/players/Players.svelte";
|
||||||
|
import Events from "@components/moderator/pages/events/Events.svelte";
|
||||||
|
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
|
||||||
|
import Event from "@components/moderator/pages/event/Event.svelte";
|
||||||
|
import Pages from "@components/moderator/pages/pages/Pages.svelte";
|
||||||
|
import Generator from "@components/moderator/pages/generators/Generator.svelte";
|
||||||
|
import Schematics from "@components/moderator/pages/schems/Schematics.svelte";
|
||||||
|
import { Tooltip } from "bits-ui";
|
||||||
|
|
||||||
|
const routes: RouteDefinition = {
|
||||||
|
"/": Dashboard,
|
||||||
|
"/events": Events,
|
||||||
|
"/players": Players,
|
||||||
|
"/event/:id": Event,
|
||||||
|
"/event/:id/generate": Generator,
|
||||||
|
"/pages": Pages,
|
||||||
|
"/schems": Schematics,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col bg-background min-w-full min-h-screen">
|
||||||
|
<div class="border-b">
|
||||||
|
<div class="flex h-16 items-center px-4">
|
||||||
|
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
|
||||||
|
<NavLinks />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Router {routes} />
|
||||||
|
</Tooltip.Provider>
|
||||||
|
</div>
|
||||||
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 { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
import {onDestroy, onMount} from "svelte";
|
import {onDestroy, onMount} from "svelte";
|
||||||
import { CollectionEntry } from "astro:content";
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pub: CollectionEntry<"publics">;
|
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,21 +17,37 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {Player, Server} from "@type/data.ts";
|
import type { Player, Server } from "@type/data.ts";
|
||||||
import {PlayerSchema, ServerSchema} from "@type/data.ts";
|
import { PlayerSchema, ServerSchema } from "@type/data.ts";
|
||||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||||
import {derived} from "svelte/store";
|
import { derived, get } from "svelte/store";
|
||||||
|
import { TeamSchema, type Team } from "@components/types/team.ts";
|
||||||
|
|
||||||
export class DataRepo {
|
export class DataRepo {
|
||||||
constructor(private token: string) {
|
constructor(private token: string) {}
|
||||||
}
|
|
||||||
|
|
||||||
public async getServer(): Promise<Server> {
|
public async getServer(): Promise<Server> {
|
||||||
return await fetchWithToken(this.token, "/data/server").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> {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,12 +17,26 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
|
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
|
||||||
import {fetchWithToken, tokenStore} from "./repo";
|
import { fetchWithToken, tokenStore } from "./repo";
|
||||||
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
|
import {
|
||||||
import {z} from "zod";
|
ExtendedEventSchema,
|
||||||
import type {Dayjs} from "dayjs";
|
ShortEventSchema,
|
||||||
import {derived} from "svelte/store";
|
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 {
|
export interface CreateEvent {
|
||||||
name: string;
|
name: string;
|
||||||
@ -31,30 +45,36 @@ export interface CreateEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEvent {
|
export interface UpdateEvent {
|
||||||
name: string | null;
|
name?: string | null;
|
||||||
start: Dayjs | null;
|
start?: Dayjs | number | null;
|
||||||
end: Dayjs | null;
|
end?: Dayjs | number | null;
|
||||||
deadline: Dayjs | null;
|
deadline?: Dayjs | number | null;
|
||||||
maxTeamMembers: number | null;
|
maxTeamMembers?: number | null;
|
||||||
schemType: string | null;
|
schemType?: string | null;
|
||||||
publicSchemsOnly: boolean | null;
|
publicSchemsOnly?: boolean | null;
|
||||||
addReferee: string[] | null;
|
addReferee?: string[] | null;
|
||||||
removeReferee: string[] | null;
|
removeReferee?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseUser {
|
||||||
|
name: string;
|
||||||
|
uuid: string;
|
||||||
|
prefix: string;
|
||||||
|
perms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EventRepo {
|
export class EventRepo {
|
||||||
constructor(private token: string) {
|
constructor(private token: string) {}
|
||||||
}
|
|
||||||
|
|
||||||
public async listEvents(): Promise<ShortEvent[]> {
|
public async listEvents(): Promise<ShortEvent[]> {
|
||||||
return await fetchWithToken(this.token, "/events")
|
return await fetchWithToken(this.token, "/events")
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(value => z.array(ShortEventSchema).parse(value));
|
.then((value) => z.array(ShortEventSchema).parse(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getEvent(id: string): Promise<ExtendedEvent> {
|
public async getEvent(id: string): Promise<ExtendedEvent> {
|
||||||
return await fetchWithToken(this.token, `/events/${id}`)
|
return await fetchWithToken(this.token, `/events/${id}`)
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(ExtendedEventSchema.parse);
|
.then(ExtendedEventSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +86,8 @@ export class EventRepo {
|
|||||||
start: +event.start,
|
start: +event.start,
|
||||||
end: +event.end,
|
end: +event.end,
|
||||||
}),
|
}),
|
||||||
}).then(value => value.json())
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
.then(SWEventSchema.parse);
|
.then(SWEventSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +108,8 @@ export class EventRepo {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
}).then(value => value.json())
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
.then(SWEventSchema.parse);
|
.then(SWEventSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +120,154 @@ export class EventRepo {
|
|||||||
|
|
||||||
return res.ok;
|
return res.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fights
|
||||||
|
public async listFights(eventId: string): Promise<EventFight[]> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then((value) => z.array(EventFightSchema).parse(value));
|
||||||
|
}
|
||||||
|
public async createFight(eventId: string, fight: any): Promise<EventFight> {
|
||||||
|
delete fight.ergebnis;
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(fight),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(EventFightSchema.parse);
|
||||||
|
}
|
||||||
|
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then((value) => z.array(ResponseGroupsSchema).parse(value));
|
||||||
|
}
|
||||||
|
public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
|
||||||
|
CreateEventGroupSchema.parse(group);
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: group.name,
|
||||||
|
type: group.type,
|
||||||
|
}),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseGroupsSchema.parse);
|
||||||
|
}
|
||||||
|
public async getGroup(eventId: string, groupId: string): Promise<ResponseGroups> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseGroupsSchema.parse);
|
||||||
|
}
|
||||||
|
public async updateGroup(eventId: string, groupId: string, group: UpdateEventGroup): Promise<ResponseGroups> {
|
||||||
|
UpdateEventGroupSchema.parse(group);
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(group),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseGroupsSchema.parse);
|
||||||
|
}
|
||||||
|
public async deleteGroup(eventId: string, groupId: string): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
public async listRelations(eventId: number): Promise<ResponseRelation[]> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/relations`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then((value) => z.array(ResponseRelationSchema).parse(value));
|
||||||
|
}
|
||||||
|
public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
|
||||||
|
CreateEventRelationSchema.parse(relation);
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(relation),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseRelationSchema.parse);
|
||||||
|
}
|
||||||
|
public async getRelation(eventId: string, relationId: string): Promise<ResponseRelation> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseRelationSchema.parse);
|
||||||
|
}
|
||||||
|
public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
|
||||||
|
UpdateEventRelationSchema.parse(relation);
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(relation),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(ResponseRelationSchema.parse);
|
||||||
|
}
|
||||||
|
public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams
|
||||||
|
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then((value) => z.array(ResponseTeamSchema).parse(value));
|
||||||
|
}
|
||||||
|
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(teams),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify(teams),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referees
|
||||||
|
public async listReferees(eventId: string): Promise<ResponseUser[]> {
|
||||||
|
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then((value) => z.array(ResponseUserSchema).parse(value));
|
||||||
|
}
|
||||||
|
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(refereeUuids),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return res.status === 204;
|
||||||
|
}
|
||||||
|
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||||
|
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify(refereeUuids),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return res.status === 204;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));
|
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));
|
||||||
|
|||||||
@ -17,12 +17,12 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {EventFight} from "@type/event.js";
|
import type { EventFight } from "@type/event.js";
|
||||||
import {fetchWithToken, tokenStore} from "./repo";
|
import { fetchWithToken, tokenStore } from "./repo";
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
import {EventFightSchema} from "@type/event.js";
|
import { EventFightSchema } from "@type/event.js";
|
||||||
import type {Dayjs} from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import {derived} from "svelte/store";
|
import { derived } from "svelte/store";
|
||||||
|
|
||||||
export interface CreateFight {
|
export interface CreateFight {
|
||||||
spielmodus: string;
|
spielmodus: string;
|
||||||
@ -31,7 +31,7 @@ export interface CreateFight {
|
|||||||
redTeam: number;
|
redTeam: number;
|
||||||
start: Dayjs;
|
start: Dayjs;
|
||||||
spectatePort: number | null;
|
spectatePort: number | null;
|
||||||
group: string | null;
|
group: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFight {
|
export interface UpdateFight {
|
||||||
@ -39,26 +39,24 @@ export interface UpdateFight {
|
|||||||
map: string | null;
|
map: string | null;
|
||||||
blueTeam: number | null;
|
blueTeam: number | null;
|
||||||
redTeam: number | null;
|
redTeam: number | null;
|
||||||
start: Dayjs | null;
|
start: number | null;
|
||||||
spectatePort: number | null;
|
spectatePort: number | null;
|
||||||
group: string | null;
|
group: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FightRepo {
|
export class FightRepo {
|
||||||
constructor(private token: string) {
|
constructor(private token: string) {}
|
||||||
}
|
|
||||||
|
|
||||||
public async listFights(eventId: number): Promise<EventFight[]> {
|
public async listFights(eventId: number): Promise<EventFight[]> {
|
||||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(value => z.array(EventFightSchema).parse(value));
|
.then((value) => z.array(EventFightSchema).parse(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
|
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
|
||||||
return await fetchWithToken(this.token, "/fights", {
|
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
event: eventId,
|
|
||||||
spielmodus: fight.spielmodus,
|
spielmodus: fight.spielmodus,
|
||||||
map: fight.map,
|
map: fight.map,
|
||||||
blueTeam: fight.blueTeam,
|
blueTeam: fight.blueTeam,
|
||||||
@ -67,28 +65,25 @@ export class FightRepo {
|
|||||||
spectatePort: fight.spectatePort,
|
spectatePort: fight.spectatePort,
|
||||||
group: fight.group,
|
group: fight.group,
|
||||||
}),
|
}),
|
||||||
}).then(value => value.json())
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
.then(EventFightSchema.parse);
|
.then(EventFightSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
|
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||||
return await fetchWithToken(this.token, `/fights/${fightId}`, {
|
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
spielmodus: fight.spielmodus,
|
...fight,
|
||||||
map: fight.map,
|
|
||||||
blueTeam: fight.blueTeam,
|
|
||||||
redTeam: fight.redTeam,
|
|
||||||
start: fight.start?.valueOf(),
|
start: fight.start?.valueOf(),
|
||||||
spectatePort: fight.spectatePort,
|
|
||||||
group: fight.group,
|
|
||||||
}),
|
}),
|
||||||
}).then(value => value.json())
|
})
|
||||||
|
.then((value) => value.json())
|
||||||
.then(EventFightSchema.parse);
|
.then(EventFightSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteFight(fightId: number): Promise<void> {
|
public async deleteFight(eventId: number, fightId: number): Promise<void> {
|
||||||
const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
|
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,27 +17,26 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {Page, PageList} from "@type/page.ts";
|
import type { Page, PageList } from "@type/page.ts";
|
||||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||||
import {PageListSchema, PageSchema} from "@type/page.ts";
|
import { PageListSchema, PageSchema } from "@type/page.ts";
|
||||||
import {bytesToBase64} from "../admin/util.ts";
|
import { bytesToBase64 } from "../admin/util.ts";
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
import {derived} from "svelte/store";
|
import { derived } from "svelte/store";
|
||||||
|
|
||||||
export class PageRepo {
|
export class PageRepo {
|
||||||
constructor(private token: string) {
|
constructor(private token: string) {}
|
||||||
}
|
|
||||||
|
|
||||||
public async listPages(branch: string = "master"): Promise<PageList> {
|
public async listPages(branch: string = "master"): Promise<PageList> {
|
||||||
return await fetchWithToken(this.token, `/page?branch=${branch}`)
|
return await fetchWithToken(this.token, `/page?branch=${branch}`)
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(PageListSchema.parse)
|
.then(PageListSchema.parse)
|
||||||
.then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")})));
|
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPage(id: number, branch: string = "master"): Promise<Page> {
|
public async getPage(id: number, branch: string = "master"): Promise<Page> {
|
||||||
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
|
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(PageSchema.parse);
|
.then(PageSchema.parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,40 +45,55 @@ export class PageRepo {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: bytesToBase64(new TextEncoder().encode(content)),
|
content: bytesToBase64(new TextEncoder().encode(content)),
|
||||||
sha, message,
|
sha,
|
||||||
|
message,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBranches(): Promise<string[]> {
|
public async getBranches(): Promise<string[]> {
|
||||||
return await fetchWithToken(this.token, "/page/branch")
|
return await fetchWithToken(this.token, "/page/branch")
|
||||||
.then(value => value.json())
|
.then((value) => value.json())
|
||||||
.then(value => z.array(z.string()).parse(value));
|
.then((value) => z.array(z.string()).parse(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createBranch(branch: string): Promise<void> {
|
public async createBranch(branch: string): Promise<void> {
|
||||||
await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})});
|
await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteBranch(branch: string): Promise<void> {
|
public async deleteBranch(branch: string): Promise<void> {
|
||||||
await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})});
|
await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
|
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
|
||||||
await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})});
|
await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async merge(branch: string, message: string): Promise<void> {
|
public async merge(branch: string, message: string): Promise<void> {
|
||||||
await fetchWithToken(this.token, "/page/branch/merge", {
|
await fetchWithToken(this.token, "/page/branch/merge", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({branch, message}),
|
body: JSON.stringify({ branch, message }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
|
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
|
||||||
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
|
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({message, sha}),
|
body: JSON.stringify({ message, sha }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listImages(branch: string = "master"): Promise<PageList> {
|
||||||
|
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
|
||||||
|
.then((value) => value.json())
|
||||||
|
.then(PageListSchema.parse)
|
||||||
|
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
|
||||||
|
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, data }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,31 +17,9 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {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 = {}) =>
|
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params);
|
||||||
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 function getLocalStorage() {
|
export const tokenStore = writable("");
|
||||||
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));
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||||
import {type Schematic, SchematicSchema} from "@type/schem.ts";
|
import {type Schematic, SchematicSchema} from "@type/schem.ts";
|
||||||
import {derived} from "svelte/store";
|
import {derived} from "svelte/store";
|
||||||
|
import {ResponseErrorSchema} from "@type/data.ts";
|
||||||
|
|
||||||
export class SchematicRepo {
|
export class SchematicRepo {
|
||||||
constructor(private token: string) {
|
constructor(private token: string) {
|
||||||
@ -40,7 +41,7 @@ export class SchematicRepo {
|
|||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
}),
|
}),
|
||||||
});
|
}).then(value => value.json()).then(SchematicSchema.or(ResponseErrorSchema).parse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export class StatsRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUserStats(id: string): Promise<UserStats> {
|
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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {readonly, writable} from "svelte/store";
|
import { readonly, writable } from "svelte/store";
|
||||||
|
|
||||||
import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
|
import type { Readable, Subscriber, Unsubscriber } from "svelte/store";
|
||||||
|
|
||||||
export interface Cached<T> extends Readable<T> {
|
export interface Cached<T> extends Readable<T> {
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
|
future: Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
||||||
const store = writable<T>(normal);
|
const store = writable<T>(normal);
|
||||||
|
const future = new Promise<T>((resolve) => {
|
||||||
|
let f = false;
|
||||||
|
store.subscribe((value) => {
|
||||||
|
if (f) {
|
||||||
|
resolve(value);
|
||||||
|
} else {
|
||||||
|
f = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
let first = true;
|
let first = true;
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
init().then(data => {
|
init().then((data) => {
|
||||||
store.set(data);
|
store.set(data);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -45,6 +56,7 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
|||||||
return store.subscribe(run, invalidate);
|
return store.subscribe(run, invalidate);
|
||||||
},
|
},
|
||||||
reload,
|
reload,
|
||||||
|
future,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +70,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
|
|||||||
let first = true;
|
let first = true;
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
init(arg).then(data => {
|
init(arg).then((data) => {
|
||||||
store.set(data);
|
store.set(data);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,33 +17,45 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {Player, SchematicType} from "@type/data";
|
import type { Player, SchematicType } from "@type/data";
|
||||||
import {PlayerSchema} from "@type/data.ts";
|
import { PlayerSchema } from "@type/data.ts";
|
||||||
import {cached, cachedFamily} from "./cached";
|
import { cached, cachedFamily } from "./cached";
|
||||||
import type {Team} from "@type/team.ts";
|
import type { Team } from "@type/team.ts";
|
||||||
import {TeamSchema} from "@type/team";
|
import { TeamSchema } from "@type/team";
|
||||||
import {derived, get, writable} from "svelte/store";
|
import { derived, get, writable } from "svelte/store";
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
import {fetchWithToken, tokenStore} from "@repo/repo.ts";
|
import { fetchWithToken, tokenStore } from "@repo/repo.ts";
|
||||||
import {pageRepo} from "@repo/page.ts";
|
import { pageRepo } from "@repo/page.ts";
|
||||||
import {dataRepo} from "@repo/data.ts";
|
import { dataRepo } from "@repo/data.ts";
|
||||||
|
import { permsRepo } from "@repo/perms.ts";
|
||||||
|
|
||||||
export const schemTypes = cached<SchematicType[]>([], () =>
|
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
|
||||||
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
|
|
||||||
.then(res => res.json()));
|
|
||||||
|
|
||||||
export const players = cached<Player[]>([], async () => {
|
export const players = cached<Player[]>([], async () => {
|
||||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/users");
|
return get(dataRepo).getPlayers();
|
||||||
return z.array(PlayerSchema).parse(await res.json());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
export const gamemodes = cached<string[]>([], async () => {
|
||||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
|
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
|
||||||
return z.array(z.string()).parse(await res.json());
|
return z.array(z.string()).parse(await res.json());
|
||||||
});
|
});
|
||||||
|
|
||||||
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
|
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
|
||||||
if (get(gamemodes).every(value => value !== gamemode)) return [];
|
if ((await gamemodes.future).every((value) => value !== gamemode)) return [];
|
||||||
|
|
||||||
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
|
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -58,17 +70,12 @@ export const groups = cached<string[]>([], async () => {
|
|||||||
return z.array(z.string()).parse(await res.json());
|
return z.array(z.string()).parse(await res.json());
|
||||||
});
|
});
|
||||||
|
|
||||||
export const teams = cached<Team[]>([], async () => {
|
|
||||||
const res = await fetchWithToken(get(tokenStore), "/team");
|
|
||||||
return z.array(TeamSchema).parse(await res.json());
|
|
||||||
});
|
|
||||||
|
|
||||||
export const branches = cached<string[]>([], async () => {
|
export const branches = cached<string[]>([], async () => {
|
||||||
const res = await get(pageRepo).getBranches();
|
const res = await get(pageRepo).getBranches();
|
||||||
return z.array(z.string()).parse(res);
|
return z.array(z.string()).parse(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const server = derived(dataRepo, $dataRepo => $dataRepo.getServer());
|
export const server = derived(dataRepo, ($dataRepo) => $dataRepo.getServer());
|
||||||
|
|
||||||
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);
|
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte";
|
import {onMount} from "svelte";
|
||||||
import {stopPropagation} from "@components/util.ts";
|
import {stopPropagation} from "@components/utils.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@ -68,16 +68,18 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</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">
|
<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">
|
<div onclick={stopPropagation(onclick)} aria-hidden="true" class="w-fit">
|
||||||
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<h1 class="text-4xl font-bold">{title}</h1>
|
<h1 class="text-4xl font-bold">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
|
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mx-4 my-2 p-6" onclick={() => dialog.close()} aria-hidden="true">
|
<div class="mx-4 my-2 p-6">
|
||||||
|
<div class="ml-auto flex justify-end" onclick={() => dialog.close()} aria-hidden="true">
|
||||||
{@render footer?.()}
|
{@render footer?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* This file is a part of the SteamWar software.
|
* This file is a part of the SteamWar software.
|
||||||
*
|
*
|
||||||
* Copyright (C) 2023 SteamWar.de-Serverteam
|
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -17,17 +17,18 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function window<T>(arr: T[], len: number): T[][] {
|
import {z} from "zod";
|
||||||
const result: T[][] = [];
|
|
||||||
for (let i = 0; i < arr.length; i += len) {
|
|
||||||
result.push(arr.slice(i, i + len));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopPropagation(a: any) {
|
export const TokenSchema = z.object({
|
||||||
return (e: Event) => {
|
token: z.string(),
|
||||||
e.stopPropagation();
|
expires: z.string(),
|
||||||
a(e);
|
});
|
||||||
};
|
|
||||||
}
|
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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const SchematicTypeSchema = z.object({
|
export const SchematicTypeSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@ -50,3 +50,19 @@ export const ServerSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Server = z.infer<typeof ServerSchema>;
|
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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
import {TeamSchema} from "./team.js";
|
import { TeamSchema } from "./team.js";
|
||||||
import {PlayerSchema} from "./data.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({
|
export const ShortEventSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@ -35,29 +84,69 @@ export const SWEventSchema = ShortEventSchema.extend({
|
|||||||
maxTeamMembers: z.number(),
|
maxTeamMembers: z.number(),
|
||||||
schemType: z.string().nullable(),
|
schemType: z.string().nullable(),
|
||||||
publicSchemsOnly: z.boolean(),
|
publicSchemsOnly: z.boolean(),
|
||||||
referees: z.array(PlayerSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SWEvent = z.infer<typeof SWEventSchema>;
|
export type SWEvent = z.infer<typeof SWEventSchema>;
|
||||||
|
|
||||||
export const EventFightSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
spielmodus: z.string(),
|
|
||||||
map: z.string(),
|
|
||||||
blueTeam: TeamSchema,
|
|
||||||
redTeam: TeamSchema,
|
|
||||||
start: z.number(),
|
|
||||||
ergebnis: z.number(),
|
|
||||||
spectatePort: z.number().nullable(),
|
|
||||||
group: z.string().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type EventFight = z.infer<typeof EventFightSchema>;
|
|
||||||
|
|
||||||
export const ExtendedEventSchema = z.object({
|
export const ExtendedEventSchema = z.object({
|
||||||
event: SWEventSchema,
|
event: SWEventSchema,
|
||||||
teams: z.array(TeamSchema),
|
teams: z.array(TeamSchema),
|
||||||
|
groups: z.array(ResponseGroupsSchema),
|
||||||
fights: z.array(EventFightSchema),
|
fights: z.array(EventFightSchema),
|
||||||
|
referees: z.array(ResponseUserSchema),
|
||||||
|
relations: z.array(ResponseRelationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
|
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
|
||||||
|
|
||||||
|
export const ResponseTeamSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
kuerzel: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
|
||||||
|
|
||||||
|
export const CreateEventGroupSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
|
||||||
|
});
|
||||||
|
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
|
||||||
|
|
||||||
|
export const UpdateEventGroupSchema = z.object({
|
||||||
|
name: z.string().nullable().optional(),
|
||||||
|
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
|
||||||
|
pointsPerWin: z.number().nullable().optional(),
|
||||||
|
pointsPerLoss: z.number().nullable().optional(),
|
||||||
|
pointsPerDraw: z.number().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
|
||||||
|
|
||||||
|
export const GroupEditSchema = ResponseGroupsSchema.omit({
|
||||||
|
id: true,
|
||||||
|
points: true,
|
||||||
|
});
|
||||||
|
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
|
||||||
|
|
||||||
|
export const CreateEventRelationSchema = z.object({
|
||||||
|
fightId: z.number(),
|
||||||
|
team: z.enum(["RED", "BLUE"]),
|
||||||
|
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||||
|
fromId: z.number(),
|
||||||
|
fromPlace: z.number(),
|
||||||
|
});
|
||||||
|
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
|
||||||
|
|
||||||
|
export const UpdateFromRelationSchema = z.object({
|
||||||
|
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||||
|
fromId: z.number(),
|
||||||
|
fromPlace: z.number(),
|
||||||
|
});
|
||||||
|
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
|
||||||
|
|
||||||
|
export const UpdateEventRelationSchema = z.object({
|
||||||
|
team: z.enum(["RED", "BLUE"]).nullable().optional(),
|
||||||
|
from: UpdateFromRelationSchema.nullable().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;
|
||||||
|
|||||||
@ -17,11 +17,11 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const PrefixSchema = z.object({
|
export const PrefixSchema = z.object({
|
||||||
name: z.string().startsWith("PREFIX_"),
|
name: z.string().startsWith("PREFIX_"),
|
||||||
colorCode: z.string().length(2).startsWith("§"),
|
colorCode: z.string().startsWith("§"),
|
||||||
chatPrefix: z.string(),
|
chatPrefix: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,12 +17,12 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const TeamSchema = z.object({
|
export const TeamSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
kuerzel: z.string().min(1).max(4),
|
kuerzel: z.string().min(1).max(16),
|
||||||
color: z.string().max(1),
|
color: z.string().max(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
13
src/components/ui/alert/alert-description.svelte
Normal file
13
src/components/ui/alert/alert-description.svelte
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/components/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
21
src/components/ui/alert/alert-title.svelte
Normal file
21
src/components/ui/alert/alert-title.svelte
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { HeadingLevel } from "./index.js";
|
||||||
|
import { cn } from "$lib/components/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
|
||||||
|
level?: HeadingLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let level: $$Props["level"] = "h5";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={level}
|
||||||
|
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</svelte:element>
|
||||||
17
src/components/ui/alert/alert.svelte
Normal file
17
src/components/ui/alert/alert.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { type Variant, alertVariants } from "./index.js";
|
||||||
|
import { cn } from "$lib/components/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variant?: Variant;
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let variant: $$Props["variant"] = "default";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
33
src/components/ui/alert/index.ts
Normal file
33
src/components/ui/alert/index.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
import Root from "./alert.svelte";
|
||||||
|
import Description from "./alert-description.svelte";
|
||||||
|
import Title from "./alert-title.svelte";
|
||||||
|
|
||||||
|
export const alertVariants = tv({
|
||||||
|
base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4",
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Variant = VariantProps<typeof alertVariants>["variant"];
|
||||||
|
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Description,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Alert,
|
||||||
|
Description as AlertDescription,
|
||||||
|
Title as AlertTitle,
|
||||||
|
};
|
||||||
11
src/components/ui/aspect-ratio/aspect-ratio.svelte
Normal file
11
src/components/ui/aspect-ratio/aspect-ratio.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AspectRatio as AspectRatioPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
type $$Props = AspectRatioPrimitive.Props;
|
||||||
|
|
||||||
|
export let ratio: $$Props["ratio"] = 4 / 3;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AspectRatioPrimitive.Root {ratio} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</AspectRatioPrimitive.Root>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user