Compare commits
172 Commits
add-tutori
...
event-brac
| Author | SHA1 | Date | |
|---|---|---|---|
| bd1c4f7f45 | |||
| 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/pages/en/
|
||||
/.idea
|
||||
pnpm-lock.yaml
|
||||
|
||||
@ -5,10 +5,8 @@ import configureI18n from "./astro-i18n.adapter";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
|
||||
import robotsTxt from "astro-robots-txt";
|
||||
import {resolve} from "node:url";
|
||||
import path from "node:path";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import pagefind from "astro-pagefind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@ -21,9 +19,8 @@ export default defineConfig({
|
||||
integrations: [
|
||||
svelte(),
|
||||
tailwind({
|
||||
configFile: "./tailwind.config.cjs",
|
||||
configFile: "./tailwind.config.js",
|
||||
}),
|
||||
pagefind(),
|
||||
configureI18n(),
|
||||
sitemap({
|
||||
i18n: {
|
||||
@ -69,6 +66,7 @@ export default defineConfig({
|
||||
"@layouts": path.resolve("./src/layouts"),
|
||||
"@repo": path.resolve("./src/components/repo"),
|
||||
"@stores": path.resolve("./src/components/stores"),
|
||||
"$lib": path.resolve("./src"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src\\styles\\app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/components/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
}
|
||||
160
package.json
160
package.json
@ -1,69 +1,95 @@
|
||||
{
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm run clean:dist && pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^6.0.2",
|
||||
"@astrojs/tailwind": "^5.1.2",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cssnano": "^7.0.6",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.81.0",
|
||||
"svelte": "^5.16.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^3.1.9",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@ddietr/codemirror-themes": "^1.4.4",
|
||||
"astro": "^4.16.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-pagefind": "^1.6.0",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.6",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.18.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.3",
|
||||
"flowbite-svelte-icons": "^2.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte-awesome": "^3.3.5",
|
||||
"svelte-codemirror-editor": "^1.4.1",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^7.1.0",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.488.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bits-ui": "1.3.4",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"cssnano": "^7.0.7",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"formsnap": "1.0.1",
|
||||
"lucide-svelte": "^0.476.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.89.0",
|
||||
"svelte": "^5.33.4",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vaul-svelte": "^0.3.2",
|
||||
"zod": "^3.25.31"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/view": "^6.36.8",
|
||||
"@ddietr/codemirror-themes": "^1.5.1",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"astro": "5.7.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.20.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.4",
|
||||
"flowbite-svelte-icons": "^2.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"qs": "^6.14.0",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte-awesome": "^3.3.5",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"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">
|
||||
import {twMerge} from "tailwind-merge";
|
||||
import {onMount} from "svelte";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let cardElement: HTMLDivElement = $state();
|
||||
|
||||
function rotateElement(event: MouseEvent) {
|
||||
if(!hoverEffect) return;
|
||||
if (!hoverEffect) return;
|
||||
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
@ -36,23 +36,23 @@
|
||||
const rotateX = (centerY - y) / 20;
|
||||
const rotateY = -(centerX - x) / 20;
|
||||
|
||||
cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
|
||||
cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
|
||||
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`);
|
||||
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`);
|
||||
}
|
||||
|
||||
function resetElement() {
|
||||
cardElement.style.setProperty('--rotate-x', "0");
|
||||
cardElement.style.setProperty('--rotate-y', "0");
|
||||
cardElement.style.setProperty("--rotate-x", "0");
|
||||
cardElement.style.setProperty("--rotate-y", "0");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hoverEffect?: boolean;
|
||||
extraClasses?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
interface Props {
|
||||
hoverEffect?: boolean;
|
||||
extraClasses?: string;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { hoverEffect = true, extraClasses = '', children }: Props = $props();
|
||||
let classes = $derived(twMerge("w-72 border-2 bg-zinc-50 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg dark:bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses))
|
||||
let { hoverEffect = true, extraClasses = "", children }: Props = $props();
|
||||
let classes = $derived(twMerge("w-72 border-2 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses));
|
||||
</script>
|
||||
|
||||
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
|
||||
@ -61,20 +61,20 @@
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
||||
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
|
||||
|
||||
transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
|
||||
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6);
|
||||
|
||||
:global(h1) {
|
||||
@apply text-xl font-bold mt-4;
|
||||
}
|
||||
:global(h1) {
|
||||
@apply text-xl font-bold mt-4;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl
|
||||
}
|
||||
:global(svg) {
|
||||
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.hoverEffect:hover {
|
||||
scale: 105%;
|
||||
scale: 105%;
|
||||
}
|
||||
</style>
|
||||
@ -20,6 +20,7 @@
|
||||
<script lang="ts">
|
||||
import {t} from "astro-i18n";
|
||||
import {statsRepo} from "@repo/stats.ts";
|
||||
import "@styles/table.css"
|
||||
|
||||
|
||||
interface Props {
|
||||
@ -64,7 +65,3 @@
|
||||
<p>{error.message}</p>
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
@import "../styles/table.css";
|
||||
</style>
|
||||
|
||||
|
||||
@ -19,8 +19,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import FightStatsChart from "./FightStatsChart.svelte";
|
||||
import {t} from "astro-i18n";
|
||||
import {statsRepo} from "@repo/stats.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import { statsRepo } from "@repo/stats.ts";
|
||||
|
||||
let request = getStats();
|
||||
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
})
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
@ -105,5 +107,5 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
<canvas height="500" bind:this={canvas}></canvas>
|
||||
</div>
|
||||
@ -19,25 +19,27 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./util.ts";
|
||||
import {astroI18n, t} from "astro-i18n";
|
||||
import type {EventFight, ExtendedEvent} from "@type/event";
|
||||
import { window } from "./utils.ts";
|
||||
import { astroI18n, t } from "astro-i18n";
|
||||
import type { EventFight, ExtendedEvent } from "@type/event";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
|
||||
function getWinner(fight: EventFight) {
|
||||
if (!fight.hasFinished) {
|
||||
return t("announcements.table.notPlayed");
|
||||
}
|
||||
|
||||
switch (fight.ergebnis) {
|
||||
case 1:
|
||||
return fight.blueTeam.kuerzel;
|
||||
case 2:
|
||||
return fight.redTeam.kuerzel;
|
||||
case 3:
|
||||
return t("announcements.table.draw");
|
||||
default:
|
||||
return t("announcements.table.notPlayed");
|
||||
return t("announcements.table.draw");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -55,13 +57,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each window(event.fights.filter(f => f.group === group), rows) as fights}
|
||||
{#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows ) as fights}
|
||||
<tr>
|
||||
{#each fights as fight (fight.id)}
|
||||
<td>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(new Date(fight.start))}</td>
|
||||
<td
|
||||
>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(new Date(fight.start))}</td
|
||||
>
|
||||
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
|
||||
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
|
||||
<td>{getWinner(fight)}</td>
|
||||
|
||||
@ -19,33 +19,40 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./util.ts";
|
||||
import {t} from "astro-i18n";
|
||||
import type {ExtendedEvent} from "@type/event.ts";
|
||||
import "@styles/table.css"
|
||||
import { window } from "./utils.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
|
||||
$: teamPoints = event.teams.map(team => {
|
||||
const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 3) {
|
||||
return acc + 1;
|
||||
} else {
|
||||
return acc;
|
||||
$: teamPoints = event.teams
|
||||
.map((team) => {
|
||||
let fights = event.fights.filter((fight) => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
|
||||
if (group !== undefined) {
|
||||
fights = fights.filter((fight) => fight.group?.id === group);
|
||||
}
|
||||
}, 0);
|
||||
return {
|
||||
team,
|
||||
points,
|
||||
};
|
||||
}).sort((a, b) => b.points - a.points);
|
||||
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + (fight.group?.pointsPerWin ?? 3);
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
return acc + (fight.group?.pointsPerWin ?? 3);
|
||||
} else if (fight.ergebnis === 3) {
|
||||
return acc + (fight.group?.pointsPerDraw ?? 1);
|
||||
} else {
|
||||
return acc + (fight.group?.pointsPerLoss ?? 0);
|
||||
}
|
||||
}, 0);
|
||||
return {
|
||||
team,
|
||||
points,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.points - a.points);
|
||||
</script>
|
||||
|
||||
<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("");
|
||||
|
||||
async function login() {
|
||||
let {tokenStore} = await import("./repo/repo.ts");
|
||||
let {authRepo} = await import("./repo/auth.ts");
|
||||
let {authV2Repo} = await import("./repo/authv2.ts");
|
||||
if (username === "" || pw === "") {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
@ -40,15 +39,14 @@
|
||||
}
|
||||
|
||||
try {
|
||||
let auth = await get(authRepo).login(username, pw);
|
||||
if (auth == undefined) {
|
||||
let auth = await get(authV2Repo).login(username, pw);
|
||||
if (!auth) {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
return;
|
||||
}
|
||||
|
||||
tokenStore.set(auth);
|
||||
navigate(l("/dashboard"));
|
||||
await navigate(l("/dashboard"));
|
||||
} catch (e: any) {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
@ -75,9 +73,7 @@
|
||||
|
||||
<style lang="postcss">
|
||||
input {
|
||||
@apply border-2 rounded-md p-2 shadow-2xl w-80
|
||||
dark:bg-neutral-800
|
||||
focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent;
|
||||
@apply border-2 rounded-md p-2 shadow-2xl w-80 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent text-black;
|
||||
}
|
||||
|
||||
label {
|
||||
|
||||
@ -18,133 +18,96 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import "../styles/button.css";
|
||||
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons";
|
||||
import { t } from "astro-i18n";
|
||||
import { l } from "../util/util";
|
||||
import { onMount } from "svelte";
|
||||
import { loggedIn } from "@repo/authv2.ts";
|
||||
interface Props {
|
||||
logo?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { logo }: Props = $props();
|
||||
|
||||
let navbar = $state<HTMLDivElement>();
|
||||
let searchOpen = $state(false);
|
||||
|
||||
let accountBtn = $state<HTMLAnchorElement>();
|
||||
|
||||
$effect(() => {
|
||||
if ($loggedIn) {
|
||||
accountBtn!.href = l("/dashboard");
|
||||
} else {
|
||||
accountBtn!.href = l("/login");
|
||||
import "../styles/button.css";
|
||||
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons";
|
||||
import { t } from "astro-i18n";
|
||||
import { l } from "../util/util";
|
||||
import { onMount } from "svelte";
|
||||
import { loggedIn } from "@repo/authv2.ts";
|
||||
interface Props {
|
||||
logo?: import("svelte").Snippet;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
handleScroll();
|
||||
});
|
||||
let { logo }: Props = $props();
|
||||
|
||||
function handleScroll() {
|
||||
if (window.scrollY > 0) {
|
||||
navbar!.classList.add("before:scale-y-100");
|
||||
} else {
|
||||
navbar!.classList.remove("before:scale-y-100");
|
||||
let navbar = $state<HTMLElement>();
|
||||
let searchOpen = $state(false);
|
||||
|
||||
let accountBtn = $state<HTMLAnchorElement>();
|
||||
|
||||
$effect(() => {
|
||||
if ($loggedIn) {
|
||||
accountBtn!.href = l("/dashboard");
|
||||
} else {
|
||||
accountBtn!.href = l("/login");
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
if (window.scrollY > 0) {
|
||||
navbar!.classList.add("before:scale-y-100");
|
||||
} else {
|
||||
navbar!.classList.remove("before:scale-y-100");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={handleScroll} />
|
||||
|
||||
<nav
|
||||
data-pagefind-ignore
|
||||
class="fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors z-10 flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top"
|
||||
bind:this={navbar}
|
||||
data-pagefind-ignore
|
||||
class="z-20 fixed top-0 left-0 right-0 sm:px-4 py-1 transition-colors flex justify-center before:backdrop-blur before:shadow-2xl before:absolute before:top-0 before:left-0 before:bottom-0 before:right-0 before:-z-10 before:scale-y-0 before:transition-transform before:origin-top"
|
||||
bind:this={navbar}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center justify-evenly md:justify-between match"
|
||||
>
|
||||
<a class="flex items-center" href={l("/")}>
|
||||
{@render logo?.()}
|
||||
<span
|
||||
class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block"
|
||||
>
|
||||
{t("navbar.title")}
|
||||
<span
|
||||
class="before:scale-y-100"
|
||||
style="display: none"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<div class="btn-dropdown">
|
||||
<button class="btn btn-gray">
|
||||
<a href={l("/")}>
|
||||
<span class="btn__text">{t("navbar.links.home.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a class="btn btn-gray" href={l("/announcements")}
|
||||
>{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("/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 class="btn-dropdown">
|
||||
<button class="btn btn-gray">
|
||||
<a rel="prefetch" href={l("/rules")}>
|
||||
<span class="btn__text">{t("navbar.links.rules.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a href={l("/rules/wargear")} class="btn btn-gray"
|
||||
>{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/airship")} class="btn btn-gray"
|
||||
>{t("navbar.links.rules.as")}</a
|
||||
>
|
||||
<a href={l("/rules/quickgear")} class="btn btn-gray"
|
||||
>{t("navbar.links.rules.qg")}</a
|
||||
>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
|
||||
<a href={l("/rules/megawargear")} class="btn btn-gray"
|
||||
>{t("navbar.links.rules.megawg")}</a
|
||||
>
|
||||
<a href={l("/rules/microwargear")} class="btn btn-gray"
|
||||
>{t("navbar.links.rules.micro")}</a
|
||||
>
|
||||
<a href={l("/rules/streetfight")} class="btn btn-gray"
|
||||
>{t("navbar.links.rules.sf")}</a
|
||||
>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
|
||||
<a href={l("/rangliste/missilewars")} class="btn btn-gray"
|
||||
>{t("navbar.links.ranked.mw")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Add help center
|
||||
<div class="flex flex-row items-center justify-evenly md:justify-between match">
|
||||
<a class="flex items-center" href={l("/")}>
|
||||
{@render logo?.()}
|
||||
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block">
|
||||
{t("navbar.title")}
|
||||
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<div class="btn-dropdown">
|
||||
<button class="btn btn-gray">
|
||||
<a href={l("/")}>
|
||||
<span class="btn__text">{t("navbar.links.home.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a class="btn btn-gray" href={l("/announcements")}>{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("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-dropdown">
|
||||
<button class="btn btn-gray">
|
||||
<a rel="prefetch" href={l("/rules")}>
|
||||
<span class="btn__text">{t("navbar.links.rules.title")}</span>
|
||||
</a>
|
||||
<CaretDownOutline class="ml-2 mt-auto" />
|
||||
</button>
|
||||
<div>
|
||||
<a href={l("/rules/wargear")} class="btn btn-gray">{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/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a>
|
||||
<a href={l("/rules/quickgear")} class="btn btn-gray">{t("navbar.links.rules.qg")}</a>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.rotating")}</h2>
|
||||
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</a>
|
||||
<a href={l("/rules/microwargear")} class="btn btn-gray">{t("navbar.links.rules.micro")}</a>
|
||||
<a href={l("/rules/streetfight")} class="btn btn-gray">{t("navbar.links.rules.sf")}</a>
|
||||
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2>
|
||||
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Add help center
|
||||
<div class="btn-dropdown my-1">
|
||||
<div class="btn btn-gray" tabindex="1">
|
||||
<a rel="prefetch">
|
||||
@ -158,26 +121,26 @@
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
<a class="btn" href={l("/login")} bind:this={accountBtn}>
|
||||
<span class="btn__text">{t("navbar.links.account")}</span>
|
||||
</a>
|
||||
<!--
|
||||
<a class="btn" href={l("/login")} bind:this={accountBtn}>
|
||||
<span class="btn__text">{t("navbar.links.account")}</span>
|
||||
</a>
|
||||
<!--
|
||||
<button class="btn my-1" onclick={() => searchOpen = true}>
|
||||
<SearchOutline ariaLabel="Site Search" class="inline-block h-6"/>
|
||||
</button>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{#if searchOpen}
|
||||
{#await import("./SearchComponent.svelte") then c}
|
||||
<c.default bind:open={searchOpen} />
|
||||
{/await}
|
||||
{#await import("./SearchComponent.svelte") then c}
|
||||
<c.default bind:open={searchOpen} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.match {
|
||||
width: min(100vw, 70em);
|
||||
}
|
||||
.match {
|
||||
width: min(100vw, 70em);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,49 +1,59 @@
|
||||
---
|
||||
import {CollectionEntry} from "astro:content";
|
||||
import {l} from "../util/util";
|
||||
import {astroI18n} from "astro-i18n";
|
||||
import {Image} from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { l } from "../util/util";
|
||||
import { astroI18n } from "astro-i18n";
|
||||
import { Image } from "astro:assets";
|
||||
import TagComponent from "./TagComponent.astro";
|
||||
import P from "./P.astro";
|
||||
import Card from "@components/Card.svelte";
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"announcements">
|
||||
post: CollectionEntry<"announcements">;
|
||||
}
|
||||
|
||||
const { post, slim }: {
|
||||
post: CollectionEntry<"announcements">,
|
||||
slim: boolean,
|
||||
const {
|
||||
post,
|
||||
slim,
|
||||
}: {
|
||||
post: CollectionEntry<"announcements">;
|
||||
slim: boolean;
|
||||
} = Astro.props as Props;
|
||||
|
||||
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
|
||||
---
|
||||
|
||||
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
|
||||
<div class={`flex flex-row ${slim ? "":"p-4"}`}>
|
||||
{post.data.image != null
|
||||
? (
|
||||
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}>
|
||||
<div class={`flex flex-row ${slim ? "" : "p-4"}`}>
|
||||
{
|
||||
post.data.image != null ? (
|
||||
<a href={postUrl}>
|
||||
<div class="flex-shrink-0 pr-2">
|
||||
<Image transition:name={post.data.title + "-image"} src={post.data.image} alt="Post Image" class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105" />
|
||||
<Image
|
||||
transition:name={post.data.title + "-image"}
|
||||
src={post.data.image}
|
||||
alt="Post Image"
|
||||
class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
) : null
|
||||
}
|
||||
<div>
|
||||
<a href={postUrl} class="flex flex-col items-start">
|
||||
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
|
||||
<P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)}</P>
|
||||
<P class="text-gray-500"
|
||||
>{
|
||||
Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)
|
||||
}</P
|
||||
>
|
||||
<P>{post.data.description}</P>
|
||||
</a>
|
||||
<div class="mt-1" transition:name={post.data.title + "-tags"}>
|
||||
{post.data.tags.map((tag) => (
|
||||
<TagComponent tag={tag} />
|
||||
))}
|
||||
{post.data.tags.map((tag) => <TagComponent tag={tag} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,38 +22,40 @@
|
||||
import wrap from "svelte-spa-router/wrap";
|
||||
import Router, {replace} from "svelte-spa-router";
|
||||
import {get} from "svelte/store";
|
||||
import {tokenStore} from "@repo/repo";
|
||||
import {loggedIn} from "@repo/authv2.ts";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(tokenStore) != ""}),
|
||||
"/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(loggedIn)}),
|
||||
"/perms": wrap({
|
||||
asyncComponent: () => import("./pages/Perms.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/login": wrap({
|
||||
asyncComponent: () => import("./pages/Login.svelte"),
|
||||
conditions: detail => get(tokenStore) == ""
|
||||
conditions: detail => !get(loggedIn)
|
||||
}),
|
||||
"/event/:id": wrap({
|
||||
asyncComponent: () => import("./pages/Event.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/event/:id/generate": wrap({
|
||||
asyncComponent: () => import("./pages/Generate.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/edit": wrap({
|
||||
asyncComponent: () => import("./pages/Edit.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"/display/:event": wrap({
|
||||
asyncComponent: () => import("./pages/Display.svelte"),
|
||||
conditions: detail => get(tokenStore) != ""
|
||||
conditions: detail => get(loggedIn)
|
||||
}),
|
||||
"*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")})
|
||||
};
|
||||
|
||||
function conditionsFailed(event: ConditionsFailedEvent) {
|
||||
console.log(event)
|
||||
|
||||
if (event.detail.location === "/login") {
|
||||
replace("/");
|
||||
} else {
|
||||
|
||||
@ -168,11 +168,11 @@
|
||||
</div>
|
||||
<div>
|
||||
{#if selectedBranch !== "master"}
|
||||
<Button onclick={createFile} color="alternative" disabled={!selectedPath}>Create File
|
||||
<Button onclick={() => createFile()} color="alternative" disabled={!selectedPath}>Create File
|
||||
</Button>
|
||||
<Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button>
|
||||
{:else}
|
||||
<Button onclick={createBranch}>Create Branch</Button>
|
||||
<Button onclick={() => createBranch()}>Create Branch</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
</NavBrand>
|
||||
<NavHamburger onclick={toggle}/>
|
||||
<NavUl {hidden}>
|
||||
<NavLi href="/admin/new">New UI</NavLi>
|
||||
<NavLi href="#/edit">Edit Pages</NavLi>
|
||||
<NavLi href="#/perms">Permissions</NavLi>
|
||||
</NavUl>
|
||||
|
||||
@ -18,23 +18,22 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte";
|
||||
import {json} from "@codemirror/lang-json";
|
||||
import CodeMirror from "svelte-codemirror-editor";
|
||||
import {base64ToBytes} from "../../util.ts";
|
||||
import type {Page} from "@type/page.ts";
|
||||
import {materialDark} from "@ddietr/codemirror-themes/material-dark";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { base64ToBytes } from "../../util.ts";
|
||||
import type { Page } from "@type/page.ts";
|
||||
import { materialDark } from "@ddietr/codemirror-themes/material-dark";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
let { pageId, branch, dirty = $bindable(false) }: Props = $props();
|
||||
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
|
||||
|
||||
let dispatcher = createEventDispatcher();
|
||||
|
||||
@ -71,34 +70,31 @@
|
||||
}
|
||||
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
|
||||
</script>
|
||||
<svelte:window onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}/>
|
||||
|
||||
<svelte:window
|
||||
onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#await pageFuture}
|
||||
<Spinner/>
|
||||
<Spinner />
|
||||
{:then p}
|
||||
<div>
|
||||
<div>
|
||||
<Toolbar class="!bg-gray-900">
|
||||
{#snippet end()}
|
||||
<ToolbarGroup >
|
||||
<ToolbarButton onclick={deletePage}>
|
||||
Delete
|
||||
</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>
|
||||
Save
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton onclick={deletePage}>Delete</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
</div>
|
||||
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty/>
|
||||
{:else}
|
||||
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} on:change={() => dirty = true}/>
|
||||
{/if}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty />
|
||||
{:else}{/if}
|
||||
</div>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
let request = getRequest();
|
||||
|
||||
function getRequest() {
|
||||
return $statsRepo.getUserStats(user.id)
|
||||
return $statsRepo.getUserStats(user.uuid)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -43,8 +43,5 @@
|
||||
maximumFractionDigits: 2
|
||||
}).format(data.playtime)})}h</p>
|
||||
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
|
||||
{#if user.perms.includes("CHECK")}
|
||||
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
|
||||
{/await}
|
||||
@ -21,19 +21,21 @@
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {schemRepo} from "@repo/schem.ts";
|
||||
import SWModal from "@components/styled/SWModal.svelte";
|
||||
import {t} from "astro-i18n"
|
||||
import {t} from "astro-i18n";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
let {open = $bindable(false)}: Props = $props();
|
||||
|
||||
async function upload() {
|
||||
async function upload(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (uploadFile == null) {
|
||||
return
|
||||
error = "dashboard.schematic.errors.noFile";
|
||||
return;
|
||||
}
|
||||
let file = uploadFile[0];
|
||||
|
||||
@ -42,32 +44,46 @@
|
||||
let type = name.split(".").pop();
|
||||
|
||||
if (type !== "schem" && type !== "schematic") {
|
||||
return
|
||||
error = "dashboard.schematic.errors.invalidEnding";
|
||||
return;
|
||||
}
|
||||
|
||||
let content = await file.arrayBuffer();
|
||||
|
||||
// @ts-ignore
|
||||
let b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(content)));
|
||||
|
||||
await $schemRepo.uploadSchematic(name, b64);
|
||||
try {
|
||||
await $schemRepo.uploadSchematic(name, b64);
|
||||
|
||||
open = false;
|
||||
uploadFile = null;
|
||||
dispatch("reset")
|
||||
open = false;
|
||||
value = "";
|
||||
dispatch("reset");
|
||||
} catch (e) {
|
||||
error = "dashboard.schematic.errors.upload";
|
||||
}
|
||||
}
|
||||
|
||||
function reset(e: Event) {
|
||||
e.stopPropagation();
|
||||
open = false
|
||||
value = "";
|
||||
}
|
||||
|
||||
let uploadFile: FileList | null = $state(null);
|
||||
let value = $state("");
|
||||
let error = $state(null)
|
||||
</script>
|
||||
|
||||
<SWModal title={t("dashboard.schematic.title")} bind:open>
|
||||
<form>
|
||||
<input type="file" bind:files={uploadFile} />
|
||||
<label for="schem-upload">{t("dashboard.schematic.title")}</label>
|
||||
<input type="file" id="schem-upload" bind:files={uploadFile} class="overflow-ellipsis" bind:value accept=".schem, .schematic"/>
|
||||
{#if error !== null}
|
||||
<p class="text-red-400">{t(error)}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
|
||||
<button class="btn !ml-auto" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
||||
<button class="btn btn-gray" onclick={() => open = false}>{t("dashboard.schematic.cancel")}</button>
|
||||
|
||||
{/snippet}
|
||||
<button class="btn" onclick={upload}>{t("dashboard.schematic.upload")}</button>
|
||||
<button class="btn btn-gray" onclick={reset}>{t("dashboard.schematic.cancel")}</button>
|
||||
{/snippet}
|
||||
</SWModal>
|
||||
@ -18,24 +18,23 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {t} from "astro-i18n";
|
||||
import type {Player} from "@type/data.ts";
|
||||
import {l} from "@utils/util.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { Player } from "@type/data.ts";
|
||||
import { l } from "@utils/util.ts";
|
||||
import Statistics from "./Statistics.svelte";
|
||||
import {authRepo} from "@repo/auth.ts";
|
||||
import {tokenStore} from "@repo/repo.ts";
|
||||
import { authV2Repo } from "@repo/authv2.ts";
|
||||
import Card from "@components/Card.svelte";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user }: Props = $props();
|
||||
|
||||
async function logout() {
|
||||
await $authRepo.logout()
|
||||
tokenStore.set("")
|
||||
window.location.href = l("/login")
|
||||
await $authV2Repo.logout();
|
||||
await navigate(l("/login"));
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -44,19 +43,25 @@
|
||||
<Card>
|
||||
<figure>
|
||||
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
|
||||
<img src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`} class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl" alt={user.name + "s bust"} width="150" height="150" />
|
||||
<img
|
||||
src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`}
|
||||
class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl"
|
||||
alt={user.name + "s bust"}
|
||||
width="150"
|
||||
height="150"
|
||||
/>
|
||||
</figure>
|
||||
</Card>
|
||||
<div class="flex flex-wrap">
|
||||
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
|
||||
{#if user.perms.includes("MODERATION")}
|
||||
<a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a>
|
||||
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1>
|
||||
<p>{t("dashboard.rank", {rank: t("home.prefix." + user.prefix)})}</p>
|
||||
<h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1>
|
||||
<p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p>
|
||||
<Statistics {user} />
|
||||
</div>
|
||||
</div>
|
||||
54
src/components/moderator/App.svelte
Normal file
54
src/components/moderator/App.svelte
Normal file
@ -0,0 +1,54 @@
|
||||
<!--
|
||||
- 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 { Switch } from "@components/ui/switch";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import Players from "@components/moderator/pages/players/Players.svelte";
|
||||
import 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";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": Dashboard,
|
||||
"/events": Events,
|
||||
"/players": Players,
|
||||
"/event/:id": Event,
|
||||
"/pages": Pages,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col bg-background min-w-full min-h-screen">
|
||||
<div class="border-b">
|
||||
<div class="flex h-16 items-center px-4">
|
||||
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
|
||||
<NavLinks />
|
||||
<div class="ml-auto flex items-center space-x-4">
|
||||
<Switch id="new-ui-switch" checked={true} onclick={() => navigate("/admin")} />
|
||||
<Label for="new-ui-switch">New UI!</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Router {routes} />
|
||||
</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>
|
||||
298
src/components/moderator/components/FightEdit.svelte
Normal file
298
src/components/moderator/components/FightEdit.svelte
Normal file
@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import GroupSelector from "./GroupSelector.svelte";
|
||||
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, 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 type { Team } from "@components/types/team";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import type { Snippet } from "svelte";
|
||||
import { Input } from "@components/ui/input";
|
||||
|
||||
let {
|
||||
fight,
|
||||
teams,
|
||||
event,
|
||||
actions,
|
||||
onSave,
|
||||
groups = $bindable(),
|
||||
}: {
|
||||
fight: EventFight | null;
|
||||
teams: Team[];
|
||||
event: SWEvent;
|
||||
groups: ResponseGroups[];
|
||||
actions: Snippet<[boolean, () => void]>;
|
||||
onSave: (fight: EventFightEdit) => void;
|
||||
} = $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(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 selectedGroup = $derived(groups.find((group) => group.id === fightGroup));
|
||||
|
||||
let mapsStore = $derived(maps(fightModus ?? "null"));
|
||||
let gamemodeSelectOpen = $state(false);
|
||||
let mapSelectOpen = $state(false);
|
||||
let blueTeamSelectOpen = $state(false);
|
||||
let redTeamSelectOpen = $state(false);
|
||||
|
||||
let createOpen = $state(false);
|
||||
let groupSelectOpen = $state(false);
|
||||
|
||||
let dirty = $derived(
|
||||
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 for="fight-blue-team">Blue Team</Label>
|
||||
<Popover bind:open={blueTeamSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{teams.find((value) => value.id === fightBlueTeam?.id)?.name || fightBlueTeam?.name || "Select a team..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = team;
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== fightBlueTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label for="fight-red-team">Red Team</Label>
|
||||
<Popover bind:open={redTeamSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{teams.find((value) => value.id === fightRedTeam?.id)?.name || fightRedTeam?.name || "Select a team..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
fightRedTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
fightRedTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
fightRedTeam = team;
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== fightRedTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label>Start</Label>
|
||||
<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} bind:value={fightGroup} bind: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.toString(), 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>
|
||||
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>
|
||||
319
src/components/moderator/pages/event/EventFightList.svelte
Normal file
319
src/components/moderator/pages/event/EventFightList.svelte
Normal file
@ -0,0 +1,319 @@
|
||||
<!--
|
||||
- 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 { EventFight, EventFightEdit, ExtendedEvent } 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 { Team } from "@components/types/team";
|
||||
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} teams={data.teams} event={data.event} groups={data.groups} 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>
|
||||
<MenubarItem disabled>Gruppenphase</MenubarItem>
|
||||
<MenubarItem disabled>K.O. Phase</MenubarItem>
|
||||
</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">
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group)}>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group)}>
|
||||
<GroupIcon />
|
||||
</Button>
|
||||
<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}
|
||||
teams={data.teams}
|
||||
bind:groups={data.groups}
|
||||
event={data.event}
|
||||
onupdate={(update) => (data.fights = data.fights.map((v) => (v.id === update.id ? update : v)))}
|
||||
></FightEditRow>
|
||||
</TableCell>
|
||||
</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>
|
||||
49
src/components/moderator/pages/event/FightEditRow.svelte
Normal file
49
src/components/moderator/pages/event/FightEditRow.svelte
Normal file
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { EditIcon, MenuIcon, GroupIcon } 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";
|
||||
|
||||
let { fight, teams, groups = $bindable(), event, onupdate }: { fight: EventFight; teams: Team[]; groups: ResponseGroups[]; event: SWEvent; onupdate: (update: EventFight) => void } = $props();
|
||||
|
||||
let editOpen = $state(false);
|
||||
|
||||
async function handleSave(fightData: EventFightEdit) {
|
||||
let f = await $fightRepo.updateFight(event.id, fight.id, {
|
||||
...fightData,
|
||||
blueTeam: fightData.blueTeam.id,
|
||||
redTeam: fightData.redTeam.id,
|
||||
group: fightData.group ?? -1,
|
||||
});
|
||||
|
||||
onupdate(f);
|
||||
|
||||
editOpen = 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} {teams} bind:groups {event} onSave={handleSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button disabled={!dirty} 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.includes(playerSearch))
|
||||
.filter((v, i) => i < 50)
|
||||
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
|
||||
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Table>
|
||||
93
src/components/moderator/pages/event/TeamTable.svelte
Normal file
93
src/components/moderator/pages/event/TeamTable.svelte
Normal file
@ -0,0 +1,93 @@
|
||||
<!--
|
||||
- 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 type { ExtendedEvent } from "@type/event.ts";
|
||||
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 { Team } from "@components/types/team";
|
||||
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 { EventFight } from "@type/event.ts";
|
||||
|
||||
export const columns: ColumnDef<EventFight> = [
|
||||
{
|
||||
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.date) > 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.name + " vs " + r.redTeam.name,
|
||||
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";
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
21
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal file
21
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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";
|
||||
|
||||
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([]);
|
||||
|
||||
constructor(data: ExtendedEvent) {
|
||||
this.event = data.event;
|
||||
this.teams = data.teams;
|
||||
this.groups = data.groups;
|
||||
this.fights = data.fights;
|
||||
this.referees = data.referees;
|
||||
this.relations = data.relations;
|
||||
}
|
||||
}
|
||||
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>
|
||||
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}
|
||||
228
src/components/moderator/pages/pages/page.svelte.ts
Normal file
228
src/components/moderator/pages/pages/page.svelte.ts
Normal file
@ -0,0 +1,228 @@
|
||||
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;
|
||||
const encodedContent = btoa(new TextEncoder().encode(contentToSave).reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||
|
||||
console.log(encodedContent);
|
||||
//await get(pageRepo).updatePage(this.pageId, this.sha, encodedContent, this.manager.branch);
|
||||
this.dirty = false;
|
||||
this.manager.reloadImages();
|
||||
}
|
||||
|
||||
public focus(): boolean {
|
||||
let index = this.manager.pages.indexOf(this);
|
||||
|
||||
if (index === this.manager.openPageIndex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.manager.openPageIndex = this.manager.pages.indexOf(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
|
||||
const lines = content.split("\n");
|
||||
let inFrontmatter = false;
|
||||
const frontmatterLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "---") {
|
||||
if (inFrontmatter) {
|
||||
break; // End of frontmatter
|
||||
}
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) {
|
||||
frontmatterLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatterLines.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml
|
||||
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
|
||||
} catch (error) {
|
||||
console.error("Failed to parse YAML frontmatter:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private removeFrontmatter(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
let inFrontmatter = false;
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "---") {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
continue;
|
||||
}
|
||||
if (!inFrontmatter) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n").trim();
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirTree {
|
||||
name: string;
|
||||
dirs: { [key: string]: DirTree };
|
||||
files: { [key: string]: ListPage };
|
||||
}
|
||||
|
||||
export class PageManager {
|
||||
public reloadImages() {
|
||||
this.updater = this.updater + 1;
|
||||
}
|
||||
public branch: string = $state("master");
|
||||
public pages: OpenEditPage[] = $state([]);
|
||||
public branches: string[] = $state([]);
|
||||
|
||||
constructor() {
|
||||
this.reloadBranches();
|
||||
}
|
||||
|
||||
public reloadBranches() {
|
||||
get(pageRepo)
|
||||
.getBranches()
|
||||
.then((branches) => {
|
||||
this.branches = branches;
|
||||
});
|
||||
}
|
||||
|
||||
private updater = $state(0);
|
||||
|
||||
public openPageIndex: number = $state(-1);
|
||||
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater)));
|
||||
public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
|
||||
|
||||
private _t<T>(n: number): (v: T) => T {
|
||||
return (v: T) => v;
|
||||
}
|
||||
|
||||
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
|
||||
|
||||
private convertToTree(pages: PageList): DirTree {
|
||||
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
|
||||
|
||||
pages.forEach((page) => {
|
||||
const pathParts = page.path.split("/").filter((part) => part !== "");
|
||||
let current = tree;
|
||||
|
||||
// Navigate/create directory structure
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const dir = pathParts[i];
|
||||
if (!current.dirs[dir]) {
|
||||
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
|
||||
}
|
||||
current = current.dirs[dir];
|
||||
}
|
||||
|
||||
// Add file to the final directory
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
current.files[fileName] = page;
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
public async openPage(pageId: number) {
|
||||
const existingPage = this.existingPage(pageId);
|
||||
if (existingPage) {
|
||||
existingPage.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let r = await get(pageRepo).getPage(pageId, this.branch);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
|
||||
this.pages.push(newPage);
|
||||
newPage.focus();
|
||||
}
|
||||
|
||||
public existingPage(pageId: number): OpenEditPage | undefined {
|
||||
return this.pages.find((page) => page.pageId === pageId);
|
||||
}
|
||||
|
||||
public closePage(index: number) {
|
||||
if (index < 0 || index >= this.pages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = this.pages[index];
|
||||
if (page.dirty) {
|
||||
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.pages.splice(index, 1);
|
||||
if (this.openPageIndex >= index) {
|
||||
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
|
||||
}
|
||||
|
||||
if (this.openPageIndex < 0 && this.pages.length > 0) {
|
||||
this.openPageIndex = 0;
|
||||
}
|
||||
|
||||
if (this.pages.length === 0) {
|
||||
this.openPageIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public async createPage(path: string, newPageName: string): Promise<void> {
|
||||
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
|
||||
this.branch = this.branch;
|
||||
}
|
||||
|
||||
public anyUnsavedChanges() {
|
||||
return this.pages.some((page) => page.dirty);
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = $state(new PageManager());
|
||||
@ -0,0 +1,56 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {permissions, players} from "@stores/stores.ts";
|
||||
import {Select, SelectContent, SelectItem} from "@components/ui/select";
|
||||
import {SelectTrigger} from "@components/ui/select/index.js";
|
||||
import {permsRepo} from "@repo/perms.ts";
|
||||
|
||||
const {
|
||||
perms, uuid
|
||||
}: { perms: string[], uuid: string } = $props();
|
||||
|
||||
let value = $state(perms);
|
||||
let prevValue = $state(perms);
|
||||
|
||||
function onChange(change: string[]) {
|
||||
$permissions.perms.forEach(perm => {
|
||||
if (prevValue.includes(perm) && !change.includes(perm)) {
|
||||
$permsRepo.removePerm(uuid, perm)
|
||||
} else if (!prevValue.includes(perm) && change.includes(perm)) {
|
||||
$permsRepo.addPerm(uuid, perm)
|
||||
}
|
||||
});
|
||||
|
||||
prevValue = change;
|
||||
value = change;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select type="multiple" bind:value onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
{value.length} Permissions
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each $permissions.perms as permission (permission)}
|
||||
<SelectItem value={permission}>{permission}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
32
src/components/moderator/pages/players/Players.svelte
Normal file
32
src/components/moderator/pages/players/Players.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script>
|
||||
import Table from "@components/moderator/pages/players/Table.svelte";
|
||||
|
||||
import {dataRepo} from "@repo/data";
|
||||
|
||||
let playersFuture = $state($dataRepo.getPlayers())
|
||||
</script>
|
||||
|
||||
{#await playersFuture}
|
||||
<p>Loading...</p>
|
||||
{:then players}
|
||||
<Table data={players} />
|
||||
{/await}
|
||||
47
src/components/moderator/pages/players/PrefixDropdown.svelte
Normal file
47
src/components/moderator/pages/players/PrefixDropdown.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
|
||||
import {permissions} from "@stores/stores.ts";
|
||||
import {permsRepo} from "@repo/perms.ts";
|
||||
|
||||
const {
|
||||
prefix, uuid
|
||||
}: { prefix: string, uuid: string } = $props();
|
||||
|
||||
let value = $state(prefix);
|
||||
|
||||
function onChange(change: string) {
|
||||
$permsRepo.setPrefix(uuid, change);
|
||||
|
||||
value = $permissions.prefixes[change].chatPrefix;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select type="single" bind:value onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
{value === "" ? "None" : value}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each Object.entries($permissions.prefixes) as prefix (prefix[1].name)}
|
||||
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
174
src/components/moderator/pages/players/Table.svelte
Normal file
174
src/components/moderator/pages/players/Table.svelte
Normal file
@ -0,0 +1,174 @@
|
||||
<!--
|
||||
- This file is a part of the SteamWar software.
|
||||
-
|
||||
- Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
getCoreRowModel, getFilteredRowModel,
|
||||
getPaginationRowModel, getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/table-core";
|
||||
import {
|
||||
createSvelteTable,
|
||||
FlexRender,
|
||||
} from "@components/ui/data-table/index";
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
|
||||
import {Button} from "@components/ui/button";
|
||||
import {Input} from "@components/ui/input";
|
||||
import {Select} from "@components/ui/select";
|
||||
import {SelectContent, SelectItem, SelectTrigger} from "@components/ui/select/index.js";
|
||||
import type {Player} from "@type/data";
|
||||
import { columns } from "./columns";
|
||||
|
||||
let { data }: { data: Player[] } = $props();
|
||||
|
||||
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 25 });
|
||||
let sorting = $state<SortingState>([]);
|
||||
let columnFilters = $state<ColumnFiltersState>([]);
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
state: {
|
||||
get pagination() {
|
||||
return pagination;
|
||||
},
|
||||
get sorting() {
|
||||
return sorting;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters;
|
||||
},
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
pagination = updater(pagination);
|
||||
} else {
|
||||
pagination = updater;
|
||||
}
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
sorting = updater(sorting);
|
||||
} else {
|
||||
sorting = updater;
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
columnFilters = updater(columnFilters);
|
||||
} else {
|
||||
columnFilters = updater;
|
||||
}
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border m-4">
|
||||
<div class="flex items-center p-4 border-b">
|
||||
<Input
|
||||
placeholder="Filter Players..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onchange={(e) => {
|
||||
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
oninput={(e) => {
|
||||
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<div class="flex items-center px-4">
|
||||
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => pagination = { pageSize: +e, pageIndex: 0 }}>
|
||||
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<TableRow>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<TableHead>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</TableHead>
|
||||
{/each}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<TableCell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</TableCell>
|
||||
{/each}
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={columns.length} class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="flex items-center justify-end space-x-2 p-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>{pagination.pageIndex + 1}/{table.getPageCount()}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
60
src/components/moderator/pages/players/columns.ts
Normal file
60
src/components/moderator/pages/players/columns.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ColumnDef} from "@tanstack/table-core";
|
||||
import type {Player} from "@type/data.ts";
|
||||
import { renderComponent } from "@components/ui/data-table";
|
||||
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
|
||||
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
|
||||
|
||||
export const columns: ColumnDef<Player[]> = [
|
||||
{
|
||||
accessorKey: "uuid",
|
||||
header: "UUID",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "prefix",
|
||||
header: "Prefix",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(
|
||||
PrefixDropdown, {
|
||||
prefix: row.getValue("prefix"),
|
||||
uuid: row.getValue("uuid"),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "perms",
|
||||
header: "Permissions",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(
|
||||
PermissionsDropdown,
|
||||
{
|
||||
perms: row.getValue("perms"),
|
||||
uuid: row.getValue("uuid"),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -22,7 +22,7 @@
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import {onDestroy, onMount} from "svelte";
|
||||
import { CollectionEntry } from "astro:content";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
interface Props {
|
||||
pub: CollectionEntry<"publics">;
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2023 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {derived} from "svelte/store";
|
||||
|
||||
export class AuthRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
|
||||
public async login(username: string, password: string): Promise<string> {
|
||||
return await fetchWithToken(this.token, "/auth/login", {
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
method: "POST",
|
||||
}).then(value => value.json()).then(value => value.token);
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await fetchWithToken(this.token, "/auth/tokens/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const authRepo = derived(tokenStore, ($token) => new AuthRepo($token));
|
||||
184
src/components/repo/authv2.ts
Normal file
184
src/components/repo/authv2.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readable, writable} from "svelte/store";
|
||||
import dayjs, {type Dayjs} from "dayjs";
|
||||
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
|
||||
|
||||
export class AuthV2Repo {
|
||||
private accessToken: string | undefined;
|
||||
private accessTokenExpires: Dayjs | undefined;
|
||||
private refreshToken: string | undefined;
|
||||
private refreshTokenExpires: Dayjs | undefined;
|
||||
|
||||
constructor() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accessToken = localStorage.getItem("sw-access-token") ?? undefined;
|
||||
if (this.accessToken) {
|
||||
this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? "");
|
||||
}
|
||||
|
||||
this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined;
|
||||
if (this.refreshToken) {
|
||||
loggedIn.set(true);
|
||||
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
async login(name: string, password: string) {
|
||||
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
|
||||
throw new Error("Already logged in");
|
||||
}
|
||||
|
||||
try {
|
||||
const login = await this.request("/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password,
|
||||
keepLoggedIn: true,
|
||||
}),
|
||||
}).then(value => value.json()).then(value => AuthTokenSchema.parse(value));
|
||||
|
||||
this.setLoginState(login);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.request("/auth", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
this.resetAccessToken();
|
||||
this.resetRefreshToken();
|
||||
}
|
||||
|
||||
private setLoginState(tokens: AuthToken) {
|
||||
this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires));
|
||||
this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires));
|
||||
loggedIn.set(true);
|
||||
}
|
||||
|
||||
private setAccessToken(token: string, expires: Dayjs) {
|
||||
this.accessToken = token;
|
||||
this.accessTokenExpires = expires;
|
||||
localStorage.setItem("sw-access-token", token);
|
||||
localStorage.setItem("sw-access-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetAccessToken() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.accessToken = undefined;
|
||||
this.accessTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-access-token");
|
||||
localStorage.removeItem("sw-access-token-expires");
|
||||
}
|
||||
|
||||
private setRefreshToken(token: string, expires: Dayjs) {
|
||||
this.refreshToken = token;
|
||||
this.refreshTokenExpires = expires;
|
||||
localStorage.setItem("sw-refresh-token", token);
|
||||
localStorage.setItem("sw-refresh-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetRefreshToken() {
|
||||
if (this.refreshToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.refreshToken = undefined;
|
||||
this.refreshTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-refresh-token");
|
||||
localStorage.removeItem("sw-refresh-token-expires");
|
||||
|
||||
loggedIn.set(false);
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.requestWithToken(this.refreshToken!, "/auth", {
|
||||
method: "PUT",
|
||||
}).then(value => {
|
||||
if (value.status === 401) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.json();
|
||||
}).then(value => AuthTokenSchema.parse(value));
|
||||
|
||||
this.setLoginState(response);
|
||||
}
|
||||
|
||||
async request(url: string, params: RequestInit = {}, retryCount: number = 0) {
|
||||
if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
return this.requestWithToken(this.accessToken ?? "", url, params, retryCount);
|
||||
}
|
||||
|
||||
private async requestWithToken(token: string, url: string, params: RequestInit = {}, retryCount: number = 0): Promise<Response> {
|
||||
if (retryCount >= 3) {
|
||||
throw new Error("Too many retries");
|
||||
}
|
||||
|
||||
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
|
||||
headers: {
|
||||
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
|
||||
"Content-Type": "application/json", ...params.headers,
|
||||
},
|
||||
})
|
||||
.then(async value => {
|
||||
if (value.status === 401 && url !== "/auth") {
|
||||
try {
|
||||
await this.refresh();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) { /* empty */ }
|
||||
|
||||
return this.request(url, params, retryCount + 1);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const loggedIn = writable(false);
|
||||
|
||||
export const authV2Repo = readable(new AuthV2Repo());
|
||||
@ -17,21 +17,37 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, Server} from "@type/data.ts";
|
||||
import {PlayerSchema, ServerSchema} from "@type/data.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {derived} from "svelte/store";
|
||||
import type { Player, Server } from "@type/data.ts";
|
||||
import { PlayerSchema, ServerSchema } from "@type/data.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { TeamSchema, type Team } from "@components/types/team.ts";
|
||||
|
||||
export class DataRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async getServer(): Promise<Server> {
|
||||
return await fetchWithToken(this.token, "/data/server").then(value => value.json()).then(ServerSchema.parse);
|
||||
return await fetchWithToken(this.token, "/data/server")
|
||||
.then((value) => value.json())
|
||||
.then(ServerSchema.parse);
|
||||
}
|
||||
|
||||
public async getMe(): Promise<Player> {
|
||||
return await fetchWithToken(this.token, "/data/me").then(value => value.json()).then(PlayerSchema.parse);
|
||||
return await fetchWithToken(this.token, "/data/me")
|
||||
.then((value) => value.json())
|
||||
.then(PlayerSchema.parse);
|
||||
}
|
||||
|
||||
public async getPlayers(): Promise<Player[]> {
|
||||
return await fetchWithToken(get(tokenStore), "/data/admin/users")
|
||||
.then((value) => value.json())
|
||||
.then(PlayerSchema.array().parse);
|
||||
}
|
||||
|
||||
public async getTeams(): Promise<Team[]> {
|
||||
return await fetchWithToken(get(tokenStore), "/data/admin/teams")
|
||||
.then((value) => value.json())
|
||||
.then(TeamSchema.array().parse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,12 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
|
||||
import {z} from "zod";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import {
|
||||
ExtendedEventSchema,
|
||||
ShortEventSchema,
|
||||
SWEventSchema,
|
||||
EventFightSchema,
|
||||
ResponseGroupsSchema,
|
||||
ResponseRelationSchema,
|
||||
ResponseTeamSchema,
|
||||
CreateEventGroupSchema,
|
||||
UpdateEventGroupSchema,
|
||||
CreateEventRelationSchema,
|
||||
UpdateEventRelationSchema,
|
||||
} from "@type/event.js";
|
||||
import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEventRelation } from "@type/event.js";
|
||||
import { z } from "zod";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { derived } from "svelte/store";
|
||||
import { ResponseUserSchema } from "@components/types/data";
|
||||
|
||||
export interface CreateEvent {
|
||||
name: string;
|
||||
@ -31,30 +45,36 @@ export interface CreateEvent {
|
||||
}
|
||||
|
||||
export interface UpdateEvent {
|
||||
name: string | null;
|
||||
start: Dayjs | null;
|
||||
end: Dayjs | null;
|
||||
deadline: Dayjs | null;
|
||||
maxTeamMembers: number | null;
|
||||
schemType: string | null;
|
||||
publicSchemsOnly: boolean | null;
|
||||
addReferee: string[] | null;
|
||||
removeReferee: string[] | null;
|
||||
name?: string | null;
|
||||
start?: Dayjs | number | null;
|
||||
end?: Dayjs | number | null;
|
||||
deadline?: Dayjs | number | null;
|
||||
maxTeamMembers?: number | null;
|
||||
schemType?: string | null;
|
||||
publicSchemsOnly?: boolean | null;
|
||||
addReferee?: string[] | null;
|
||||
removeReferee?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ResponseUser {
|
||||
name: string;
|
||||
uuid: string;
|
||||
prefix: string;
|
||||
perms: string[];
|
||||
}
|
||||
|
||||
export class EventRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listEvents(): Promise<ShortEvent[]> {
|
||||
return await fetchWithToken(this.token, "/events")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(ShortEventSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ShortEventSchema).parse(value));
|
||||
}
|
||||
|
||||
public async getEvent(id: string): Promise<ExtendedEvent> {
|
||||
return await fetchWithToken(this.token, `/events/${id}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(ExtendedEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -66,7 +86,8 @@ export class EventRepo {
|
||||
start: +event.start,
|
||||
end: +event.end,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -87,7 +108,8 @@ export class EventRepo {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -98,6 +120,154 @@ export class EventRepo {
|
||||
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Fights
|
||||
public async listFights(eventId: string): Promise<EventFight[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(EventFightSchema).parse(value));
|
||||
}
|
||||
public async createFight(eventId: string, fight: any): Promise<EventFight> {
|
||||
delete fight.ergebnis;
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fight),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Groups
|
||||
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseGroupsSchema).parse(value));
|
||||
}
|
||||
public async createGroup(eventId: string, 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: string): 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: string, 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: string, relationId: string, 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: string, relationId: string): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Teams
|
||||
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseTeamSchema).parse(value));
|
||||
}
|
||||
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(teams),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(teams),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Referees
|
||||
public async listReferees(eventId: string): Promise<ResponseUser[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseUserSchema).parse(value));
|
||||
}
|
||||
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(refereeUuids),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.status === 204;
|
||||
}
|
||||
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(refereeUuids),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return res.status === 204;
|
||||
}
|
||||
}
|
||||
|
||||
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EventFight} from "@type/event.js";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {z} from "zod";
|
||||
import {EventFightSchema} from "@type/event.js";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { EventFight } from "@type/event.js";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import { z } from "zod";
|
||||
import { EventFightSchema } from "@type/event.js";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export interface CreateFight {
|
||||
spielmodus: string;
|
||||
@ -39,23 +39,22 @@ export interface UpdateFight {
|
||||
map: string | null;
|
||||
blueTeam: number | null;
|
||||
redTeam: number | null;
|
||||
start: Dayjs | null;
|
||||
start: number | null;
|
||||
spectatePort: number | null;
|
||||
group: string | null;
|
||||
group: number | null;
|
||||
}
|
||||
|
||||
export class FightRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listFights(eventId: number): Promise<EventFight[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(EventFightSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(EventFightSchema).parse(value));
|
||||
}
|
||||
|
||||
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, "/fights", {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: eventId,
|
||||
@ -67,28 +66,25 @@ export class FightRepo {
|
||||
spectatePort: fight.spectatePort,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
spielmodus: fight.spielmodus,
|
||||
map: fight.map,
|
||||
blueTeam: fight.blueTeam,
|
||||
redTeam: fight.redTeam,
|
||||
...fight,
|
||||
start: fight.start?.valueOf(),
|
||||
spectatePort: fight.spectatePort,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async deleteFight(fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async deleteFight(eventId: number, fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
@ -17,27 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Page, PageList} from "@type/page.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {PageListSchema, PageSchema} from "@type/page.ts";
|
||||
import {bytesToBase64} from "../admin/util.ts";
|
||||
import {z} from "zod";
|
||||
import {derived} from "svelte/store";
|
||||
import type { Page, PageList } from "@type/page.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { PageListSchema, PageSchema } from "@type/page.ts";
|
||||
import { bytesToBase64 } from "../admin/util.ts";
|
||||
import { z } from "zod";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export class PageRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listPages(branch: string = "master"): Promise<PageList> {
|
||||
return await fetchWithToken(this.token, `/page?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(PageListSchema.parse)
|
||||
.then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")})));
|
||||
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||
}
|
||||
|
||||
public async getPage(id: number, branch: string = "master"): Promise<Page> {
|
||||
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(PageSchema.parse);
|
||||
}
|
||||
|
||||
@ -46,40 +45,55 @@ export class PageRepo {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
content: bytesToBase64(new TextEncoder().encode(content)),
|
||||
sha, message,
|
||||
sha,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public async getBranches(): Promise<string[]> {
|
||||
return await fetchWithToken(this.token, "/page/branch")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(z.string()).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(z.string()).parse(value));
|
||||
}
|
||||
|
||||
public async createBranch(branch: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})});
|
||||
await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) });
|
||||
}
|
||||
|
||||
public async deleteBranch(branch: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})});
|
||||
await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) });
|
||||
}
|
||||
|
||||
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})});
|
||||
await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) });
|
||||
}
|
||||
|
||||
public async merge(branch: string, message: string): Promise<void> {
|
||||
await fetchWithToken(this.token, "/page/branch/merge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({branch, message}),
|
||||
body: JSON.stringify({ branch, message }),
|
||||
});
|
||||
}
|
||||
|
||||
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({message, sha}),
|
||||
body: JSON.stringify({ message, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
public async listImages(branch: string = "master"): Promise<PageList> {
|
||||
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
|
||||
.then((value) => value.json())
|
||||
.then(PageListSchema.parse)
|
||||
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
|
||||
}
|
||||
|
||||
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
|
||||
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, data }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,31 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {writable} from "svelte/store";
|
||||
import {get, writable} from "svelte/store";
|
||||
import {authV2Repo} from "@repo/authv2.ts";
|
||||
|
||||
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) =>
|
||||
fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
|
||||
headers: {
|
||||
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
|
||||
"Content-Type": "application/json", ...params.headers,
|
||||
},
|
||||
})
|
||||
.then(value => {
|
||||
if (value.status === 401) {
|
||||
tokenStore.set("");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params);
|
||||
|
||||
export function getLocalStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return {
|
||||
getItem: () => "",
|
||||
setItem: () => {},
|
||||
};
|
||||
}
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
export const tokenStore = writable((getLocalStorage().getItem("sw-session") ?? ""));
|
||||
tokenStore.subscribe((value) => getLocalStorage().setItem("sw-session", value));
|
||||
export const tokenStore = writable("");
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {type Schematic, SchematicSchema} from "@type/schem.ts";
|
||||
import {derived} from "svelte/store";
|
||||
import {ResponseErrorSchema} from "@type/data.ts";
|
||||
|
||||
export class SchematicRepo {
|
||||
constructor(private token: string) {
|
||||
@ -40,7 +41,7 @@ export class SchematicRepo {
|
||||
name,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
}).then(value => value.json()).then(SchematicSchema.or(ResponseErrorSchema).parse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ export class StatsRepo {
|
||||
}
|
||||
|
||||
public async getUserStats(id: string): Promise<UserStats> {
|
||||
return await fetchWithToken(this.token, `/stats/user/${id}`).then(value => value.json()).then(UserStatsSchema.parse);
|
||||
return await fetchWithToken(this.token, `/stats/user`).then(value => value.json()).then(UserStatsSchema.parse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,33 +17,45 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, SchematicType} from "@type/data";
|
||||
import {PlayerSchema} from "@type/data.ts";
|
||||
import {cached, cachedFamily} from "./cached";
|
||||
import type {Team} from "@type/team.ts";
|
||||
import {TeamSchema} from "@type/team";
|
||||
import {derived, get, writable} from "svelte/store";
|
||||
import {z} from "zod";
|
||||
import {fetchWithToken, tokenStore} from "@repo/repo.ts";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import {dataRepo} from "@repo/data.ts";
|
||||
import type { Player, SchematicType } from "@type/data";
|
||||
import { PlayerSchema } from "@type/data.ts";
|
||||
import { cached, cachedFamily } from "./cached";
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { TeamSchema } from "@type/team";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { z } from "zod";
|
||||
import { fetchWithToken, tokenStore } from "@repo/repo.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
import { dataRepo } from "@repo/data.ts";
|
||||
import { permsRepo } from "@repo/perms.ts";
|
||||
|
||||
export const schemTypes = cached<SchematicType[]>([], () =>
|
||||
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
|
||||
.then(res => res.json()));
|
||||
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
|
||||
|
||||
export const players = cached<Player[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/users");
|
||||
return z.array(PlayerSchema).parse(await res.json());
|
||||
return get(dataRepo).getPlayers();
|
||||
});
|
||||
|
||||
export const teams = cached<Team[]>([], async () => {
|
||||
return get(dataRepo).getTeams();
|
||||
});
|
||||
|
||||
export const permissions = cached(
|
||||
{
|
||||
perms: [],
|
||||
prefixes: {},
|
||||
},
|
||||
async () => {
|
||||
return get(permsRepo).listPerms();
|
||||
}
|
||||
);
|
||||
|
||||
export const gamemodes = cached<string[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
|
||||
return z.array(z.string()).parse(await res.json());
|
||||
});
|
||||
|
||||
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
|
||||
if (get(gamemodes).every(value => value !== gamemode)) return [];
|
||||
if (get(gamemodes).every((value) => value !== gamemode)) return [];
|
||||
|
||||
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
|
||||
if (!res.ok) {
|
||||
@ -58,17 +70,12 @@ export const groups = cached<string[]>([], async () => {
|
||||
return z.array(z.string()).parse(await res.json());
|
||||
});
|
||||
|
||||
export const teams = cached<Team[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/team");
|
||||
return z.array(TeamSchema).parse(await res.json());
|
||||
});
|
||||
|
||||
export const branches = cached<string[]>([], async () => {
|
||||
const res = await get(pageRepo).getBranches();
|
||||
return z.array(z.string()).parse(res);
|
||||
});
|
||||
|
||||
export const server = derived(dataRepo, $dataRepo => $dataRepo.getServer());
|
||||
export const server = derived(dataRepo, ($dataRepo) => $dataRepo.getServer());
|
||||
|
||||
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte";
|
||||
import {stopPropagation} from "@components/util.ts";
|
||||
import {stopPropagation} from "@components/utils.ts";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@ -68,16 +68,18 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full max-w-md w-full rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
|
||||
<div onclick={stopPropagation(onclick)} aria-hidden="true">
|
||||
<dialog bind:this={dialog} onclose={close} onclick={(e) => dialog.close()} aria-hidden="true" class="max-h-full min-w-md w-fit rounded-lg shadow-lg dark:bg-neutral-800 dark:text-neutral-100">
|
||||
<div onclick={stopPropagation(onclick)} aria-hidden="true" class="w-fit">
|
||||
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h1 class="text-4xl font-bold">{title}</h1>
|
||||
</div>
|
||||
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<div class="flex mx-4 my-2 p-6" onclick={() => dialog.close()} aria-hidden="true">
|
||||
{@render footer?.()}
|
||||
<div class="mx-4 my-2 p-6">
|
||||
<div class="ml-auto flex justify-end" onclick={() => dialog.close()} aria-hidden="true">
|
||||
{@render footer?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2023 SteamWar.de-Serverteam
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
@ -17,17 +17,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function window<T>(arr: T[], len: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += len) {
|
||||
result.push(arr.slice(i, i + len));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import {z} from "zod";
|
||||
|
||||
export function stopPropagation(a: any) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
a(e);
|
||||
};
|
||||
}
|
||||
export const TokenSchema = z.object({
|
||||
token: z.string(),
|
||||
expires: z.string(),
|
||||
});
|
||||
|
||||
export type Token = z.infer<typeof TokenSchema>;
|
||||
|
||||
export const AuthTokenSchema = z.object({
|
||||
accessToken: TokenSchema,
|
||||
refreshToken: TokenSchema,
|
||||
});
|
||||
|
||||
export type AuthToken = z.infer<typeof AuthTokenSchema>;
|
||||
@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const SchematicTypeSchema = z.object({
|
||||
name: z.string(),
|
||||
@ -50,3 +50,19 @@ export const ServerSchema = z.object({
|
||||
});
|
||||
|
||||
export type Server = z.infer<typeof ServerSchema>;
|
||||
|
||||
export const ResponseErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export type ResponseError = z.infer<typeof ResponseErrorSchema>;
|
||||
|
||||
export const ResponseUserSchema = z.object({
|
||||
name: z.string(),
|
||||
uuid: z.string(),
|
||||
prefix: z.string(),
|
||||
perms: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ResponseUser = z.infer<typeof ResponseUserSchema>;
|
||||
|
||||
@ -17,9 +17,57 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import {TeamSchema} from "./team.js";
|
||||
import {PlayerSchema} from "./data.js";
|
||||
import { z } from "zod";
|
||||
import { TeamSchema } from "./team.js";
|
||||
import { PlayerSchema, ResponseUserSchema } from "./data.js";
|
||||
|
||||
export const ResponseGroupsSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
pointsPerWin: z.number(),
|
||||
pointsPerLoss: z.number(),
|
||||
pointsPerDraw: z.number(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
|
||||
points: z.record(z.string(), z.number()).nullable(),
|
||||
});
|
||||
|
||||
export const EventFightSchema = z.object({
|
||||
id: z.number(),
|
||||
spielmodus: z.string(),
|
||||
map: z.string(),
|
||||
blueTeam: TeamSchema,
|
||||
redTeam: TeamSchema,
|
||||
start: z.number(),
|
||||
ergebnis: z.number(),
|
||||
spectatePort: z.number().nullable(),
|
||||
group: ResponseGroupsSchema.nullable(),
|
||||
hasFinished: z.boolean(),
|
||||
});
|
||||
|
||||
export type EventFight = z.infer<typeof EventFightSchema>;
|
||||
|
||||
export const EventFightEditSchema = EventFightSchema.omit({
|
||||
id: true,
|
||||
group: true,
|
||||
hasFinished: true,
|
||||
}).extend({
|
||||
group: z.number().nullable(),
|
||||
});
|
||||
|
||||
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
|
||||
|
||||
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
|
||||
|
||||
export const ResponseRelationSchema = z.object({
|
||||
id: z.number(),
|
||||
fight: EventFightSchema,
|
||||
type: z.enum(["FIGHT", "GROUP"]),
|
||||
fromFight: EventFightSchema.nullable(),
|
||||
fromGroup: ResponseGroupsSchema.nullable(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
|
||||
export type ResponseRelation = z.infer<typeof ResponseRelationSchema>;
|
||||
|
||||
export const ShortEventSchema = z.object({
|
||||
id: z.number(),
|
||||
@ -35,29 +83,69 @@ export const SWEventSchema = ShortEventSchema.extend({
|
||||
maxTeamMembers: z.number(),
|
||||
schemType: z.string().nullable(),
|
||||
publicSchemsOnly: z.boolean(),
|
||||
referees: z.array(PlayerSchema),
|
||||
});
|
||||
|
||||
export type SWEvent = z.infer<typeof SWEventSchema>;
|
||||
|
||||
export const EventFightSchema = z.object({
|
||||
id: z.number(),
|
||||
spielmodus: z.string(),
|
||||
map: z.string(),
|
||||
blueTeam: TeamSchema,
|
||||
redTeam: TeamSchema,
|
||||
start: z.number(),
|
||||
ergebnis: z.number(),
|
||||
spectatePort: z.number().nullable(),
|
||||
group: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type EventFight = z.infer<typeof EventFightSchema>;
|
||||
|
||||
export const ExtendedEventSchema = z.object({
|
||||
event: SWEventSchema,
|
||||
teams: z.array(TeamSchema),
|
||||
groups: z.array(ResponseGroupsSchema),
|
||||
fights: z.array(EventFightSchema),
|
||||
referees: z.array(ResponseUserSchema),
|
||||
relations: z.array(ResponseRelationSchema),
|
||||
});
|
||||
|
||||
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
|
||||
|
||||
export const ResponseTeamSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
kuerzel: z.string(),
|
||||
color: z.string(),
|
||||
});
|
||||
|
||||
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
|
||||
|
||||
export const CreateEventGroupSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
|
||||
});
|
||||
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
|
||||
|
||||
export const UpdateEventGroupSchema = z.object({
|
||||
name: z.string().nullable().optional(),
|
||||
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
|
||||
pointsPerWin: z.number().nullable().optional(),
|
||||
pointsPerLoss: z.number().nullable().optional(),
|
||||
pointsPerDraw: z.number().nullable().optional(),
|
||||
});
|
||||
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
|
||||
|
||||
export const GroupEditSchema = ResponseGroupsSchema.omit({
|
||||
id: true,
|
||||
points: true,
|
||||
});
|
||||
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
|
||||
|
||||
export const CreateEventRelationSchema = z.object({
|
||||
fightId: z.number(),
|
||||
team: z.enum(["RED", "BLUE"]),
|
||||
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||
fromId: z.number(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
|
||||
|
||||
export const UpdateFromRelationSchema = z.object({
|
||||
fromType: z.enum(["FIGHT", "GROUP"]),
|
||||
fromId: z.number(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
|
||||
|
||||
export const UpdateEventRelationSchema = z.object({
|
||||
team: z.enum(["RED", "BLUE"]).nullable().optional(),
|
||||
from: UpdateFromRelationSchema.nullable().optional(),
|
||||
});
|
||||
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;
|
||||
|
||||
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>
|
||||
3
src/components/ui/aspect-ratio/index.ts
Normal file
3
src/components/ui/aspect-ratio/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Root from "./aspect-ratio.svelte";
|
||||
|
||||
export { Root, Root as AspectRatio };
|
||||
16
src/components/ui/avatar/avatar-fallback.svelte
Normal file
16
src/components/ui/avatar/avatar-fallback.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AvatarPrimitive.FallbackProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</AvatarPrimitive.Fallback>
|
||||
18
src/components/ui/avatar/avatar-image.svelte
Normal file
18
src/components/ui/avatar/avatar-image.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AvatarPrimitive.ImageProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let src: $$Props["src"] = undefined;
|
||||
export let alt: $$Props["alt"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
{src}
|
||||
{alt}
|
||||
class={cn("aspect-square h-full w-full", className)}
|
||||
{...$$restProps}
|
||||
/>
|
||||
18
src/components/ui/avatar/avatar.svelte
Normal file
18
src/components/ui/avatar/avatar.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = AvatarPrimitive.Props;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let delayMs: $$Props["delayMs"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
{delayMs}
|
||||
class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</AvatarPrimitive.Root>
|
||||
13
src/components/ui/avatar/index.ts
Normal file
13
src/components/ui/avatar/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
18
src/components/ui/badge/badge.svelte
Normal file
18
src/components/ui/badge/badge.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { type Variant, badgeVariants } from "./index.js";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export let href: string | undefined = undefined;
|
||||
export let variant: Variant = "default";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant, className }))}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</svelte:element>
|
||||
21
src/components/ui/badge/index.ts
Normal file
21
src/components/ui/badge/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus:ring-ring inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type Variant = VariantProps<typeof badgeVariants>["variant"];
|
||||
24
src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal file
24
src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Ellipsis from "lucide-svelte/icons/ellipsis";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
el?: HTMLSpanElement;
|
||||
};
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={el}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<Ellipsis class="h-4 w-4" />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
16
src/components/ui/breadcrumb/breadcrumb-item.svelte
Normal file
16
src/components/ui/breadcrumb/breadcrumb-item.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = HTMLLiAttributes & {
|
||||
el?: HTMLLIElement;
|
||||
};
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<li bind:this={el} class={cn("inline-flex items-center gap-1.5", className)}>
|
||||
<slot />
|
||||
</li>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user