2 Commits

Author SHA1 Message Date
D4rkr34lm
4d100fcafc Started implementing tutorials
Some checks failed
SteamWarCI Build failed
2025-04-23 10:39:19 +02:00
D4rkr34lm
82f5ab48b8 Add tracer tutorial 2025-01-16 21:47:03 +01:00
405 changed files with 10408 additions and 14112 deletions

1
.gitignore vendored
View File

@@ -26,4 +26,3 @@ pnpm-debug.log*
/src/env.d.ts /src/env.d.ts
/src/pages/en/ /src/pages/en/
/.idea /.idea
pnpm-lock.yaml

View File

@@ -1,14 +1,14 @@
import { defineConfig, sharpImageService } from "astro/config"; import {defineConfig, sharpImageService} from "astro/config";
import svelte from "@astrojs/svelte"; import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
import configureI18n from "./astro-i18n.adapter"; import configureI18n from "./astro-i18n.adapter";
import sitemap from "@astrojs/sitemap"; import sitemap from "@astrojs/sitemap";
import robotsTxt from "astro-robots-txt"; import robotsTxt from "astro-robots-txt";
import {resolve} from "node:url";
import path from "node:path"; import path from "node:path";
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
import pagefind from "astro-pagefind";
import starlight from "@astrojs/starlight";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
@@ -20,40 +20,15 @@ export default defineConfig({
site: "https://steamwar.de", site: "https://steamwar.de",
integrations: [ integrations: [
svelte(), svelte(),
starlight({
disable404Route: true,
title: "SteamWar Docs",
defaultLocale: "de",
logo: {
src: "./src/images/logo.png",
},
social: [
{ icon: "discord", label: "Discord", href: "https://steamwar.de/discord" },
{ icon: "document", label: "Gitea", href: "https://git.steamwar.de" },
],
sidebar: [
{ label: "Startseite", slug: "docs" },
{ label: "Bau", badge: "WIP", items: ["docs/bausystem", { label: "Script System", items: ["docs/bausystem/script"] }] },
{ label: "Kampfsystem", badge: "WIP", items: ["docs/fightsystem"] },
{ label: "Minigames", badge: "WIP", items: ["docs/minigames"] },
{ label: "Schematicsystem", badge: "WIP", items: ["docs/schematicsystem"] },
{ label: "API", badge: "WIP", items: ["docs/api"] },
],
editLink: {
baseUrl: "https://git.steamwar.de/SteamWar/Website/src/branch/master/",
},
}),
tailwind({ tailwind({
configFile: "./tailwind.config.js", configFile: "./tailwind.config.cjs",
applyBaseStyles: false,
}), }),
pagefind(),
configureI18n(), configureI18n(),
sitemap({ sitemap({
i18n: { i18n: {
defaultLocale: "en", defaultLocale: "en", locales: {
locales: { en: "en-US", de: "de-DE",
en: "en-US",
de: "de-DE",
}, },
}, },
}), }),
@@ -77,7 +52,7 @@ export default defineConfig({
{ userAgent: "omgili", disallow: "/" }, { userAgent: "omgili", disallow: "/" },
{ userAgent: "OmigliBot", disallow: "/" }, { userAgent: "OmigliBot", disallow: "/" },
{ userAgent: "PerplexityBot", disallow: "/" }, { userAgent: "PerplexityBot", disallow: "/" },
{ userAgent: "Timpibot", disallow: "/" }, { userAgent: "Timpibot", disallow: "/" }
], ],
}), }),
mdx(), mdx(),
@@ -94,8 +69,7 @@ export default defineConfig({
"@layouts": path.resolve("./src/layouts"), "@layouts": path.resolve("./src/layouts"),
"@repo": path.resolve("./src/components/repo"), "@repo": path.resolve("./src/components/repo"),
"@stores": path.resolve("./src/components/stores"), "@stores": path.resolve("./src/components/stores"),
$lib: path.resolve("./src"),
}, },
}, },
}, },
}); });

View File

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

View File

@@ -1,95 +1,69 @@
{ {
"name": "steamwar-website", "name": "steamwar-website",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"i18n:extract": "astro-i18n extract", "i18n:extract": "astro-i18n extract",
"i18n:generate:pages": "astro-i18n generate:pages --purge", "i18n:generate:pages": "astro-i18n generate:pages --purge",
"i18n:generate:types": "astro-i18n generate:types", "i18n:generate:types": "astro-i18n generate:types",
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types", "i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node_modules": "rm -rf node_modules", "clean:node_modules": "rm -rf node_modules",
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build" "ci": "pnpm run clean:dist && pnpm install && pnpm run i18n:sync && pnpm run build"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/svelte": "^7.1.0", "@astrojs/svelte": "^6.0.2",
"@astrojs/tailwind": "^5.1.5", "@astrojs/tailwind": "^5.1.2",
"@astropub/icons": "^0.2.0", "@astropub/icons": "^0.2.0",
"@internationalized/date": "^3.8.1", "@types/color": "^4.2.0",
"@lucide/svelte": "^0.488.0", "@types/node": "^22.9.3",
"@types/color": "^4.2.0", "@types/three": "^0.170.0",
"@types/js-yaml": "^4.0.9", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@types/node": "^22.15.23", "@typescript-eslint/parser": "^8.15.0",
"@types/three": "^0.170.0", "autoprefixer": "^10.4.20",
"@typescript-eslint/eslint-plugin": "^8.33.0", "cssnano": "^7.0.6",
"@typescript-eslint/parser": "^8.33.0", "esbuild": "^0.24.0",
"autoprefixer": "^10.4.21", "eslint": "^9.15.0",
"bits-ui": "1.3.4", "eslint-plugin-astro": "^1.3.1",
"clsx": "^2.1.1", "eslint-plugin-jsx-a11y": "^6.10.2",
"cmdk-sv": "^0.0.18", "eslint-plugin-svelte": "^2.46.0",
"cssnano": "^7.0.7", "postcss-nesting": "^13.0.1",
"embla-carousel-svelte": "^8.6.0", "sass": "^1.81.0",
"esbuild": "^0.24.2", "svelte": "^5.16.0",
"eslint": "^9.27.0", "tailwind-merge": "^2.5.5",
"eslint-plugin-astro": "^1.3.1", "tailwindcss": "^3.4.15",
"eslint-plugin-jsx-a11y": "^6.10.2", "three": "^0.170.0",
"eslint-plugin-svelte": "^2.46.1", "typescript": "^5.7.2"
"formsnap": "1.0.1", },
"lucide-svelte": "^0.476.0", "dependencies": {
"mode-watcher": "^0.5.1", "@astrojs/mdx": "^3.1.9",
"paneforge": "^0.0.6", "@astrojs/sitemap": "^3.2.1",
"postcss-nesting": "^13.0.1", "@codemirror/lang-json": "^6.0.1",
"sass": "^1.89.0", "@ddietr/codemirror-themes": "^1.4.4",
"svelte": "^5.33.4", "astro": "^4.16.14",
"svelte-sonner": "^0.3.28", "astro-i18n": "^2.2.4",
"tailwind-merge": "^2.6.0", "astro-pagefind": "^1.6.0",
"tailwind-variants": "^0.3.1", "astro-robots-txt": "^1.0.0",
"tailwindcss": "^3.4.17", "astro-seo": "^0.8.4",
"three": "^0.170.0", "chart.js": "^4.4.6",
"typescript": "^5.8.3", "chartjs-adapter-dayjs-4": "^1.0.4",
"vaul-svelte": "^0.3.2", "chartjs-adapter-moment": "^1.0.1",
"zod": "^3.25.31" "color": "^4.2.3",
}, "dayjs": "^1.11.13",
"dependencies": { "easymde": "^2.18.0",
"@astrojs/mdx": "^4.3.0", "flowbite": "^2.5.2",
"@astrojs/sitemap": "^3.4.0", "flowbite-svelte": "^0.47.3",
"@astrojs/starlight": "^0.34.4", "flowbite-svelte-icons": "^2.0.2",
"@astrojs/starlight-tailwind": "^4.0.1", "qs": "^6.13.1",
"@codemirror/commands": "^6.8.1", "sharp": "^0.33.5",
"@codemirror/lang-json": "^6.0.1", "svelte-awesome": "^3.3.5",
"@codemirror/view": "^6.36.8", "svelte-codemirror-editor": "^1.4.1",
"@ddietr/codemirror-themes": "^1.5.1", "svelte-spa-router": "^4.0.1",
"@tanstack/table-core": "^8.21.3", "zod": "^3.23.8"
"astro": "5.7.14", }
"astro-i18n": "^2.2.4",
"astro-robots-txt": "^1.0.0",
"astro-seo": "^0.8.4",
"chart.js": "^4.4.9",
"chartjs-adapter-dayjs-4": "^1.0.4",
"chartjs-adapter-moment": "^1.0.1",
"codemirror": "^6.0.1",
"color": "^4.2.3",
"dayjs": "^1.11.13",
"easymde": "^2.20.0",
"flowbite": "^2.5.2",
"flowbite-svelte": "^0.47.4",
"flowbite-svelte-icons": "^2.2.0",
"js-yaml": "^4.1.0",
"qs": "^6.14.0",
"sharp": "^0.33.5",
"svelte-awesome": "^3.3.5",
"svelte-spa-router": "^4.0.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"sharp"
]
}
} }

8818
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 406 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -18,13 +18,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import { twMerge } from "tailwind-merge"; import {twMerge} from "tailwind-merge";
import { onMount } from "svelte"; import {onMount} from "svelte";
let cardElement: HTMLDivElement = $state(); let cardElement: HTMLDivElement = $state();
function rotateElement(event: MouseEvent) { function rotateElement(event: MouseEvent) {
if (!hoverEffect) return; if(!hoverEffect) return;
const x = event.clientX; const x = event.clientX;
const y = event.clientY; const y = event.clientY;
@@ -36,23 +36,23 @@
const rotateX = (centerY - y) / 20; const rotateX = (centerY - y) / 20;
const rotateY = -(centerX - x) / 20; const rotateY = -(centerX - x) / 20;
cardElement.style.setProperty("--rotate-x", `${rotateX}deg`); cardElement.style.setProperty('--rotate-x', `${rotateX}deg`);
cardElement.style.setProperty("--rotate-y", `${rotateY}deg`); cardElement.style.setProperty('--rotate-y', `${rotateY}deg`);
} }
function resetElement() { function resetElement() {
cardElement.style.setProperty("--rotate-x", "0"); cardElement.style.setProperty('--rotate-x', "0");
cardElement.style.setProperty("--rotate-y", "0"); cardElement.style.setProperty('--rotate-y', "0");
} }
interface Props { interface Props {
hoverEffect?: boolean; hoverEffect?: boolean;
extraClasses?: string; extraClasses?: string;
children?: import("svelte").Snippet; children?: import('svelte').Snippet;
} }
let { hoverEffect = true, extraClasses = "", children }: Props = $props(); let { hoverEffect = true, extraClasses = '', children }: Props = $props();
let classes = $derived(twMerge("w-72 border-2 border-gray-100 flex flex-col items-center p-8 m-4 rounded-xl shadow-lg bg-zinc-900 dark:border-gray-800 dark:text-gray-100", extraClasses)); let classes = $derived(twMerge("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))
</script> </script>
<div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect> <div class={classes} bind:this={cardElement} onmousemove={rotateElement} onmouseleave={resetElement} class:hoverEffect>
@@ -61,20 +61,20 @@
<style lang="scss"> <style lang="scss">
div { div {
transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important; transform: perspective(1000px) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) !important;
transition: scale 300ms cubic-bezier(0.2, 3, 0.67, 0.6); transition: scale 300ms cubic-bezier(.2, 3, .67, .6);
:global(h1) { :global(h1) {
@apply text-xl font-bold mt-4; @apply text-xl font-bold mt-4;
} }
:global(svg) { :global(svg) {
@apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl; @apply transition-transform duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl
} }
} }
.hoverEffect:hover { .hoverEffect:hover {
scale: 105%; scale: 105%;
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,14 +18,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { preventDefault } from "svelte/legacy"; import { preventDefault } from 'svelte/legacy';
import { l } from "@utils/util.ts"; import {l} from "@utils/util.ts";
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import { get } from "svelte/store"; import {get} from "svelte/store";
import { navigate } from "astro:transitions/client"; import {navigate} from "astro:transitions/client";
import { onMount } from "svelte";
import { authV2Repo } from "./repo/authv2.ts";
let username: string = $state(""); let username: string = $state("");
let pw: string = $state(""); let pw: string = $state("");
@@ -33,7 +31,8 @@
let error: string = $state(""); let error: string = $state("");
async function login() { async function login() {
let { authV2Repo } = await import("./repo/authv2.ts"); let {tokenStore} = await import("./repo/repo.ts");
let {authRepo} = await import("./repo/auth.ts");
if (username === "" || pw === "") { if (username === "" || pw === "") {
pw = ""; pw = "";
error = t("login.error"); error = t("login.error");
@@ -41,37 +40,20 @@
} }
try { try {
let auth = await get(authV2Repo).login(username, pw); let auth = await get(authRepo).login(username, pw);
if (!auth) { if (auth == undefined) {
pw = ""; pw = "";
error = t("login.error"); error = t("login.error");
return; return;
} }
await navigate(l("/dashboard")); tokenStore.set(auth);
navigate(l("/dashboard"));
} catch (e: any) { } catch (e: any) {
pw = ""; pw = "";
error = t("login.error"); error = t("login.error");
} }
} }
onMount(() => {
if (window.location.hash.includes("access_token")) {
const params = new URLSearchParams(window.location.hash.substring(1));
const accessToken = params.get("access_token");
if (accessToken) {
let auth = $authV2Repo.loginDiscord(accessToken);
if (!auth) {
pw = "";
error = t("login.error");
return;
}
navigate(l("/dashboard"));
}
}
});
</script> </script>
<form class="bg-gray-100 dark:bg-neutral-900 p-12 rounded-2xl shadow-2xl border-2 border-gray-600 flex flex-col" onsubmit={preventDefault(login)}> <form class="bg-gray-100 dark:bg-neutral-900 p-12 rounded-2xl shadow-2xl border-2 border-gray-600 flex flex-col" onsubmit={preventDefault(login)}>
@@ -83,27 +65,22 @@
<input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} /> <input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} />
</div> </div>
<p class="mt-2"> <p class="mt-2">
<a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a> <a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a></p>
</p>
{#if error} {#if error}
<p class="mt-2 text-red-500">{error}</p> <p class="mt-2 text-red-500">{error}</p>
{/if} {/if}
<button class="btn mt-4 !mx-0 justify-center" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button> <button class="btn mt-4 !mx-0 justify-center" type="submit" onclick={preventDefault(login)}>{t("login.submit")}</button>
<a
class="btn mt-4 !mx-0 justify-center"
href="https://discord.com/oauth2/authorize?client_id=869611389818400779&response_type=token&redirect_uri=http%3A%2F%2Flocalhost%3A4321%2Flogin&scope=identify"
>
{t("login.discord")}
</a>
</form> </form>
<style lang="postcss"> <style lang="postcss">
input { input {
@apply border-2 rounded-md p-2 shadow-2xl w-80 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:border-transparent text-black; @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;
} }
label { label {
@apply text-neutral-300; @apply text-neutral-300;
} }
</style> </style>

View File

@@ -18,97 +18,133 @@
--> -->
<script lang="ts"> <script lang="ts">
import "../styles/button.css"; import "../styles/button.css";
import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, SearchOutline } from "flowbite-svelte-icons";
import { t } from "astro-i18n"; import { t } from "astro-i18n";
import { l } from "../util/util"; import { l } from "../util/util";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { loggedIn } from "@repo/authv2.ts"; import { loggedIn } from "@repo/authv2.ts";
interface Props { interface Props {
logo?: import("svelte").Snippet; 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");
} }
});
let { logo }: Props = $props(); onMount(() => {
handleScroll();
});
let navbar = $state<HTMLElement>(); function handleScroll() {
let searchOpen = $state(false); if (window.scrollY > 0) {
navbar!.classList.add("before:scale-y-100");
let accountBtn = $state<HTMLAnchorElement>(); } else {
navbar!.classList.remove("before:scale-y-100");
$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> </script>
<svelte:window onscroll={handleScroll} /> <svelte:window onscroll={handleScroll} />
<nav <nav
data-pagefind-ignore 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" 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} bind:this={navbar}
> >
<div class="flex flex-row items-center justify-evenly md:justify-between match"> <div
<a class="flex items-center" href={l("/")}> class="flex flex-row items-center justify-evenly md:justify-between match"
{@render logo?.()} >
<span class="text-2xl uppercase font-bold text-white hidden md:inline-block"> <a class="flex items-center" href={l("/")}>
{t("navbar.title")} {@render logo?.()}
<span class="before:scale-y-100" style="display: none" aria-hidden="true"></span> <span
</span> class="text-2xl uppercase font-bold dark:text-white hidden md:inline-block"
</a> >
<div class="flex justify-center flex-wrap"> {t("navbar.title")}
<div class="btn-dropdown"> <span
<button class="btn btn-gray"> class="before:scale-y-100"
<a href={l("/")}> style="display: none"
<span class="btn__text">{t("navbar.links.home.title")}</span> aria-hidden="true"
</a> ></span>
<CaretDownOutline class="ml-2 mt-auto" /> </span>
</button> </a>
<div> <div class="flex justify-center flex-wrap">
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a> <div class="btn-dropdown">
<a class="btn btn-gray" href={l("/events")}>{t("navbar.links.home.events")}</a> <button class="btn btn-gray">
<a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a> <a href={l("/")}>
<a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a> <span class="btn__text">{t("navbar.links.home.title")}</span>
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a> </a>
</div> <CaretDownOutline class="ml-2 mt-auto" />
</div> </button>
<div class="btn-dropdown"> <div>
<button class="btn btn-gray"> <a class="btn btn-gray" href={l("/announcements")}
<a rel="prefetch" href={l("/rules")}> >{t("navbar.links.home.announcements")}</a
<span class="btn__text">{t("navbar.links.rules.title")}</span> >
</a> <a class="btn btn-gray" href={l("/downloads")}
<CaretDownOutline class="ml-2 mt-auto" /> >{t("navbar.links.home.downloads")}</a
</button> >
<div> <a class="btn btn-gray" href={l("/tutorials")}
<a href={l("/rules/wargear")} class="btn btn-gray">{t("navbar.links.rules.wg")}</a> >{t("navbar.links.home.tutorials")}</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 class="btn btn-gray" href={l("/faq")}
<a href={l("/rules/airship")} class="btn btn-gray">{t("navbar.links.rules.as")}</a> >{t("navbar.links.home.faq")}</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 class="btn btn-gray" href={l("/code-of-conduct")}
<a href={l("/rules/megawargear")} class="btn btn-gray">{t("navbar.links.rules.megawg")}</a> >{t("navbar.links.rules.coc")}</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> </div>
<h2 class="px-2 text-gray-300">{t("navbar.links.rules.ranked")}</h2> </div>
<a href={l("/rangliste/missilewars")} class="btn btn-gray">{t("navbar.links.ranked.mw")}</a> <div class="btn-dropdown">
</div> <button class="btn btn-gray">
</div> <a rel="prefetch" href={l("/rules")}>
<!-- TODO: Add help center <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-dropdown my-1">
<div class="btn btn-gray" tabindex="1"> <div class="btn btn-gray" tabindex="1">
<a rel="prefetch"> <a rel="prefetch">
@@ -122,26 +158,26 @@
</div> </div>
</div> </div>
--> -->
<a class="btn" href={l("/login")} bind:this={accountBtn}> <a class="btn" href={l("/login")} bind:this={accountBtn}>
<span class="btn__text">{t("navbar.links.account")}</span> <span class="btn__text">{t("navbar.links.account")}</span>
</a> </a>
<!-- <!--
<button class="btn my-1" onclick={() => searchOpen = true}> <button class="btn my-1" onclick={() => searchOpen = true}>
<SearchOutline ariaLabel="Site Search" class="inline-block h-6"/> <SearchOutline ariaLabel="Site Search" class="inline-block h-6"/>
</button> </button>
--> -->
</div>
</div> </div>
</div>
</nav> </nav>
{#if searchOpen} {#if searchOpen}
{#await import("./SearchComponent.svelte") then c} {#await import("./SearchComponent.svelte") then c}
<c.default bind:open={searchOpen} /> <c.default bind:open={searchOpen} />
{/await} {/await}
{/if} {/if}
<style lang="scss"> <style lang="scss">
.match { .match {
width: min(100vw, 70em); width: min(100vw, 70em);
} }
</style> </style>

View File

@@ -1,60 +1,50 @@
--- ---
import type { CollectionEntry } from "astro:content"; import {CollectionEntry} from "astro:content";
import { l } from "../util/util"; import {l} from "../util/util";
import { astroI18n } from "astro-i18n"; import {astroI18n} from "astro-i18n";
import { Image } from "astro:assets"; import {Image} from "astro:assets";
import TagComponent from "./TagComponent.astro"; import TagComponent from "./TagComponent.astro";
import P from "./P.astro"; import P from "./P.astro";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
interface Props { interface Props {
post: CollectionEntry<"announcements">; post: CollectionEntry<"announcements">
} }
const { const { post, slim }: {
post, post: CollectionEntry<"announcements">,
slim, slim: boolean,
}: {
post: CollectionEntry<"announcements">;
slim: boolean;
} = Astro.props as Props; } = Astro.props as Props;
const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`); const postUrl = l(`/announcements/${post.slug.split("/").slice(1).join("/")}`);
--- ---
<Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1 backdrop-blur-xl bg-transparent" : ""}`} hoverEffect={false}> <Card extraClasses={`w-full items-start mx-0 ${slim ? "m-0 p-1" : ""}`} hoverEffect={false}>
<div class={`flex flex-row ${slim ? "" : "p-4"}`}> <div class={`flex flex-row ${slim ? "":"p-4"}`}>
{ {post.data.image != null
post.data.image != null ? ( ? (
<a href={postUrl}> <a href={postUrl}>
<div class="flex-shrink-0 pr-2"> <div class="flex-shrink-0 pr-2">
<Image <Image transition:name={post.data.title + "-image"} src={post.data.image} alt="Post Image" class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105" />
transition:name={post.data.title + "-image"}
src={post.data.image}
alt="Post Image"
class="rounded-2xl shadow-2xl object-cover h-32 w-32 max-w-none transition-transform hover:scale-105"
/>
</div> </div>
</a> </a>
) : null )
} : null}
<div> <div>
<a href={postUrl} class="flex flex-col items-start"> <a href={postUrl} class="flex flex-col items-start">
<h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2> <h2 class="text-2xl font-bold" transition:name={post.data.title + "-title"}>{post.data.title}</h2>
<P class="text-gray-500" <P class="text-gray-500">{Intl.DateTimeFormat(astroI18n.locale, {
>{ day: "numeric",
Intl.DateTimeFormat(astroI18n.locale, { month: "long",
day: "numeric", year: "numeric",
month: "long", }).format(post.data.created)}</P>
year: "numeric",
}).format(post.data.created)
}</P
>
<P>{post.data.description}</P> <P>{post.data.description}</P>
</a> </a>
<div class="mt-1" transition:name={post.data.title + "-tags"}> <div class="mt-1" transition:name={post.data.title + "-tags"}>
{post.data.tags.map((tag) => <TagComponent tag={tag} />)} {post.data.tags.map((tag) => (
<TagComponent tag={tag} />
))}
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>

View File

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

View File

@@ -168,11 +168,11 @@
</div> </div>
<div> <div>
{#if selectedBranch !== "master"} {#if selectedBranch !== "master"}
<Button onclick={() => createFile()} color="alternative" disabled={!selectedPath}>Create File <Button onclick={createFile} color="alternative" disabled={!selectedPath}>Create File
</Button> </Button>
<Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button> <Button onclick={() => deleteBranch(false)} color="none">Delete Branch</Button>
{:else} {:else}
<Button onclick={() => createBranch()}>Create Branch</Button> <Button onclick={createBranch}>Create Branch</Button>
{/if} {/if}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
let request = getRequest(); let request = getRequest();
function getRequest() { function getRequest() {
return $statsRepo.getUserStats(user.uuid) return $statsRepo.getUserStats(user.id)
} }
</script> </script>
@@ -43,5 +43,8 @@
maximumFractionDigits: 2 maximumFractionDigits: 2
}).format(data.playtime)})}h</p> }).format(data.playtime)})}h</p>
<p>{t("dashboard.stats.fights", {fights: data.fights})}</p> <p>{t("dashboard.stats.fights", {fights: data.fights})}</p>
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p> {#if user.perms.includes("CHECK")}
<p>{t("dashboard.stats.checked", {checked: data.acceptedSchematics})}</p>
{/if}
{:catch error}
{/await} {/await}

View File

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

View File

@@ -18,23 +18,24 @@
--> -->
<script lang="ts"> <script lang="ts">
import { t } from "astro-i18n"; import {t} from "astro-i18n";
import type { Player } from "@type/data.ts"; import type {Player} from "@type/data.ts";
import { l } from "@utils/util.ts"; import {l} from "@utils/util.ts";
import Statistics from "./Statistics.svelte"; import Statistics from "./Statistics.svelte";
import { authV2Repo } from "@repo/authv2.ts"; import {authRepo} from "@repo/auth.ts";
import {tokenStore} from "@repo/repo.ts";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
import { navigate } from "astro:transitions/client";
interface Props { interface Props {
user: Player; user: Player;
} }
let { user }: Props = $props(); let { user }: Props = $props();
async function logout() { async function logout() {
await $authV2Repo.logout(); await $authRepo.logout()
await navigate(l("/login")); tokenStore.set("")
window.location.href = l("/login")
} }
</script> </script>
@@ -43,25 +44,19 @@
<Card> <Card>
<figure> <figure>
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption> <figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
<img <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" />
src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`}
class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl"
alt={user.name + "s bust"}
width="150"
height="150"
/>
</figure> </figure>
</Card> </Card>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button> <button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
{#if user.perms.includes("MODERATION")} {#if user.perms.includes("MODERATION")}
<a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a> <a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a>
{/if} {/if}
</div> </div>
</div> </div>
<div> <div>
<h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1> <h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1>
<p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p> <p>{t("dashboard.rank", {rank: t("home.prefix." + user.prefix)})}</p>
<Statistics {user} /> <Statistics {user} />
</div> </div>
</div> </div>

View File

@@ -1,108 +0,0 @@
<script lang="ts">
import { fightConnector } from "./connections.svelte";
import { onMount, onDestroy } from "svelte";
let root: HTMLElement | null = null;
let refresh = $state(0);
function handleScroll() {
refresh++;
}
function getScrollableParent(el: HTMLElement | null): HTMLElement | null {
let node: HTMLElement | null = el?.parentElement ?? null;
while (node) {
const style = getComputedStyle(node);
const canScrollX = (style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth;
const canScrollY = (style.overflowY === "auto" || style.overflowY === "scroll") && node.scrollHeight > node.clientHeight;
if (canScrollX || canScrollY) return node;
node = node.parentElement;
}
return null;
}
let cleanup: (() => void) | null = null;
onMount(() => {
const scrollParent = getScrollableParent(root);
const target: EventTarget | null = scrollParent ?? window;
target?.addEventListener("scroll", handleScroll, { passive: true } as AddEventListenerOptions);
window.addEventListener("resize", handleScroll, { passive: true });
cleanup = () => {
target?.removeEventListener?.("scroll", handleScroll as EventListener);
window.removeEventListener("resize", handleScroll as EventListener);
};
});
onDestroy(() => {
cleanup?.();
cleanup = null;
});
</script>
<div bind:this={root} class="connection-renderer-root">
{#key refresh}
{#each $fightConnector.showedConnections as connection}
{@const fromLeft = connection.fromElement.offsetLeft + connection.fromElement.offsetWidth}
{@const toLeft = connection.toElement.offsetLeft}
{@const fromTop = connection.fromElement.offsetTop + connection.fromElement.offsetHeight / 2}
{@const toTop = connection.toElement.offsetTop + connection.toElement.offsetHeight / 2}
{@const horizontalDistance = toLeft - fromLeft}
{@const verticalDistance = toTop - fromTop}
<!-- Apply horizontal offset only to the mid bridge and second segment fan-out; also shift vertical line to keep continuity -->
{@const midLeft = fromLeft + horizontalDistance / 2 + connection.offset}
{@const firstSegmentWidth = horizontalDistance / 2}
{@const secondSegmentWidth = horizontalDistance / 2}
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {fromLeft}px;
top: {fromTop + connection.offset / 4}px;
width: {firstSegmentWidth + connection.offset + 2}px;
"
></div>
<div
class="vertical-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {Math.min(fromTop + connection.offset / 4, toTop + connection.offset / 4)}px;
height: {Math.abs(toTop + connection.offset / 4 - (fromTop + connection.offset / 4))}px;
"
></div>
<div
class="horizontal-line"
style="
background-color: {connection.color};
left: {midLeft}px;
top: {toTop + connection.offset / 4}px;
width: {secondSegmentWidth - connection.offset}px;
"
></div>
{/each}
{/key}
</div>
<style>
.connection-renderer-root {
position: static;
pointer-events: none;
}
.vertical-line {
position: absolute;
width: 2px;
z-index: -10;
pointer-events: none;
}
.horizontal-line {
position: absolute;
height: 2px;
z-index: -10;
pointer-events: none;
}
</style>

View File

@@ -1,165 +0,0 @@
<script lang="ts">
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
import type { DoubleEleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { fightConnector } from "./connections.svelte.ts";
const { event, config }: { event: ExtendedEvent; config: DoubleEleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Double Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function indexRelations(ev: ExtendedEvent): Map<number, ResponseRelation[]> {
const map = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = map.get(rel.fight) ?? [];
list.push(rel);
map.set(rel.fight, list);
}
return map;
}
const relationsByFight = indexRelations(event);
const fightMap = new Map<number, EventFight>(event.fights.map((f) => [f.id, f]));
function collectBracket(startFinalId: number): EventFight[][] {
const finalFight = fightMap.get(startFinalId);
if (!finalFight) return [];
const bracketGroupId = finalFight.group?.id ?? null;
const stages: EventFight[][] = [];
let layer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (layer.length) {
stages.push(layer);
const next: EventFight[] = [];
for (const fight of layer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (!src) continue;
// Only traverse within the same bracket (group) to avoid cross-bracket pollution
if (bracketGroupId !== null && src.group?.id !== bracketGroupId) continue;
if (!visited.has(src.id)) {
visited.add(src.id);
next.push(src);
}
}
}
}
layer = next;
}
stages.reverse();
return stages;
}
const winnersStages = $derived(collectBracket(config.winnersFinalFight));
const losersStages = $derived(collectBracket(config.losersFinalFight));
const grandFinal = fightMap.get(config.grandFinalFight);
function stageName(count: number, isWinners: boolean): string {
switch (count) {
case 1:
return isWinners ? "Finale (W)" : "Finale (L)";
case 2:
return isWinners ? "Halbfinale (W)" : "Halbfinale (L)";
case 4:
return isWinners ? "Viertelfinale (W)" : "Viertelfinale (L)";
case 8:
return isWinners ? "Achtelfinale (W)" : "Achtelfinale (L)";
default:
return `Runde (${count}) ${isWinners ? "W" : "L"}`;
}
}
let connector: any;
const unsubscribe = fightConnector.subscribe((v) => (connector = v));
onDestroy(() => {
connector.clearAllConnections();
unsubscribe();
});
function buildConnections() {
if (!connector) return;
connector.clearAllConnections();
// Track offsets per source fight and team to stagger multiple outgoing lines for visual clarity
const fightTeamOffsetMap = new Map<string, number>();
const step = 8; // px separation between parallel lines
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromId = rel.fromFight.id;
const fromEl = document.getElementById(`fight-${fromId}`) as HTMLElement | null;
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
if (!fromEl || !toEl) continue;
// Use team-signed offsets so BLUE goes left (negative), RED goes right (positive)
const key = `${fromId}:${rel.team}`;
const index = fightTeamOffsetMap.get(key) ?? 0;
const sign = rel.team === "BLUE" ? -1 : 1;
const offset = sign * (index + 1) * step;
const color = rel.fromPlace === 0 ? "#60a5fa" : "#f87171";
connector.addConnection(fromEl, toEl, color, offset);
fightTeamOffsetMap.set(key, index + 1);
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if !grandFinal}
<p class="text-gray-400 italic">Konfiguration unvollständig (Grand Final fehlt).</p>
{:else}
{#key winnersStages.length + ":" + losersStages.length}
<!-- Build a grid where rows: winners (stages), losers (stages), with losers offset by one stage/column -->
{@const totalColumns = Math.max(winnersStages.length, losersStages.length + 1) + 1}
<div class="grid gap-x-16 gap-y-6 items-start" style={`grid-template-columns: repeat(${totalColumns}, max-content);`}>
<!-- Winners heading spans all columns -->
<h2 class="font-bold text-center">Winners Bracket</h2>
<!-- Winners stages in row 2 -->
{#each winnersStages as stage, i}
<div style={`grid-row: 2; grid-column: ${i + 1};`}>
<EventCard title={stageName(stage.length, true)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
<!-- Place Grand Final at the far right, aligned with winners row -->
<div style={`grid-row: 2; grid-column: ${totalColumns};`} class="self-center">
<EventCard title="Grand Final">
{#if grandFinal}
<EventFightChip fight={grandFinal} group={grandFinal.group ?? defaultGroup} />
{/if}
</EventCard>
</div>
<!-- Losers heading spans all columns -->
<h2 class="font-bold text-center" style="grid-row: 3; grid-column: 1 / {totalColumns};">Losers Bracket</h2>
<!-- Losers stages in row 4, offset by one column to the right -->
{#each losersStages as stage, j}
<div style={`grid-row: 4; grid-column: ${j + 2};`} class="mt-2">
<EventCard title={stageName(stage.length, false)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/key}
{/if}

View File

@@ -1,120 +0,0 @@
<script lang="ts">
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
import type { EleminationViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventFightChip from "./EventFightChip.svelte";
import { onMount, onDestroy, tick } from "svelte";
import { FightConnector, fightConnector } from "./connections.svelte.ts";
const { event, config }: { event: ExtendedEvent; config: EleminationViewConfig } = $props();
const defaultGroup: ResponseGroups = {
id: -1,
name: "Elimination",
pointsPerWin: 0,
pointsPerLoss: 0,
pointsPerDraw: 0,
type: "ELIMINATION_STAGE",
points: null,
};
function buildStages(ev: ExtendedEvent, finalFightId: number): EventFight[][] {
const fightMap = new Map<number, EventFight>(ev.fights.map((f) => [f.id, f]));
const relationsByFight = new Map<number, ResponseRelation[]>();
for (const rel of ev.relations) {
const list = relationsByFight.get(rel.fight) ?? [];
list.push(rel);
relationsByFight.set(rel.fight, list);
}
const finalFight = fightMap.get(finalFightId);
if (!finalFight) return [];
const stages: EventFight[][] = [];
let currentLayer: EventFight[] = [finalFight];
const visited = new Set<number>([finalFight.id]);
while (currentLayer.length) {
stages.push(currentLayer);
const nextLayer: EventFight[] = [];
for (const fight of currentLayer) {
const rels = relationsByFight.get(fight.id) ?? [];
for (const rel of rels) {
if (rel.type === "FIGHT" && rel.fromFight) {
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
if (src && !visited.has(src.id)) {
visited.add(src.id);
nextLayer.push(src);
}
}
}
}
currentLayer = nextLayer;
}
stages.reverse();
return stages;
}
function stageName(index: number, fights: EventFight[]): string {
const count = fights.length;
switch (count) {
case 1:
return `Finale`;
case 2:
return "Halbfinale";
case 4:
return "Viertelfinale";
case 8:
return "Achtelfinale";
case 16:
return "Sechzehntelfinale";
default:
return `Runde ${index + 1}`;
}
}
const stages = $derived(buildStages(event, config.finalFight));
const connector = $fightConnector;
onDestroy(() => {
connector.clearAllConnections();
});
function buildConnections() {
if (!connector) return;
connector.clearConnections();
for (const rel of event.relations) {
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
const fromEl = document.getElementById(`fight-${rel.fromFight.id}`) as HTMLElement | null;
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
if (fromEl && toEl) {
connector.addConnection(fromEl, toEl, "#9ca3af");
}
}
}
onMount(async () => {
await tick();
buildConnections();
});
</script>
{#if stages.length === 0}
<p class="text-gray-400 italic">Keine Eliminationsdaten gefunden.</p>
{:else}
<div class="flex gap-12">
{#each stages as stage, index}
<div class="flex flex-col justify-center">
<EventCard title={stageName(index, stage)}>
{#each stage as fight}
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/if}

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
title,
children,
}: {
title: string;
children: Snippet;
} = $props();
</script>
<div class="flex flex-col w-72 m-4 gap-1">
<div class="bg-gray-100 text-black font-bold px-2 rounded uppercase">
{title}
</div>
<div class="border border-gray-600 rounded p-2 flex flex-col gap-2 bg-slate-900">
{@render children()}
</div>
</div>

View File

@@ -1,13 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
children,
}: {
children: Snippet;
} = $props();
</script>
<div class="bg-neutral-900 border border-gray-700 rounded-lg overflow-hidden">
{@render children()}
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { EventFight, ResponseGroups } from "@components/types/event";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
import { fightConnector } from "./connections.svelte.ts";
let {
fight,
group,
}: {
fight: EventFight;
group: ResponseGroups;
} = $props();
function getScore(group: ResponseGroups, fight: EventFight, blueTeam: boolean): string {
if (!fight.hasFinished) return "-";
if (fight.ergebnis === 1) {
return blueTeam ? group.pointsPerWin.toString() : group.pointsPerLoss.toString();
} else if (fight.ergebnis === 2) {
return blueTeam ? group.pointsPerLoss.toString() : group.pointsPerWin.toString();
} else {
return group.pointsPerDraw.toString();
}
}
</script>
<EventCardOutline>
<EventTeamChip
team={{
id: -1,
kuerzel: new Date(fight.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
name: new Date(fight.start).toLocaleDateString([]),
color: "-1",
}}
time={true}
/>
<div id={"fight-" + fight.id}>
<EventTeamChip team={fight.blueTeam} score={getScore(group, fight, true)} showWinner={true} isWinner={fight.ergebnis === 1} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-blue" />
<EventTeamChip team={fight.redTeam} score={getScore(group, fight, false)} showWinner={true} isWinner={fight.ergebnis === 2} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-red" />
</div>
</EventCardOutline>

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@type/event.ts";
import type { EventViewConfig } from "./types";
import { onMount } from "svelte";
import { eventRepo } from "@components/repo/event";
import GroupDisplay from "./GroupDisplay.svelte";
import ConnectionRenderer from "./ConnectionRenderer.svelte";
import EleminationDisplay from "./EleminationDisplay.svelte";
import DoubleEleminationDisplay from "./DoubleEleminationDisplay.svelte";
const { event, viewConfig }: { event: ExtendedEvent; viewConfig: EventViewConfig } = $props();
let loadedEvent = $state<ExtendedEvent>(event);
onMount(() => {
loadEvent();
});
async function loadEvent() {
loadedEvent = await $eventRepo.getEvent(event.event.id.toString());
}
let selectedView = $state<string>(Object.keys(viewConfig)[0]);
</script>
<div class="flex gap-4 overflow-x-auto mb-4">
{#each Object.entries(viewConfig) as [name, view]}
<button
class="mb-8 border-gray-700 border rounded-lg p-4 w-60 hover:bg-gray-700 hover:shadow-lg transition-shadow hover:border-gray-500"
class:bg-gray-800={selectedView === name}
onclick={() => (selectedView = name)}
>
<h1 class="text-left">{view.name}</h1>
</button>
{/each}
</div>
{#if selectedView}
{@const view = viewConfig[selectedView]}
<div class="overflow-x-scroll relative">
<ConnectionRenderer />
{#if view.view.type === "GROUP"}
<GroupDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "ELEMINATION"}
<EleminationDisplay event={loadedEvent} config={view.view} />
{:else if view.view.type === "DOUBLE_ELEMINATION"}
<DoubleEleminationDisplay event={loadedEvent} config={view.view} />
{/if}
</div>
{/if}

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import type { Team } from "@type/team.ts";
import { fightConnector } from "./connections.svelte";
import { teamHoverService } from "./team-hover.svelte";
const {
team,
score = "",
time = false,
showWinner = false,
isWinner = false,
noWinner = false,
id,
}: {
team: Team;
score?: string;
time?: boolean;
showWinner?: boolean;
isWinner?: boolean;
noWinner?: boolean;
id?: string;
} = $props();
let hoverService = $teamHoverService;
</script>
<button
class="flex justify-between px-2 w-full team-chip text-left {time ? 'py-1 hover:bg-gray-800' : 'py-3 cursor-pointer'} team-{team.id} {hoverService.currentHover === team.id
? 'bg-gray-800'
: ''} {showWinner ? 'border-l-4' : ''} {showWinner && isWinner ? 'border-l-yellow-500' : 'border-l-gray-950'}"
onmouseenter={() => team.id === -1 || hoverService.setHover(team.id)}
onmouseleave={() => team.id === -1 || hoverService.clearHover()}
{id}
>
<div class="flex">
<div class="w-12 {time ? 'font-bold' : ''}">{team.kuerzel}</div>
<span class={time ? "font-mono" : "font-bold"}>{team.name}</span>
</div>
<div class="{showWinner && isWinner && 'font-bold'} {isWinner ? 'text-yellow-400' : ''} {noWinner ? 'font-bold' : ''}">
{score}
</div>
</button>
<style>
.team-chip:not(:last-child) {
@apply border-b border-b-gray-700;
}
</style>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event.ts";
import type { GroupViewConfig } from "./types";
import EventCard from "./EventCard.svelte";
import EventCardOutline from "./EventCardOutline.svelte";
import EventTeamChip from "./EventTeamChip.svelte";
import EventFightChip from "./EventFightChip.svelte";
const {
event,
config,
}: {
event: ExtendedEvent;
config: GroupViewConfig;
} = $props();
// Groups fights into rounds: a round starts at the first fight's start;
// all fights starting within 10 minutes (600_000 ms) of that are in the same round.
function detectRounds(fights: EventFight[]): EventFight[][] {
if (!fights || fights.length === 0) return [];
const TEN_MIN_MS = 10 * 60 * 1000;
const sorted = [...fights].sort((a, b) => a.start - b.start);
const rounds: EventFight[][] = [];
let currentRound: EventFight[] = [];
let roundStart = sorted[0].start;
for (const fight of sorted) {
if (fight.start - roundStart <= TEN_MIN_MS) {
currentRound.push(fight);
} else {
if (currentRound.length) rounds.push(currentRound);
currentRound = [fight];
roundStart = fight.start;
}
}
if (currentRound.length) rounds.push(currentRound);
return rounds;
}
</script>
{#each config.groups as groupId}
{@const group = event.groups.find((g) => g.id === groupId)!!}
{@const fights = event.fights.filter((f) => f.group?.id === groupId)}
{@const rounds = detectRounds(fights)}
<div class="flex">
<div>
<EventCard title={group.name}>
<EventCardOutline>
{#each Object.entries(group.points ?? {}).sort((v1, v2) => v2[1] - v1[1]) as points}
{@const [teamId, point] = points}
{@const team = event.teams.find((t) => t.id.toString() === teamId)!!}
<EventTeamChip {team} score={point.toString()} />
{/each}
</EventCardOutline>
</EventCard>
</div>
{#each rounds as round, index}
<div>
<EventCard title="Runde {index + 1}">
{#each round as fight}
<EventFightChip {fight} {group} />
{/each}
</EventCard>
</div>
{/each}
</div>
{/each}

View File

@@ -1,55 +0,0 @@
import { readonly, writable } from "svelte/store";
class FightConnection {
constructor(
public readonly fromElement: HTMLElement,
public readonly toElement: HTMLElement,
public readonly color: string = "white",
public readonly background: boolean,
public readonly offset: number = 0
) {}
}
export class FightConnector {
private connections: FightConnection[] = $state([]);
get allConnections(): FightConnection[] {
return this.connections;
}
get showedConnections(): FightConnection[] {
const showBackground = this.connections.some((conn) => !conn.background);
return showBackground ? this.connections.filter((conn) => !conn.background) : this.connections;
}
addTeamConnection(teamId: number): void {
const teamElements = document.getElementsByClassName(`team-${teamId}`);
const teamArray = Array.from(teamElements);
teamArray.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.top - rectB.top || rectA.left - rectB.left;
});
for (let i = 1; i < teamElements.length; i++) {
const fromElement = teamElements[i - 1] as HTMLElement;
const toElement = teamElements[i] as HTMLElement;
this.connections.push(new FightConnection(fromElement, toElement, "white", false));
}
}
addConnection(fromElement: HTMLElement, toElement: HTMLElement, color: string = "white", offset: number = 0): void {
this.connections.push(new FightConnection(fromElement, toElement, color, true, offset));
}
clearConnections(): void {
this.connections = this.connections.filter((conn) => conn.background);
}
clearAllConnections(): void {
this.connections = [];
}
}
const fightConnectorInternal = writable(new FightConnector());
export const fightConnector = readonly(fightConnectorInternal);

View File

@@ -1,19 +0,0 @@
import { get, writable } from "svelte/store";
import { fightConnector } from "./connections.svelte";
class TeamHoverService {
public currentHover = $state<number | undefined>(undefined);
private fightConnector = get(fightConnector);
setHover(teamId: number): void {
this.currentHover = teamId;
this.fightConnector.addTeamConnection(teamId);
}
clearHover(): void {
this.currentHover = undefined;
this.fightConnector.clearConnections();
}
}
export const teamHoverService = writable(new TeamHoverService());

View File

@@ -1,34 +0,0 @@
import { z } from "astro:content";
export const GroupViewSchema = z.object({
type: z.literal("GROUP"),
groups: z.array(z.number()),
});
export type GroupViewConfig = z.infer<typeof GroupViewSchema>;
export const EleminationViewSchema = z.object({
type: z.literal("ELEMINATION"),
finalFight: z.number(),
});
export type EleminationViewConfig = z.infer<typeof EleminationViewSchema>;
// Double elimination config: needs final fight (grand final) and entry fights for winners & losers brackets
export const DoubleEleminationViewSchema = z.object({
type: z.literal("DOUBLE_ELEMINATION"),
winnersFinalFight: z.number(), // Final fight of winners bracket (feeds into grand final)
losersFinalFight: z.number(), // Final fight of losers bracket (feeds into grand final)
grandFinalFight: z.number(), // Grand final fight id
});
export type DoubleEleminationViewConfig = z.infer<typeof DoubleEleminationViewSchema>;
export const EventViewConfigSchema = z.record(
z.object({
name: z.string(),
view: z.discriminatedUnion("type", [GroupViewSchema, EleminationViewSchema, DoubleEleminationViewSchema]),
})
);
export type EventViewConfig = z.infer<typeof EventViewConfigSchema>;

View File

@@ -1,53 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import type { RouteDefinition } from "svelte-spa-router";
import Router from "svelte-spa-router";
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
import Players from "@components/moderator/pages/players/Players.svelte";
import Events from "@components/moderator/pages/events/Events.svelte";
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = {
"/": Dashboard,
"/events": Events,
"/players": Players,
"/event/:id": Event,
"/event/:id/generate": Generator,
"/pages": Pages,
};
</script>
<div class="flex flex-col bg-background min-w-full min-h-screen">
<div class="border-b">
<div class="flex h-16 items-center px-4">
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
<NavLinks />
</div>
</div>
<Tooltip.Provider>
<Router {routes} />
</Tooltip.Provider>
</div>

View File

@@ -1,42 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import type {ShortEvent} from "@type/event.ts";
import {Card, CardContent, CardHeader, CardTitle} from "@components/ui/card";
let { event }: { event: ShortEvent } = $props();
let sameDate = $derived(new Intl.DateTimeFormat().format(event.start) === new Intl.DateTimeFormat().format(event.end));
</script>
<Card>
<CardHeader>
<CardTitle>{event.name}</CardTitle>
</CardHeader>
<CardContent>
{#if !sameDate}
<p>Startet: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>Endet: {new Intl.DateTimeFormat().format(event.end)}</p>
{:else}
<p>Am: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>&nbsp;</p>
{/if}
</CardContent>
</Card>

View File

@@ -1,170 +0,0 @@
<script lang="ts">
import GroupSelector from "./GroupSelector.svelte";
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { fromAbsolute } from "@internationalized/date";
import { Label } from "@components/ui/label";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { gamemodes, maps } from "@components/stores/stores";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDown, Check } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import type { Snippet } from "svelte";
import { Input } from "@components/ui/input";
import TeamSelector from "./TeamSelector.svelte";
import type { EventModel } from "../pages/event/eventmodel.svelte";
let {
fight,
actions,
onSave,
data,
}: {
fight: EventFight | null;
actions: Snippet<[boolean, () => void]>;
onSave: (fight: EventFightEdit) => void;
data: EventModel;
} = $props();
let fightModus = $state(fight?.spielmodus);
let fightMap = $state(fight?.map);
let fightBlueTeam = $state(fight?.blueTeam);
let fightRedTeam = $state(fight?.redTeam);
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(data.event.start, "Europe/Berlin"));
let fightErgebnis = $state(fight?.ergebnis ?? 0);
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
let fightGroup = $state(fight?.group?.id ?? null);
let mapsStore = $derived(maps(fightModus ?? "null"));
let gamemodeSelectOpen = $state(false);
let mapSelectOpen = $state(false);
let dirty = $derived(
fightModus !== fight?.spielmodus ||
fightMap !== fight?.map ||
fightBlueTeam?.id !== fight?.blueTeam?.id ||
fightRedTeam?.id !== fight?.redTeam?.id ||
fightStart.toDate().getTime() !== fight?.start ||
fightErgebnis !== fight?.ergebnis ||
fightSpectatePort !== (fight?.spectatePort?.toString() ?? null) ||
fightGroup !== (fight?.group?.id ?? null)
);
let loading = $state(false);
async function submit() {
loading = true;
try {
await onSave({
spielmodus: fightModus!,
map: fightMap!,
blueTeam: fightBlueTeam!,
redTeam: fightRedTeam!,
start: fightStart?.toDate().getTime(),
ergebnis: fightErgebnis,
spectatePort: fightSpectatePort ? +fightSpectatePort : null,
group: fightGroup,
});
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col gap-2">
<Label for="fight-modus">Modus</Label>
<Popover bind:open={gamemodeSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$gamemodes.find((value) => value === fightModus) || fightModus || "Select a modus type..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Fight Modus..." />
<CommandList>
<CommandEmpty>No fight modus found.</CommandEmpty>
<CommandGroup>
{#each $gamemodes as modus}
<CommandItem
value={modus}
onSelect={() => {
fightModus = modus;
gamemodeSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", modus !== fightModus && "text-transparent")} />
{modus}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-map">Map</Label>
<Popover bind:open={mapSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$mapsStore.find((value) => value === fightMap) || fightMap || "Select a map..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Maps..." />
<CommandList>
<CommandEmpty>No map found.</CommandEmpty>
<CommandGroup>
{#each $mapsStore as map}
<CommandItem
value={map}
onSelect={() => {
fightMap = map;
mapSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", map !== fightMap && "text-transparent")} />
{map}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label>Blue Team</Label>
<TeamSelector bind:selectedTeam={fightBlueTeam} {data} fightId={fight?.id} team="BLUE" />
<Label>Red Team</Label>
<TeamSelector bind:selectedTeam={fightRedTeam} {data} fightId={fight?.id} team="RED" />
<Label>Start</Label>
<DateTimePicker bind:value={fightStart} />
{#if fight !== null}
<Label for="fight-ergebnis">Ergebnis</Label>
<Select type="single" value={fightErgebnis?.toString()} onValueChange={(v) => (fightErgebnis = +v)}>
<SelectTrigger>
{fightErgebnis === 0 ? "Unentschieden" : (fightErgebnis === 1 ? fightBlueTeam?.name : fightRedTeam?.name) + " gewinnt"}
</SelectTrigger>
<SelectContent>
<SelectItem value={"0"}>Unentschieden</SelectItem>
<SelectItem value={"1"}>{fightBlueTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
<SelectItem value={"2"}>{fightRedTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
</SelectContent>
</Select>
{/if}
<Label for="fight-group">Gruppe</Label>
<GroupSelector event={data.event} bind:value={fightGroup} bind:groups={data.groups}></GroupSelector>
<Label for="spectate-port">Spectate Port</Label>
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
</div>
{@render actions(dirty && !loading, submit)}

View File

@@ -1,78 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ResponseGroups, GroupUpdateEdit } from "@type/event";
import { Label } from "@components/ui/label";
import { Input } from "@components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
const {
group,
actions,
onSave,
}: {
group: ResponseGroups | null;
actions: Snippet<[boolean, () => void]>;
onSave: (groupData: GroupUpdateEdit) => void;
} = $props();
let groupName = $state(group?.name ?? "");
let groupType = $state(group?.type ?? "GROUP_STAGE");
let pointsPerWin = $state(group?.pointsPerWin ?? 3);
let pointsPerLoss = $state(group?.pointsPerLoss ?? 0);
let pointsPerDraw = $state(group?.pointsPerDraw ?? 1);
let canSave = $derived(groupName.length > 0 && (groupType === "GROUP_STAGE" || groupType === "ELIMINATION_STAGE") && pointsPerWin !== null && pointsPerLoss !== null && pointsPerDraw !== null);
let dirty = $derived(
groupName !== (group ? group.name : "") ||
groupType !== (group ? group.type : "GROUP_STAGE") ||
pointsPerWin !== (group ? group.pointsPerWin : 3) ||
pointsPerLoss !== (group ? group.pointsPerLoss : 0) ||
pointsPerDraw !== (group ? group.pointsPerDraw : 1)
);
function submit() {
onSave({
name: groupName,
type: groupType,
pointsPerWin: pointsPerWin,
pointsPerLoss: pointsPerLoss,
pointsPerDraw: pointsPerDraw,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="group-name">Name</Label>
<Input id="group-name" bind:value={groupName} placeholder="z.B. Gruppenphase A" />
<Label for="group-type">Typ</Label>
<Select
value={groupType}
type="single"
onValueChange={(v) => {
if (v) groupType = v as "GROUP_STAGE" | "ELIMINATION_STAGE";
}}
>
<SelectTrigger id="group-type" placeholder="Wähle einen Gruppentyp">
{groupType === "GROUP_STAGE" ? "Gruppenphase" : "Eliminierungsphase"}
</SelectTrigger>
<SelectContent>
<SelectItem value="GROUP_STAGE">Gruppenphase</SelectItem>
<SelectItem value="ELIMINATION_STAGE">Eliminierungsphase</SelectItem>
</SelectContent>
</Select>
{#if groupType === "GROUP_STAGE" && group !== null}
<Label for="points-win">Punkte pro Sieg</Label>
<Input id="points-win" type="number" bind:value={pointsPerWin} placeholder="3" />
<Label for="points-loss">Punkte pro Niederlage</Label>
<Input id="points-loss" type="number" bind:value={pointsPerLoss} placeholder="0" />
<Label for="points-draw">Punkte pro Unentschieden</Label>
<Input id="points-draw" type="number" bind:value={pointsPerDraw} placeholder="1" />
{/if}
</div>
{@render actions(group === null ? canSave : dirty, submit)}

View File

@@ -1,103 +0,0 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDownIcon, PlusIcon, CheckIcon, MinusIcon } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "./GroupEdit.svelte";
import { eventRepo } from "@components/repo/event";
let {
event,
groups = $bindable(),
value = $bindable(),
}: {
event: SWEvent;
groups: ResponseGroups[];
value: number | null;
} = $props();
let selectedGroup = $derived(groups.find((group) => group.id === value));
let createOpen = $state(false);
let groupSelectOpen = $state(false);
async function handleGroupSave(group: GroupUpdateEdit) {
let g = await $eventRepo.createGroup(event.id, group);
groups.push(g);
value = g.id;
createOpen = false;
groupSelectOpen = false;
}
</script>
<Dialog bind:open={createOpen}>
<Popover bind:open={groupSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button id="fight-group" variant="outline" class="justify-between" {...props} role="combobox">
{selectedGroup?.name || "Keine Gruppe"}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Gruppe suchen..." />
<CommandList>
<CommandGroup>
<CommandItem value={"new"} onSelect={() => (createOpen = true)}>
<PlusIcon class={"mr-2 size-4"} />
Neue Gruppe
</CommandItem>
<CommandGroup heading="Gruppen">
<CommandItem
value={"none"}
onSelect={() => {
value = null;
groupSelectOpen = false;
}}
>
{#if value === null}
<CheckIcon class={"mr-2 size-4"} />
{:else}
<MinusIcon class={"mr-2 size-4"} />
{/if}
Keine Gruppe
</CommandItem>
{#each groups as group}
<CommandItem
value={group.id.toString()}
onSelect={() => {
value = group.id;
groupSelectOpen = false;
}}
>
<CheckIcon class={cn("mr-2 size-4", value !== group.id && "text-transparent")} />
{group.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
<DialogDescription>Hier kannst du eine neue Gruppe erstellen</DialogDescription>
</DialogHeader>
<GroupEdit group={null} onSave={handleGroupSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>

View File

@@ -1,253 +0,0 @@
<script lang="ts">
import type { ResponseRelation } from "@components/types/event";
import type { Team } from "@components/types/team";
import { Button } from "@components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
import { cn } from "@components/utils";
import { Check, ChevronsUpDown, GitPullRequestArrow, Plus } from "lucide-svelte";
import type { EventModel } from "../pages/event/eventmodel.svelte";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Label } from "@components/ui/label";
import { eventRepo } from "@components/repo/event";
interface Props {
selectedTeam: Team | undefined;
open?: boolean;
team: "BLUE" | "RED";
data: EventModel;
fightId?: number;
onSelect?: (team: Team) => void;
}
let { selectedTeam = $bindable(), data, team, open = $bindable(false), fightId, onSelect }: Props = $props();
const currentRelation = $derived(data.relations.find((r) => r.fight === fightId && r.team === team));
let fromType = $state<"FIGHT" | "GROUP">(currentRelation?.type ?? "FIGHT");
let fromFight = $state<string | undefined>(currentRelation?.fromFight?.id?.toString());
let fromFightData = $derived(data.fights.find((f) => f.id.toString() === fromFight));
let fromGroup = $state<string | undefined>(currentRelation?.fromGroup?.id?.toString());
let fromGroupData = $derived(data.groups.find((g) => g.id.toString() === fromGroup));
let fromPlace = $state<string | undefined>(currentRelation?.fromPlace?.toString());
let relationOpen = $state(false);
async function saveRelation() {
relationOpen = false;
if (currentRelation === undefined) {
await $eventRepo.createRelation(data.event.id, {
fightId: fightId!,
team,
fromType,
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
fromPlace: parseInt(fromPlace!),
});
} else {
await $eventRepo.updateRelation(data.event.id, currentRelation.id, {
from: {
fromType,
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
fromPlace: parseInt(fromPlace!),
},
});
}
data.relations = await $eventRepo.listRelations(data.event.id);
reset();
}
async function clearRelation() {
relationOpen = false;
if (currentRelation !== undefined) {
await $eventRepo.deleteRelation(data.event.id, currentRelation.id);
data.relations = await $eventRepo.listRelations(data.event.id);
}
reset();
}
function reset() {
fromType = currentRelation?.type ?? "FIGHT";
fromFight = currentRelation?.fromFight?.id.toString();
fromGroup = currentRelation?.fromGroup?.id.toString();
fromPlace = currentRelation?.fromPlace.toString();
}
let canSave = $derived(
(fromType !== currentRelation?.type ||
fromFight !== (currentRelation?.fromFight?.id.toString() ?? "") ||
fromGroup !== (currentRelation?.fromGroup?.id.toString() ?? "") ||
fromPlace !== (currentRelation?.fromPlace.toString() ?? "")) &&
((fromType === "FIGHT" && fromFight !== "" && fromPlace !== "") || (fromType === "GROUP" && fromGroup !== "" && fromPlace !== ""))
);
</script>
<div class="flex gap-2">
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{#if selectedTeam?.id === -1}
???
{:else if selectedTeam?.id === 0}
PUB
{:else}
{data.teams.find((v) => v.id === selectedTeam?.id)?.name || selectedTeam?.name || "Select a team..."}
{/if}
{#if currentRelation !== undefined}
({#if currentRelation.type === "FIGHT"}
{currentRelation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von {currentRelation.fromFight?.blueTeam.name} vs {currentRelation.fromFight?.redTeam.name} ({new Date(
currentRelation.fromFight?.start ?? 0
).toLocaleTimeString("de-DE", {
timeStyle: "short",
})})
{:else}
{currentRelation.fromPlace + 1}. Platz von {currentRelation.fromGroup?.name}
{/if})
{/if}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Teams..." />
<CommandList>
<CommandEmpty>No team found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"-1"}
onSelect={() => {
selectedTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
onSelect?.(selectedTeam);
open = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
selectedTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
onSelect?.(selectedTeam);
open = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each data.teams as team}
<CommandItem
value={team.name}
onSelect={() => {
selectedTeam = team;
onSelect?.(selectedTeam);
open = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== selectedTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover bind:open={relationOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Tooltip>
<TooltipTrigger>
<Button {...props} size="icon" variant={currentRelation !== undefined ? "default" : "outline"} disabled={fightId === undefined}>
<GitPullRequestArrow />
</Button>
</TooltipTrigger>
<TooltipContent>Kampfverbindung</TooltipContent>
</Tooltip>
{/snippet}
</PopoverTrigger>
<PopoverContent>
<Tabs bind:value={fromType}>
<TabsList>
<TabsTrigger value="FIGHT">Kampf</TabsTrigger>
<TabsTrigger value="GROUP">Gruppe</TabsTrigger>
</TabsList>
<TabsContent value="FIGHT">
<Label>Kampf</Label>
<Select bind:value={fromFight} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromFightData
? `${new Date(fromFightData.start).toLocaleString("de-DE", { timeStyle: "short" })}: ${fromFightData.blueTeam.kuerzel} vs. ${fromFightData.redTeam.kuerzel}`
: "Kampf auswählen..."}
</SelectTrigger>
<SelectContent>
{#each data.fights.filter((v) => v.id !== fightId) as fight (fight.id)}
<SelectItem value={fight.id.toString()}
>{new Date(fight.start).toLocaleString("de-DE", {
timeStyle: "short",
})}: {fight.blueTeam.kuerzel} vs. {fight.redTeam.kuerzel}</SelectItem
>
{/each}
</SelectContent>
</Select>
<Label>Team</Label>
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromPlace ? (fromPlace === "0" ? "Gewinner" : "Verlierer") : "Platz auswählen..."}
</SelectTrigger>
<SelectContent>
<SelectItem value={"0"}>Gewinner</SelectItem>
<SelectItem value={"1"}>Verlierer</SelectItem>
</SelectContent>
</Select>
</TabsContent>
<TabsContent value="GROUP">
<Label>Gruppe</Label>
<Select bind:value={fromGroup} type="single" disabled={data.groups.length === 0}>
<SelectTrigger>
{fromGroupData ? fromGroupData.name : "Kampf auswählen..."}
</SelectTrigger>
<SelectContent>
{#each data.groups as group (group.id)}
<SelectItem value={group.id.toString()}>{group.name}</SelectItem>
{/each}
</SelectContent>
</Select>
<Label>Platz</Label>
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
<SelectTrigger>
{fromPlace ? `${parseInt(fromPlace) + 1}. Platz` : "Platz auswählen..."}
</SelectTrigger>
<SelectContent>
{#each Array(32) as _, i}
<SelectItem value={i.toString()}>{i + 1}. Platz</SelectItem>
{/each}
</SelectContent>
</Select>
</TabsContent>
</Tabs>
<div class="flex justify-end gap-2 mt-2">
<Button onclick={clearRelation} variant="destructive">Löschen</Button>
<Button onclick={saveRelation} disabled={!canSave}>Übernehmen</Button>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,30 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { location } from "svelte-spa-router";
</script>
<nav class="flex items-center space-x-4 lg:space-x-6 mx-6">
<a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/"}> Dashboard </a>
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a>
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
</nav>

View File

@@ -1,22 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<div class="p-4">
<h1 class="font-bold text-xl">SteamWar Dashboard</h1>
</div>

View File

@@ -1,51 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { eventRepo } from "@repo/event.ts";
import EventView from "@components/moderator/pages/event/EventView.svelte";
import type { ExtendedEvent } from "@components/types/event";
import { onMount } from "svelte";
import { EventModel } from "./eventmodel.svelte";
interface Props {
params: { id: number };
}
let { params }: Props = $props();
let id = params.id;
let data: EventModel | undefined = $state(undefined);
let loaded = $state(false);
onMount(async () => {
refresh();
});
async function refresh() {
data = new EventModel(await $eventRepo.getEvent(id.toString()));
loaded = true;
}
</script>
{#if loaded}
<EventView bind:event={data!!} {refresh} />
{:else}
<p>Loading...</p>
{/if}

View File

@@ -1,152 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import type { SWEvent } from "@type/event.ts";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { fromAbsolute } from "@internationalized/date";
import { Button, buttonVariants } from "@components/ui/button";
import { ChevronsUpDown } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { schemTypes } from "@stores/stores.ts";
import Check from "lucide-svelte/icons/check";
import { cn } from "@components/utils.ts";
import { Switch } from "@components/ui/switch";
import { eventRepo } from "@repo/event.ts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@components/ui/alert-dialog";
const { event }: { event: SWEvent } = $props();
let rootEvent: SWEvent = $state(event);
let eventName = $state(rootEvent.name);
let eventDeadline = $state(fromAbsolute(rootEvent.deadline, "Europe/Berlin"));
let eventStart = $state(fromAbsolute(rootEvent.start, "Europe/Berlin"));
let eventEnd = $state(fromAbsolute(rootEvent.end, "Europe/Berlin"));
let eventTeamSize = $state(rootEvent.maxTeamMembers);
let eventSchematicType = $state(rootEvent.schemType);
let eventPublicsOnly = $state(rootEvent.publicSchemsOnly);
let dirty = $derived(
eventName !== rootEvent.name ||
eventDeadline.toDate().getTime() !== rootEvent.deadline ||
eventStart.toDate().getTime() !== rootEvent.start ||
eventEnd.toDate().getTime() !== rootEvent.end ||
eventTeamSize !== rootEvent.maxTeamMembers ||
eventSchematicType !== rootEvent.schemType ||
eventPublicsOnly !== rootEvent.publicSchemsOnly
);
async function updateEvent() {
rootEvent = await $eventRepo.updateEvent(event.id.toString(), {
name: eventName,
deadline: eventDeadline.toDate().getTime(),
start: eventStart.toDate().getTime(),
end: eventEnd.toDate().getTime(),
maxTeamMembers: eventTeamSize,
schemType: eventSchematicType,
publicSchemsOnly: eventPublicsOnly,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="event-name">Name</Label>
<Input id="event-name" bind:value={eventName} />
<Label>Deadline</Label>
<DateTimePicker bind:value={eventDeadline} />
<Label>Start</Label>
<DateTimePicker bind:value={eventStart} />
<Label>End</Label>
<DateTimePicker bind:value={eventEnd} />
<Label for="event-size">Teamsize</Label>
<Input id="event-size" bind:value={eventTeamSize} type="number" />
<Label>Schematic Type</Label>
<Popover>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$schemTypes.find((value) => value.db === eventSchematicType)?.name || eventSchematicType || "Select a schematic type..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search schematic types..." />
<CommandList>
<CommandEmpty>No schematic type found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"null"}
onSelect={() => {
eventSchematicType = null;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
Keinen
</CommandItem>
{#each $schemTypes as type}
<CommandItem
value={type.db}
onSelect={() => {
eventSchematicType = type.db;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== type.db && "text-transparent")} />
{type.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="event-publics">Publics Schematics Only</Label>
<Switch id="event-publics" bind:checked={eventPublicsOnly} />
<div class="flex flex-row justify-end border-t pt-2 gap-4">
<AlertDialog>
<AlertDialogTrigger class={buttonVariants({ variant: "destructive" })}>Delete</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button disabled={!dirty} onclick={updateEvent}>Update</Button>
</div>
</div>

View File

@@ -1,325 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import FightEditRow from "./FightEditRow.svelte";
import type { EventFightEdit } from "@type/event";
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
import { columns } from "./columns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Checkbox } from "@components/ui/checkbox";
import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarTrigger, MenubarSub, MenubarSubTrigger, MenubarSubContent } from "@components/ui/menubar";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@components/repo/event";
import GroupEditDialog from "./GroupEditDialog.svelte";
import GroupResultsDialog from "./GroupResultsDialog.svelte";
import type { ResponseGroups } from "@type/event";
import { EditIcon, GroupIcon, LinkIcon } from "lucide-svelte";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
import { fightRepo } from "@components/repo/fight";
import type { EventModel } from "./eventmodel.svelte";
let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]);
let selection = $state<RowSelectionState>({});
const table = createSvelteTable({
get data() {
return data.fights;
},
initialState: {
columnOrder: ["auswahl", "begegnung", "group"],
},
state: {
get sorting() {
return sorting;
},
get columnFilters() {
return columnFilters;
},
get grouping() {
return ["group"];
},
get rowSelection() {
return selection;
},
},
onSortingChange: (updater) => {
if (typeof updater === "function") {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
onRowSelectionChange: (updater) => {
if (typeof updater === "function") {
selection = updater(selection);
} else {
selection = updater;
}
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getGroupedRowModel: getGroupedRowModel(),
groupedColumnMode: "remove",
getRowId: (row) => row.id.toString(),
});
let createOpen = $state(false);
let editGroupOpen = $state(false);
let selectedGroup: ResponseGroups | null = $state(null);
let groupResultsOpen = $state(false);
let selectedGroupForResults: ResponseGroups | null = $state(null);
let groupChangeOpen = $state(false);
let groupChangeSelected: number | null = $state(null);
async function handleSave(fight: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fight,
blueTeam: fight.blueTeam.id,
redTeam: fight.redTeam.id,
});
refresh();
createOpen = false;
}
function openGroupEditDialog(group: ResponseGroups) {
selectedGroup = group;
editGroupOpen = true;
}
function openGroupResultsDialog(group: ResponseGroups) {
selectedGroupForResults = group;
groupResultsOpen = true;
}
</script>
<Dialog bind:open={createOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight Erstellen</DialogTitle>
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
</DialogHeader>
<FightEdit fight={null} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
{#if selectedGroup}
<GroupEditDialog bind:open={editGroupOpen} group={selectedGroup} event={data.event} bind:groups={data.groups} />
{/if}
{#if selectedGroupForResults}
<GroupResultsDialog bind:open={groupResultsOpen} group={selectedGroupForResults} teams={data.teams} fights={data.fights} />
{/if}
<Dialog bind:open={groupChangeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Ändern</DialogTitle>
<DialogDescription>Hier kannst du die Gruppe der ausgewählten Kämpfe ändern</DialogDescription>
</DialogHeader>
<GroupSelector event={data.event} bind:groups={data.groups} bind:value={groupChangeSelected} />
<DialogFooter>
<Button
onclick={async () => {
groupChangeOpen = false;
let group = data.groups.find((g) => g.id === groupChangeSelected);
if (group) {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.updateFight(data.event.id, g.id, {
group: group.id,
spielmodus: null,
map: null,
blueTeam: null,
redTeam: null,
start: null,
spectatePort: null,
});
}
refresh();
}
}}>Speichern</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex items-center justify-between">
<Menubar>
<MenubarMenu>
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
<MenubarContent>
<MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
<MenubarItem disabled>Spectate Port Ändern</MenubarItem>
<MenubarItem
onclick={async () => {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.deleteFight(data.event.id, g.id);
}
refresh();
}}>Kämpfe Löschen</MenubarItem
>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Erstellen</MenubarTrigger>
<MenubarContent>
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
<MenubarGroup>
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
<a href="#/event/{data.event.id}/generate">
<MenubarItem>Gruppenphase</MenubarItem>
</a>
</MenubarGroup>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger disabled={!data.groups.length}>Gruppen</MenubarTrigger>
<MenubarContent>
{#each data.groups as group (group.id)}
<MenubarSub>
<MenubarSubTrigger>
{group.name}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem onclick={() => openGroupEditDialog(group)}>Bearbeiten</MenubarItem>
<MenubarItem onclick={() => openGroupResultsDialog(group)}>Gruppen Ergebnisse</MenubarItem>
</MenubarSubContent>
</MenubarSub>
{/each}
</MenubarContent>
</MenubarMenu>
</Menubar>
<Button variant="outline" class="ml-4" onclick={refresh}>Neu laden</Button>
</div>
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
<TableHead></TableHead>
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)}
{#if groupRow.getIsGrouped()}
{@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))}
<TableRow class="font-bold">
<TableCell colspan={columns.length - 1}>
<Checkbox
checked={groupRow.getIsSelected()}
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
onCheckedChange={() => groupRow.toggleSelected()}
class="mr-4"
/>
{group?.name ?? "Keine Gruppe"}
</TableCell>
<TableCell class="text-right">
{#if group}
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group!)}>
<EditIcon />
</Button>
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group!)}>
<GroupIcon />
</Button>
{/if}
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<LinkIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<group-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Punkte Tabelle</DropdownMenuItem
>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<fight-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Kampf Tabelle</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{#each groupRow.subRows as row (row.id)}
<TableRow data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
<TableCell class="text-right">
<FightEditRow fight={row.original} {data} onupdate={(update) => (data._fights = data._fights.map((v) => (v.id === update.id ? update : v)))} {refresh}></FightEditRow>
</TableCell>
</TableRow>
{/each}
{:else}
<TableRow data-state={groupRow.getIsSelected() && "selected"}>
{#each groupRow.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{/if}
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No results.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>

View File

@@ -1,46 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
import EventFightList from "@components/moderator/pages/event/EventFightList.svelte";
import RefereesList from "@components/moderator/pages/event/RefereesList.svelte";
import TeamTable from "@components/moderator/pages/event/TeamTable.svelte";
import type { EventModel } from "./eventmodel.svelte";
let { event = $bindable(), refresh }: { event: EventModel; refresh: () => void } = $props();
</script>
<div class="flex flex-col m-4 p-4 rounded-md border gap-4">
<div class="flex flex-col md:flex-row">
<div class="md:w-1/3">
<h1 class="text-2xl font-bold mb-4">{event.event.name}</h1>
<EventEdit event={event.event} />
</div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Teams</h2>
<TeamTable bind:event />
</div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Referees</h2>
<RefereesList {event} />
</div>
</div>
<EventFightList bind:data={event} {refresh} />
</div>

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
import { Button } from "@components/ui/button";
import { EditIcon, CopyIcon } from "lucide-svelte";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import type { Team } from "@components/types/team";
import { fightRepo } from "@components/repo/fight";
import { eventRepo } from "@components/repo/event";
import type { EventModel } from "./eventmodel.svelte";
let { fight, onupdate, refresh, data }: { fight: EventFight; onupdate: (update: EventFight) => void; refresh: () => void; data: EventModel } = $props();
let editOpen = $state(false);
let duplicateOpen = $state(false);
async function handleSave(fightData: EventFightEdit) {
let f = await $fightRepo.updateFight(data.event.id, fight.id, {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
group: fightData.group ?? -1,
});
onupdate(f);
editOpen = false;
}
async function handlyCopy(fightData: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
});
refresh();
duplicateOpen = false;
}
async function handleDelete() {
await $fightRepo.deleteFight(data.event.id, fight.id);
refresh();
}
</script>
<div>
<Dialog bind:open={editOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<EditIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight bearbeiten</DialogTitle>
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
</DialogHeader>
<FightEdit {fight} {data} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
<Dialog bind:open={duplicateOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<CopyIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight duplizieren</DialogTitle>
<DialogDescription>Hier kannst du die Daten des duplizierten Fights ändern</DialogDescription>
</DialogHeader>
<FightEdit {fight} {data} onSave={handlyCopy}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
</div>

View File

@@ -1,45 +0,0 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "@components/moderator/components/GroupEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@repo/event";
let { group, groups = $bindable(), open = $bindable(), event }: { group: ResponseGroups; groups: ResponseGroups[]; open?: boolean; event: SWEvent } = $props();
async function handleSave(groupData: GroupUpdateEdit) {
if (!group) return;
const updatedGroup = await $eventRepo.updateGroup(event.id.toString(), group.id.toString(), groupData);
groups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
open = false;
}
async function handleDelete() {
if (!group) return;
await $eventRepo.deleteGroup(event.id.toString(), group.id.toString());
groups = groups.filter((g) => g.id !== group.id);
open = false;
}
</script>
{#if group}
<Dialog bind:open>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Bearbeiten: {group.name}</DialogTitle>
<DialogDescription>Hier kannst du die Gruppendetails bearbeiten.</DialogDescription>
</DialogHeader>
<GroupEdit {group} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter class="flex justify-between">
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Abbrechen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</div>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>
{/if}

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseTeam } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Button } from "@components/ui/button";
import type { Team } from "@components/types/team";
let { open = $bindable(), group, teams, fights }: { open?: boolean; group: ResponseGroups; teams: Team[]; fights: EventFight[] } = $props();
</script>
<Dialog bind:open>
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Ergebnisse: {group?.name}</DialogTitle>
<DialogDescription>
Punkte: Sieg: {group?.pointsPerWin}, Unentschieden: {group?.pointsPerDraw}, Niederlage: {group?.pointsPerLoss}
</DialogDescription>
</DialogHeader>
{#if group.points !== null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead class="text-right">Spiele</TableHead>
<TableHead class="text-right">Punkte</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each Object.entries(group.points).toSorted((a, b) => b[1] - a[1]) as [teamIdString, points] (teamIdString)}
{@const teamId = Number(teamIdString)}
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam | undefined}
{@const playedGames = fights.filter((f) => f.hasFinished && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
<TableRow>
<TableCell>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
<TableCell class="text-right">{playedGames}</TableCell>
<TableCell class="text-right font-bold">{points}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-center py-4">Noch keine Ergebnisse für diese Gruppe vorhanden oder keine Spiele zugeordnet.</p>
{/if}
<DialogFooter>
<Button variant="outline" onclick={() => (open = false)}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,86 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command/index.js";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover/index.js";
import { Button } from "@components/ui/button/index.js";
import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event";
import { players } from "@stores/stores";
const { event }: { event: ExtendedEvent } = $props();
let referees = $state(event.referees);
async function addReferee(value: string) {
await $eventRepo.updateReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
async function removeReferee(value: string) {
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
let playerSearch = $state("");
</script>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each referees as referee (referee.uuid)}
<TableRow>
<TableCell>{referee.name}</TableCell>
<TableCell>
<Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Hinzufügen</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={playerSearch} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No Players found :(</CommandEmpty>
<CommandGroup heading="Players">
{#each $players
.filter((v) => v.name.toLowerCase().includes(playerSearch.toLowerCase()))
.filter((v, i) => i < 50)
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table>

View File

@@ -1,91 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Button } from "@components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
import { eventRepo } from "@repo/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { teams } from "@components/stores/stores";
import type { EventModel } from "./eventmodel.svelte";
let { event = $bindable() }: { event: EventModel } = $props();
async function addTeam(value: number) {
await $eventRepo.updateTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
async function removeTeam(value: number) {
await $eventRepo.deleteTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
let teamSearch = $state("");
</script>
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead>Name</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each event.teams as t (t.id)}
<TableRow>
<TableCell>{t.kuerzel}</TableCell>
<TableCell>{t.name}</TableCell>
<TableCell>
<Button onclick={() => removeTeam(t.id)} variant="outline" size="sm">{t.name} abmelden</Button>
</TableCell>
</TableRow>
{/each}
{#if event.teams.length === 0}
<TableRow>
<TableCell class="text-center col-span-3">No teams available</TableCell>
</TableRow>
{/if}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Team Anmelden</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={teamSearch} placeholder="Search teams..." />
<CommandList>
<CommandEmpty>No teams found :(</CommandEmpty>
<CommandGroup heading="Teams">
{#each $teams
.filter((v) => v.name.includes(teamSearch))
.filter((v) => !event.teams.some((k) => k.id === v.id))
.filter((v, i) => i < 50) as t (t.id)}
<CommandItem value={t.id.toString()} onSelect={() => addTeam(t.id)} keywords={[t.name, t.kuerzel]}>{t.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table>

View File

@@ -1,104 +0,0 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2025 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Checkbox } from "@components/ui/checkbox";
import { renderComponent } from "@components/ui/data-table";
import type { ColumnDef } from "@tanstack/table-core";
import type { EventFightModel } from "./eventmodel.svelte";
export const columns: ColumnDef<EventFightModel>[] = [
{
id: "auswahl",
header: ({ table }) => {
return renderComponent(Checkbox, {
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onCheckedChange: () => {
if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
const now = new Date();
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.start) > now);
if (rows.length > 0) {
rows.forEach((row) => {
row.toggleSelected();
});
} else {
table.toggleAllRowsSelected(true);
}
} else if (table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
table.toggleAllRowsSelected(true);
} else {
table.toggleAllRowsSelected(false);
}
},
});
},
cell: ({ row }) => {
return renderComponent(Checkbox, {
checked: row.getIsSelected(),
onCheckedChange: row.getToggleSelectedHandler(),
});
},
},
{
accessorFn: (r) => r.blueTeam.nameWithRelation + " vs " + r.redTeam.nameWithRelation,
id: "begegnung",
header: "Begegnung",
},
{
header: "Gruppe",
accessorKey: "group.id",
id: "group",
},
{
header: "Datum",
accessorKey: "start",
id: "start",
cell: ({ row }) => {
return new Date(row.getValue("start")).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
},
},
{
header: "Spielmodus",
accessorKey: "spielmodus",
},
{
header: "Map",
accessorKey: "map",
},
{
header: "Ergebnis",
accessorKey: "ergebnis",
cell: ({ row }) => {
const fight = row.original;
if (!fight.hasFinished) {
return "Noch nicht gespielt";
} else if (fight.ergebnis === 1) {
return fight.blueTeam.name + " hat gewonnen";
} else if (fight.ergebnis === 2) {
return fight.redTeam.name + " hat gewonnen";
} else {
return "Unentschieden";
}
},
},
];

View File

@@ -1,68 +0,0 @@
import type { ResponseUser } from "@components/repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { derived } from "svelte/store";
export class EventModel {
public event: SWEvent = $state({} as SWEvent);
public teams: Array<Team> = $state([]);
public groups: Array<ResponseGroups> = $state([]);
public _fights: Array<EventFight> = $state([]);
public referees: Array<ResponseUser> = $state([]);
public relations: Array<ResponseRelation> = $state([]);
public fights = $derived(this.remapFights(this._fights, this.relations));
constructor(data: ExtendedEvent) {
this.event = data.event;
this.relations = data.relations;
this.teams = data.teams;
this.groups = data.groups;
this._fights = data.fights;
this.referees = data.referees;
}
private remapFights(v: Array<EventFight>, rels: Array<ResponseRelation>) {
return v.map((fight) => {
let f = JSON.parse(JSON.stringify(fight)) as EventFight;
let blueTeamRelation = "";
let redTeamRelation = "";
let relations = rels.filter((relation) => relation.fight === f.id);
relations.forEach((relation) => {
let str = "";
if (relation.type === "FIGHT") {
str = `${relation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von ${relation.fromFight?.blueTeam.name} vs ${relation.fromFight?.redTeam.name} (${new Date(
relation.fromFight?.start ?? 0
).toLocaleTimeString("de-DE", {
timeStyle: "short",
})})`;
} else {
str = `${relation.fromPlace + 1}. Platz von ${relation.fromGroup?.name}`;
}
if (relation.team === "BLUE") {
blueTeamRelation = str;
} else {
redTeamRelation = str;
}
});
return {
...f,
blueTeam: {
...f.blueTeam,
nameWithRelation: blueTeamRelation ? `${f.blueTeam.name} (${blueTeamRelation})` : f.blueTeam.name,
},
redTeam: {
...f.redTeam,
nameWithRelation: redTeamRelation ? `${f.redTeam.name} (${redTeamRelation})` : f.redTeam.name,
},
};
});
}
}
export type EventFightModel = (typeof EventModel.prototype.fights)[number];

View File

@@ -1,173 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { eventRepo } from "@repo/event.ts";
import EventCard from "@components/moderator/components/EventCard.svelte";
import { Button } from "@components/ui/button/index.js";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog/index.js";
import { Input } from "@components/ui/input/index.js";
import { Label } from "@components/ui/label/index.js";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { PlusIcon } from "lucide-svelte";
import dayjs from "dayjs";
import { fromAbsolute, now, ZonedDateTime } from "@internationalized/date";
let eventsFuture = $state($eventRepo.listEvents());
let millis = Date.now();
let createOpen = $state(false);
let newEventName = $state("");
let newEventStart: ZonedDateTime = $state(now("Europe/Berlin"));
let newEventEnd: ZonedDateTime = $state(
now("Europe/Berlin").add({
days: 1,
})
);
let isSubmitting = $state(false);
let errorMsg = $state("");
function resetFormFields() {
newEventName = "";
newEventStart = now("Europe/Berlin");
newEventEnd = now("Europe/Berlin").add({
days: 1,
});
errorMsg = "";
isSubmitting = false;
}
$effect(() => {
if (createOpen) {
resetFormFields();
}
});
const canSubmit = $derived(
newEventName.trim() !== "" &&
newEventStart &&
newEventEnd &&
dayjs(newEventStart.toDate()).isValid() &&
dayjs(newEventEnd.toDate()).isValid() &&
newEventStart.toDate() < newEventEnd.toDate() &&
!isSubmitting
);
async function submitCreateEvent() {
if (!canSubmit) return;
isSubmitting = true;
errorMsg = "";
const payload = {
name: newEventName.trim(),
start: dayjs(newEventStart.toDate()),
end: dayjs(newEventEnd.toDate()),
};
try {
await $eventRepo.createEvent(payload);
eventsFuture = $eventRepo.listEvents(); // Refresh the list
createOpen = false;
} catch (e: any) {
errorMsg = e.message || "Failed to create event. Please try again.";
console.error("Failed to create event:", e);
} finally {
isSubmitting = false;
}
}
</script>
<div class="p-4 min-h-screen">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-semibold">Events</h1>
<Dialog bind:open={createOpen}>
<DialogTrigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<PlusIcon class="mr-2" />
Create Event
</Button>
{/snippet}
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Event</DialogTitle>
<DialogDescription>Fill in the details for the new event. Click create when you're done.</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventName" class="text-right">Name</Label>
<Input id="eventName" bind:value={newEventName} class="col-span-3" placeholder="Event Name" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventStart" class="text-right">Start</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventStart} />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventEnd" class="text-right">End</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventEnd} />
</div>
</div>
{#if errorMsg}
<p class="col-span-4 text-sm text-red-600 dark:text-red-500 text-center">{errorMsg}</p>
{/if}
</div>
<DialogFooter>
<DialogClose>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</DialogClose>
<Button onclick={submitCreateEvent} disabled={!canSubmit}>
{#if isSubmitting}
Creating...
{:else}
Create Event
{/if}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{#await eventsFuture}
<p>Loading...</p>
{:then events}
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Upcoming</h1>
<div class="grid gap-4 p-4 border-b" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each events.filter((e) => e.start > millis) as event (event.id)}
<a href="#/event/{event.id}">
<EventCard {event} />
</a>
{/each}
</div>
<h1 class="mt-5 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">Past</h1>
<div class="grid gap-4 p-4" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each events.filter((e) => e.start < millis).reverse() as event (event.id)}
<a href="#/event/{event.id}">
<EventCard {event} />
</a>
{/each}
</div>
{/await}
</div>

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
import SingleEliminationGenerator from "./gens/elimination/SingleEliminationGenerator.svelte";
import DoubleEliminationGenerator from "./gens/elimination/DoubleEliminationGenerator.svelte";
let {
data,
}: {
data: ExtendedEvent;
} = $props();
</script>
<div class="m-4">
<Tabs value="group">
<TabsList class="mb-4">
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
<TabsTrigger value="double">Double Elimination</TabsTrigger>
</TabsList>
<TabsContent value="group">
<GroupPhaseGenerator {data} />
</TabsContent>
<TabsContent value="ko">
<SingleEliminationGenerator {data} />
</TabsContent>
<TabsContent value="double">
<DoubleEliminationGenerator {data} />
</TabsContent>
</Tabs>
</div>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { eventRepo } from "@components/repo/event";
import FightsGenerator from "./FightsGenerator.svelte";
interface Props {
params: { id: number };
}
let { params }: Props = $props();
let id = params.id;
let future = $eventRepo.getEvent(id.toString());
</script>
{#await future}
<p>Loading...</p>
{:then event}
<FightsGenerator data={event} />
{:catch error}
<p class="text-red-500">Error loading event: {error.message}</p>
{/await}

View File

@@ -1,515 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// Seed model (reuse from single elimination)
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
}
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const ni = index + dir;
if (ni < 0 || ni >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(ni, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "TEAM", id: -1 }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30);
let startDelay = $state(30);
let gamemode = $state("");
let map = $state("");
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Build winners bracket rounds (same as single elimination seeding)
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildWinnersRounds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
if ((n & (n - 1)) !== 0) return []; // power of two required
const rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
let prevWinners = round.map((f) => f.blue);
while (prevWinners.length > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) next.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
rounds.push(next);
prevWinners = next.map((f) => f.blue);
}
return rounds;
}
let winnersRounds = $derived(buildWinnersRounds(seedSlots));
// Losers bracket structure: each losers round takes losers from previous winners round or previous losers round.
// Simplified pairing: For each winners round except final, collect losers and pair them sequentially; then advance until one remains for losers final.
function buildLosersTemplate(wRounds: BracketRoundPreview[]): BracketRoundPreview[] {
const losersRounds: BracketRoundPreview[] = [];
if (wRounds.length < 2) return losersRounds;
// Round 1 losers (from winners round 1)
const firstLosersPairs: BracketRoundPreview = [];
wRounds[0].forEach((f) => firstLosersPairs.push({ blue: f.blue, red: f.red })); // placeholders (will label as losers)
losersRounds.push(firstLosersPairs);
// Subsequent losers rounds shrink similarly
let remaining = firstLosersPairs.length; // number of fights that feed losers next stage
while (remaining > 1) {
const next: BracketRoundPreview = [];
for (let i = 0; i < remaining; i += 2) next.push(firstLosersPairs[i]); // placeholder reuse
losersRounds.push(next);
remaining = next.length;
}
return losersRounds;
}
let losersRounds = $derived(buildLosersTemplate(winnersRounds));
let generateDisabled = $derived(gamemode !== "" && map !== "" && winnersRounds.length > 0 && seedSlots.length >= 4);
// Type helpers
function slotLabel(slot: SeedSlot): string {
if (slot.kind === "TEAM") return teams.get(slot.id)?.name ?? "???";
if (slot.kind === "GROUP") {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
async function generateDouble() {
if (!generateDisabled) return;
const eventId = data.event.id;
// Create two groups: winners & losers + grand final group (optional combine winners)
const winnersGroup = await $eventRepo.createGroup(eventId, { name: "Winners", type: "ELIMINATION_STAGE" });
const losersGroup = await $eventRepo.createGroup(eventId, { name: "Losers", type: "ELIMINATION_STAGE" });
const finalGroup = await $eventRepo.createGroup(eventId, { name: "Final", type: "ELIMINATION_STAGE" });
function fallbackTeamId(slot: SeedSlot): number {
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
return data.teams[0].id;
}
const winnersFightIdsByRound: number[][] = [];
for (let r = 0; r < winnersRounds.length; r++) {
const round = winnersRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const f = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(round[i].blue),
redTeam: fallbackTeamId(round[i].red),
group: winnersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
// Attach relations for GROUP/FIGHT seeds
const pair = round[i];
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(f.id);
}
winnersFightIdsByRound.push(ids);
}
// Progression in winners bracket
for (let r = 1; r < winnersFightIdsByRound.length; r++) {
const prev = winnersFightIdsByRound[r - 1];
const curr = winnersFightIdsByRound[r];
for (let i = 0; i < curr.length; i++) {
const target = curr[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
await $eventRepo.createRelation(eventId, { fightId: target, team: "BLUE", fromType: "FIGHT", fromId: srcA, fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: target, team: "RED", fromType: "FIGHT", fromId: srcB, fromPlace: 0 });
}
}
// Losers bracket (canonical pattern):
// L1: losers of WBR1 paired; then for r=2..(k-1):
// Major: winners of previous L vs losers of WBRr
// Minor: winners of that major paired (except after last WBR where we go to LB final vs WB final loser)
const losersFightIdsByRound: number[][] = [];
let losersRoundIndex = 0;
const k = winnersFightIdsByRound.length; // number of winners rounds
// L1 from WBR1 losers
{
const wb1 = winnersFightIdsByRound[0];
const ids: number[] = [];
for (let i = 0; i < wb1.length; i += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (i / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: wb1[i], fromPlace: 1 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: wb1[i + 1], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// For each subsequent winners round except the final
for (let wr = 1; wr < k - 1; wr++) {
const prevLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
// Major: winners of prevLBRound vs losers of current WBR (wr)
{
const ids: number[] = [];
for (let j = 0; j < winnersFightIdsByRound[wr].length; j++) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * j })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: prevLBRound[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFightIdsByRound[wr][j], fromPlace: 1 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
// Minor: pair winners of last LBRound among themselves (if more than 1)
{
const last = losersFightIdsByRound[losersFightIdsByRound.length - 1];
if (last.length > 1) {
const ids: number[] = [];
for (let j = 0; j < last.length; j += 2) {
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.add({ seconds: startDelay * (j / 2) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: last[j], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: last[j + 1], fromPlace: 0 });
ids.push(lf.id);
}
losersFightIdsByRound.push(ids);
losersRoundIndex++;
}
}
}
// Final losers round: winners of last LBRound vs loser of Winners Final (last WBR)
const winnersFinal = winnersFightIdsByRound[k - 1][0];
const lastLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
let losersFinal: number | undefined = undefined;
if (lastLBRound && lastLBRound.length >= 1) {
let finalMap2 = map;
if (finalMap2 === "%random%" && selectableMaps.length > 0) finalMap2 = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const lf = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: losersGroup.id,
map: finalMap2,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: lastLBRound[lastLBRound.length - 1], fromPlace: 0 });
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 1 });
losersFinal = lf.id;
losersFightIdsByRound.push([lf.id]);
losersRoundIndex++;
}
// Grand Final
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const grandFinal = await $fightRepo.createFight(eventId, {
blueTeam: -1,
redTeam: -1,
group: finalGroup.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * (k + losersRoundIndex) })
.toDate()
),
});
await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "BLUE", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 0 });
if (losersFinal !== undefined) await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "RED", fromType: "FIGHT", fromId: losersFinal, fromPlace: 0 });
await replace("#/event/" + eventId);
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Double Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 4}
<p class="text-gray-400">Mindestens vier Seeds benötigt.</p>
{:else if winnersRounds.length === 0}
<p class="text-yellow-400">Seedanzahl muss eine Zweierpotenz sein. Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid lg:grid-cols-3 gap-6">
<div class="space-y-4">
<Label>Seeds</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
<div class="space-y-2">
<Label>Hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
{#if data.groups.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
</div>
{/if}
{#if data.fights.length > 0}
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
</div>
{/if}
</div>
</div>
</div>
<div>
<Label>Konfiguration</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}<SelectItem value={gm.value}>{gm.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}<SelectItem value={mp.value}>{mp.name}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-6">
<div>
<Label>Winners Bracket Vorschau</Label>
{#if winnersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each winnersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">W Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<div>
<Label>Losers Bracket (vereinfachte Vorschau)</Label>
{#if losersRounds.length > 0}
<div class="flex gap-6 overflow-x-auto mt-2">
{#each losersRounds as round, r}
<div>
<h3 class="font-semibold mb-2">L Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-xs">
Verlierer Paar {i + 1} (aus W Runde {r + 1})
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateDouble} aria-label="Double Bracket generieren">
<Plus />
</Button>
</Card>
<!-- minimal styles only -->

View File

@@ -1,364 +0,0 @@
<script lang="ts">
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus, Shuffle } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let { data }: { data: ExtendedEvent } = $props();
// --- Seeding model: support teams, group results, unknown placeholders ---
interface SeedTeamSlot {
kind: "TEAM";
id: number;
}
interface SeedGroupSlot {
kind: "GROUP";
groupId: number;
place: number;
}
interface SeedUnknownSlot {
kind: "UNKNOWN";
uid: number;
}
interface SeedFightSlot {
kind: "FIGHT";
fightId: number;
place: 0 | 1;
} // 0 winner, 1 loser
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedUnknownSlot | SeedFightSlot;
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
let unknownCounter = 1;
function shuffleTeams() {
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
let p = 0;
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
}
function moveSlot(index: number, dir: -1 | 1) {
const newIndex = index + dir;
if (newIndex < 0 || newIndex >= seedSlots.length) return;
const copy = [...seedSlots];
const [item] = copy.splice(index, 1);
copy.splice(newIndex, 0, item);
seedSlots = copy;
}
function removeSlot(index: number) {
seedSlots = seedSlots.filter((_, i) => i !== index);
}
function addUnknown() {
seedSlots = [...seedSlots, { kind: "UNKNOWN", uid: unknownCounter++ }];
}
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
function addTeam() {
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
}
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
let selectedGroupPlace = $state<number>(0);
function addGroupPlace() {
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
}
// Fight seed selection
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
let selectedFightPlace = $state<0 | 1>(0);
function addFightPlace() {
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
}
// Config inputs
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let roundTime = $state(30); // minutes per round
let startDelay = $state(30); // seconds between fights inside round
let gamemode = $state("");
let map = $state("");
// Gamemode / Map selection stores
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
// Derived: bracket rounds preview
interface BracketFightPreview {
blue: SeedSlot;
red: SeedSlot;
}
type BracketRoundPreview = BracketFightPreview[];
function buildBracketSeeds(order: SeedSlot[]): BracketRoundPreview[] {
const n = order.length;
if (n < 2) return [];
// Require power of two for now; simplest implementation
if ((n & (n - 1)) !== 0) return [];
let rounds: BracketRoundPreview[] = [];
let round: BracketRoundPreview = [];
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
rounds.push(round);
// Higher rounds placeholders using first team from each prior pairing as seed representative
let prevWinners = round.map((fight) => fight.blue);
while (prevWinners.length > 1) {
const nextRound: BracketRoundPreview = [];
for (let i = 0; i < prevWinners.length; i += 2) {
nextRound.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
}
rounds.push(nextRound);
prevWinners = nextRound.map((f) => f.blue);
}
return rounds;
}
let bracketRounds = $derived(buildBracketSeeds(seedSlots));
let generateDisabled = $derived(gamemode !== "" && map !== "" && bracketRounds.length > 0 && seedSlots.length >= 2);
async function generateBracket() {
if (!generateDisabled) return;
const eventId = data.event.id;
// create elimination group
const group = await $eventRepo.createGroup(eventId, { name: "Elimination", type: "ELIMINATION_STAGE" });
// Create fights round by round & keep ids for relation wiring
const fightIdsByRound: number[][] = [];
function fallbackTeamId(slot: SeedSlot): number {
// If this seed is a relation (GROUP or FIGHT), use -1 as requested
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
if (slot.kind === "TEAM") return slot.id;
// UNKNOWN stays as a concrete placeholder team or -1? Keep concrete team to avoid backend errors.
return data.teams[0].id;
}
for (let r = 0; r < bracketRounds.length; r++) {
const round = bracketRounds[r];
const ids: number[] = [];
for (let i = 0; i < round.length; i++) {
const pair = round[i];
let finalMap = map;
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
const fight = await $fightRepo.createFight(eventId, {
blueTeam: fallbackTeamId(pair.blue),
redTeam: fallbackTeamId(pair.red),
group: group.id,
map: finalMap,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({ minutes: roundTime * r })
.add({ seconds: startDelay * i })
.toDate()
),
});
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
ids.push(fight.id);
}
fightIdsByRound.push(ids);
}
// Wire relations: for each fight in rounds >0, reference winners of two source fights from previous round
for (let r = 1; r < fightIdsByRound.length; r++) {
const prev = fightIdsByRound[r - 1];
const current = fightIdsByRound[r];
for (let i = 0; i < current.length; i++) {
const targetFightId = current[i];
const srcA = prev[i * 2];
const srcB = prev[i * 2 + 1];
// Winner assumed place 1
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "BLUE",
fromType: "FIGHT",
fromId: srcA,
fromPlace: 1,
});
await $eventRepo.createRelation(eventId, {
fightId: targetFightId,
team: "RED",
fromType: "FIGHT",
fromId: srcB,
fromPlace: 1,
});
}
}
// Redirect back to event view
await replace("#/event/" + eventId);
}
// Helpers for template rendering with TS type guards
function isTeam(slot: SeedSlot): slot is SeedTeamSlot {
return slot.kind === "TEAM";
}
function isGroup(slot: SeedSlot): slot is SeedGroupSlot {
return slot.kind === "GROUP";
}
function slotLabel(slot: SeedSlot): string {
if (isTeam(slot)) return teams.get(slot.id)?.name ?? "Team";
if (isGroup(slot)) {
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
return `(Grp ${gname} Platz ${slot.place + 1})`;
}
if (slot.kind === "FIGHT") {
const f = data.fights.find((x) => x.id === slot.fightId);
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
}
return "???";
}
</script>
<Card class="p-4 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Single Elimination Bracket</h2>
<div class="flex gap-2">
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
</div>
</div>
{#if seedSlots.length < 2}
<p class="text-gray-400">Mindestens zwei Seeds benötigt.</p>
{:else if bracketRounds.length === 0}
<p class="text-yellow-400">Anzahl der Seeds muss eine Zweierpotenz sein (2,4,8,16,...). Aktuell: {seedSlots.length}</p>
{/if}
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-4">
<div>
<Label>Seeds / Reihenfolge</Label>
<ul class="mt-2 space-y-1">
{#each seedSlots as slot, i (i)}
<li class="flex items-center gap-2 text-sm">
<span class="w-6 text-right">{i + 1}.</span>
<span class="flex-1 truncate">{slotLabel(slot)}</span>
<div class="flex gap-1">
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}></Button>
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}></Button>
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
</div>
</li>
{/each}
</ul>
</div>
<div class="space-y-2">
<Label>Seed hinzufügen</Label>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 items-center">
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
</select>
<Button size="sm" onclick={addTeam}>Team</Button>
<Button size="sm" onclick={addUnknown}>???</Button>
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.groups.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
</select>
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center">
{#if data.fights.length > 0}
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
{#each data.fights as f}
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
{/each}
</select>
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
<option value={0}>Gewinner</option>
<option value={1}>Verlierer</option>
</select>
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
{/if}
</div>
</div>
<p class="text-xs text-gray-500">Gruppen- oder Kampfplätze erzeugen Relationen beim Generieren. ??? bleibt Platzhalter.</p>
</div>
</div>
<div>
<Label>Startzeit</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-4">
<Label>Rundenzeit: {roundTime}m</Label>
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
</div>
<div class="mt-4">
<Label>Startverzögerung: {startDelay}s</Label>
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
</div>
<div class="mt-4">
<Label>Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger>{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gm}
<SelectItem value={gm.value}>{gm.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="mt-4">
<Label>Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger>{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mp}
<SelectItem value={mp.value}>{mp.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="mt-6">
<Label>Vorschau</Label>
{#if bracketRounds.length > 0}
<div class="flex gap-8 overflow-x-auto mt-2">
{#each bracketRounds as round, r}
<div>
<h3 class="font-semibold mb-2">Runde {r + 1}</h3>
<ul class="space-y-1">
{#each round as fight, i}
<li class="p-2 border border-gray-700 rounded text-sm">
<span class="text-gray-400"
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
startTime
.copy()
.add({ minutes: roundTime * r, seconds: startDelay * i })
.toDate()
)}</span
>
: {slotLabel(fight.blue)} &nbsp;vs.&nbsp; {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
<Plus />
</Button>
</Card>
<!-- no component-scoped styles needed -->

View File

@@ -1,306 +0,0 @@
<script lang="ts">
import DragAcceptor from "@components/admin/pages/generate/DragAcceptor.svelte";
import TeamChip from "@components/admin/pages/generate/TeamChip.svelte";
import { eventRepo } from "@components/repo/event";
import { fightRepo } from "@components/repo/fight";
import { gamemodes, maps } from "@components/stores/stores";
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { Button } from "@components/ui/button";
import { Card } from "@components/ui/card";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Dialog } from "@components/ui/dialog";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Slider } from "@components/ui/slider";
import { fromAbsolute, fromDate, parseDateTime, parseDuration } from "@internationalized/date";
import dayjs from "dayjs";
import { Plus } from "lucide-svelte";
import { replace } from "svelte-spa-router";
let {
data,
}: {
data: ExtendedEvent;
} = $props();
let teams = $derived(new Map<number, Team>(data.teams.map((team) => [team.id, team])));
let groups: number[][] = $state([]);
let teamsNotInGroup = $derived(data.teams.filter((team) => !groups.flat().includes(team.id)));
function dragToNewGroup(event: DragEvent) {
event.preventDefault();
let teamId = parseInt(event.dataTransfer!.getData("team"));
groups = [...groups.map((value) => value.filter((value1) => value1 != teamId)), [teamId]].filter((value) => value.length > 0);
}
function teamDragStart(ev: DragEvent, team: Team) {
ev.dataTransfer!.setData("team", team.id.toString());
}
let resetDragOver = $state(false);
function resetDragOverEvent(ev: DragEvent) {
resetDragOver = true;
ev.preventDefault();
}
function dropReset(ev: DragEvent) {
ev.preventDefault();
let teamId = parseInt(ev.dataTransfer!.getData("team"));
groups = groups.map((group) => group.filter((team) => team !== teamId)).filter((group) => group.length > 0);
resetDragOver = false;
}
function dropGroup(ev: DragEvent, groupIndex: number) {
ev.preventDefault();
let teamId = parseInt(ev.dataTransfer!.getData("team"));
groups = groups.map((group, i) => (i === groupIndex ? [...group.filter((value) => value != teamId), teamId] : group.filter((value) => value != teamId))).filter((group) => group.length > 0);
}
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
let gamemode = $state("");
let map = $state("");
let selectableGamemodes = $derived(
$gamemodes
.map((gamemode) => {
return {
name: gamemode,
value: gamemode,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived(
$mapsStore
.map((map) => {
return {
name: map,
value: map,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let roundTime = $state(30);
let startDelay = $state(30);
let showAutoGrouping = $state(false);
let groupCount = $state(Math.floor(data.teams.length / 2));
function createGroups() {
let teams = data.teams.map((team) => team.id).sort(() => Math.random() - 0.5);
groups = [];
for (let i = 0; i < groupCount; i++) {
groups.push([]);
}
while (teams.length > 0) {
groups[teams.length % groupCount].push(teams.pop() as number);
}
showAutoGrouping = false;
groups = groups.filter((group) => group.length > 0);
}
function generateGroups(groups: number[][]): number[][][][] {
const groupFights: number[][][][] = [];
groups.forEach((group) => {
let round = group.length + (group.length % 2) - 1;
let groupFight = [];
for (let i = 0; i < round; i++) {
let availableTeams = [...group];
if (group.length % 2 === 1) {
availableTeams = availableTeams.filter((team, index) => index !== i);
}
let roundFights = [];
while (availableTeams.length > 0) {
let team1 = availableTeams.pop() as number;
let team2 = availableTeams.at(i % availableTeams.length) as number;
availableTeams = availableTeams.filter((team) => team !== team2);
let fight = [team1, team2];
fight.sort(() => Math.random() - 0.5);
roundFights.push(fight);
}
groupFight.push(roundFights);
}
groupFights.push(groupFight);
});
return groupFights;
}
let groupsFights = $derived(generateGroups(groups));
let generateDisabled = $derived(groupsFights.length > 0 && groupsFights.every((value) => value.every((value1) => value1.length > 0)) && gamemode !== "" && map !== "");
async function generateFights() {
groupsFights.forEach((group, i) => {
$eventRepo
.createGroup(data.event.id, {
name: "Gruppe " + (i + 1),
type: "GROUP_STAGE",
})
.then((v) => {
group.forEach((round, j) => {
round.forEach(async (fight, k) => {
const blueTeam = teams.get(fight[0])!;
const redTeam = teams.get(fight[1])!;
let karte = map;
if (karte === "%random%") {
karte = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
}
await $fightRepo.createFight(data.event.id, {
blueTeam: blueTeam.id,
redTeam: redTeam.id,
group: v.id,
map: karte,
spectatePort: null,
spielmodus: gamemode,
start: dayjs(
startTime
.copy()
.add({
minutes: roundTime * j,
})
.add({
seconds: startDelay * (k + i * round.length),
})
.toDate()
),
});
});
});
});
});
await replace("#/event/" + data.event.id);
}
</script>
<div class="flex justify-between">
<Card
id="reseter"
class="flex w-fit p-2 border border-gray-700 rounded h-20 pt-6 relative {resetDragOver ? 'border-white' : ''}"
ondragover={resetDragOverEvent}
ondragleave={() => (resetDragOver = false)}
ondrop={dropReset}
role="group"
>
{#each teamsNotInGroup as team (team.id)}
<TeamChip {team} ondragstart={(ev) => teamDragStart(ev, team)} />
{/each}
</Card>
<div class="flex items-center mr-4">
<Button onclick={() => (showAutoGrouping = true)}>Automatische Gruppen</Button>
</div>
</div>
<div class="flex mt-4 gap-4 border-b border-gray-700 pb-4">
{#each groups as group, i (i)}
<DragAcceptor ondrop={(ev) => dropGroup(ev, i)}>
<h1>Gruppe {i + 1} ({group.length})</h1>
{#each group as teamId (teamId)}
<TeamChip team={teams.get(teamId)!} ondragstart={(ev) => teamDragStart(ev, teams.get(teamId)!)} />
{/each}
</DragAcceptor>
{/each}
<DragAcceptor ondrop={dragToNewGroup}>
<h1>Neue Gruppe</h1>
</DragAcceptor>
</div>
<div class="border-b mt-4 border-gray-700 pb-4">
<Label for="event-end">Startzeit</Label>
<DateTimePicker bind:value={startTime} />
<div class="mt-2">
<Label for="event-roundtime">Rundenzeit: {roundTime}m</Label>
<Slider id="event-roundtime" type="single" bind:value={roundTime} step={1} min={5} max={60} />
</div>
<div class="mt-2">
<Label for="event-member">Startverzögerung: {startDelay}</Label>
<Slider id="event-member" type="single" bind:value={startDelay} step={1} min={0} max={30} />
</div>
<div class="mt-2">
<Label for="fight-gamemode">Spielmodus</Label>
<Select type="single" bind:value={gamemode}>
<SelectTrigger id="fight-gamemode">{gamemode}</SelectTrigger>
<SelectContent>
{#each selectableGamemodes as gamemodeOption}
<SelectItem value={gamemodeOption.value}>{gamemodeOption.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="mt-2">
<Label for="fight-maps">Map</Label>
<Select type="single" bind:value={map}>
<SelectTrigger id="fight-maps">{map}</SelectTrigger>
<SelectContent>
<SelectItem value="%random%">Zufällige Map</SelectItem>
{#each selectableMaps as mapOption}
<SelectItem value={mapOption.value}>{mapOption.name}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="text-center mx-2">
{#each groupsFights as fightsGroup, i}
<div>
<h1 class="text-4xl">Gruppe: {i + 1}</h1>
{#each fightsGroup as fightsRound, j}
<div class="border-b border-gray-700">
<h1 class="text-2xl">Runde: {j + 1}</h1>
{#each fightsRound as fightTeams, k}
<div class="text-left p-4">
<span class="p-2 border border-gray-700 rounded"
>{new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(
startTime
.copy()
.add({
minutes: roundTime * j,
seconds: startDelay * (k + i * fightsRound.length),
})
.toDate()
)}</span
>
{teams.get(fightTeams[0])!.name} vs. {teams.get(fightTeams[1])!.name}
</div>
{/each}
</div>
{/each}
</div>
{/each}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateFights}>
<Plus />
</Button>
<style lang="scss">
:global(#reseter::before) {
content: "Reset";
position: absolute;
top: 0;
color: white;
}
:global(#reseter) {
min-width: 14rem;
}
</style>

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { Separator } from "@components/ui/separator";
import { manager, OpenEditPage } from "./page.svelte";
import { File, X } from "lucide-svelte";
import { onMount } from "svelte";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import { json } from "@codemirror/lang-json";
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark";
import FrontmatterEditor from "./FrontmatterEditor.svelte";
import { slide } from "svelte/transition";
import { Button } from "@components/ui/button";
let codemirrorParent: HTMLElement | undefined = $state();
let easyMdeParent: HTMLElement | undefined = $state();
let easyMdeWrapper: HTMLElement | undefined = $state();
let easyMde: EasyMDE | null = $state(null);
let view: EditorView | null = $state(null);
$effect(() => {
switch (manager.selectedPage?.fileType) {
case "md":
case "mdx":
easyMdeWrapper?.classList.remove("hidden");
codemirrorParent?.classList.add("hidden");
break;
case "json":
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.remove("hidden");
break;
default:
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.add("hidden");
}
});
function updatePage(page: OpenEditPage | undefined) {
if (page) {
easyMde?.value(page.content || "");
view?.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: page.content || "" },
});
}
}
$effect(() => updatePage(manager.selectedPage));
onMount(() => {
view = new EditorView({
doc: manager.selectedPage?.content || "",
parent: codemirrorParent,
extensions: [basicSetup, json(), materialDark],
});
easyMde = new EasyMDE({
element: easyMdeParent,
spellChecker: false,
initialValue: manager.selectedPage?.content || "",
});
easyMde.codemirror.on("change", () => {
if (manager.selectedPage?.content !== easyMde?.value()) {
manager.selectedPage!.dirty = true;
}
manager.selectedPage!.content = easyMde?.value() || "";
});
});
</script>
<div class="flex flex-col h-full w-full">
<div class="h-8 flex">
{#each manager.pages as tab, index}
{@const isActive = manager.openPageIndex === index}
<button
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
? 'text-primary bg-neutral-900'
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
onclick={() => (manager.openPageIndex = index)}
>
<File class="h-4 w-4 mr-2" />
{tab.pageTitle}
<span
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
manager.closePage(index);
}}><X /></span
>
</button>
{/each}
</div>
<Separator />
<div class="flex-1 flex flex-col">
{#if manager.selectedPage}
<div class="flex items-center justify-end p-2">
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button>
</div>
<div class="flex gap-2 items-center">
{#if manager.selectedPage.path.startsWith("src/content/announcements/")}
<div class="border-b flex-1" transition:slide>
<FrontmatterEditor />
</div>
{/if}
</div>
{/if}
<div class="flex-1">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
</div>
</div>
</div>

View File

@@ -1,122 +0,0 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { manager } from "./page.svelte";
import { slide } from "svelte/transition";
</script>
<details class="group">
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800">
<span class="font-medium">Frontmatter</span>
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-3 border-t bg-neutral-900">
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]}
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800">
<div class="flex items-center gap-2">
<input
type="text"
value={key}
onchange={(e) => {
const newKey = (e.target as HTMLInputElement).value;
if (newKey !== key) {
manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key];
delete manager.selectedPage?.frontmatter[key];
manager.selectedPage!.dirty = true;
}
}}
class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900"
placeholder="Key"
/>
<span>:</span>
{#if Array.isArray(value)}
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span>
{:else if value instanceof Date || key === "created"}
<input
type="date"
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""}
onchange={(e) => {
const dateValue = (e.target as HTMLInputElement).value;
manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : "";
manager.selectedPage!.dirty = true;
}}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
/>
{:else}
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Value"
/>
{/if}
<button
onclick={() => {
delete manager.selectedPage!.frontmatter[key];
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-4 h-4" />
</button>
</div>
{#if Array.isArray(value)}
<div class="ml-4 space-y-1">
{#each value as item, index}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-6">[{index}]</span>
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key][index]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Array item"
/>
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].splice(index, 1);
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].push("");
manager.selectedPage!.dirty = true;
}}
class="text-xs text-blue-500 hover:text-blue-700 ml-8"
>
+ Add item
</button>
</div>
{/if}
</div>
{/each}
<div class="flex gap-2">
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_key_${Object.keys(manager.selectedPage!.frontmatter).length}`] = "";
manager.selectedPage!.dirty = true;
}}
class="text-sm text-blue-500 hover:text-blue-700"
>
+ Add field
</button>
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_array_${Object.keys(manager.selectedPage!.frontmatter).length}`] = [];
manager.selectedPage!.dirty = true;
}}
class="text-sm text-green-500 hover:text-green-700"
>
+ Add array
</button>
</div>
</div>
</details>

View File

@@ -1,155 +0,0 @@
<script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { cn } from "@components/utils";
import { pageRepo } from "@components/repo/page";
let branchSelectOpen = $state(false);
let imageSelectOpen = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}>
<div class="overflow-y-scroll">
<div class="flex p-2 gap-2">
<Popover bind:open={branchSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{manager.branch}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Branches..." />
<CommandList>
<CommandEmpty>No Branches Found.</CommandEmpty>
<CommandGroup>
{#each manager.branches as branch}
<CommandItem
value={branch}
onSelect={() => {
if (manager.anyUnsavedChanges()) {
if (!confirm("You have unsaved changes. Are you sure you want to switch branches?")) {
return;
}
}
manager.branch = branch;
manager.pages = [];
branchSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", branch !== manager.branch && "text-transparent")} />
{branch}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
<RefreshCw />
</Button>
<Popover bind:open={imageSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button size="icon" variant="outline" {...props}>
<FileImage />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
{#await manager.imagesLoad}
<p>Loading images...</p>
{:then images}
<div class="flex flex-col gap-2">
<div class="p-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target?.result?.toString().split(",")[1];
if (base64) {
await $pageRepo.createImage(file.name, base64, manager.branch);
manager.reloadImages();
}
};
reader.readAsDataURL(file);
}}
/>
<Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" />
Upload Image
</Button>
</div>
<div class="grid grid-cols-4 gap-2 p-2">
{#each images as image}
<button
onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
const path = [...Array(backs).fill(".."), image.path.replace("src/", "")].join("/");
navigator.clipboard.writeText(path);
imageSelectOpen = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
</button>
{/each}
</div>
</div>
{/await}
</PopoverContent>
</Popover>
<Button
size="icon"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus />
</Button>
</div>
<Separator />
{#await manager.pagesLoad}
<p>Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
</ResizablePane>
<ResizableHandle />
<ResizablePane defaultSize={80}>
<EditorWithTabs />
</ResizablePane>
</ResizablePaneGroup>
</div>

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import type { DirTree } from "./page.svelte";
import PagesList from "./PagesList.svelte";
import { slide } from "svelte/transition";
import Button from "@components/ui/button/button.svelte";
import { manager } from "./page.svelte";
const { page, depth = 0, path }: { page: DirTree; depth?: number; path: string } = $props();
let open = $state(false);
let newPage = $state(false);
let newPageName = $state("");
let newPageInput: HTMLInputElement | undefined = $state();
function startNewPageCreate(e: Event) {
e.stopPropagation();
newPage = true;
newPageName = "";
open = true;
setTimeout(() => {
newPageInput?.focus();
}, 1);
}
function createNewPage(e: Event) {
e.preventDefault();
e.stopPropagation();
if (newPageName.trim() === "") {
alert("Page name cannot be empty");
return;
}
if (!newPageName.match(/^[a-zA-Z0-9_\-\.]+$/)) {
alert("Invalid page name. Only alphanumeric characters, underscores, dashes, and dots are allowed.");
return;
}
if (!newPageName.endsWith(".json") && !newPageName.endsWith(".md") && !newPageName.endsWith(".mdx")) {
newPageName += ".md";
}
manager
.createPage(path + newPageName, newPageName)
.then(() => {
newPage = false;
newPageName = "";
})
.catch((error) => {
alert("Error creating page: " + error.message);
});
}
</script>
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
<div class="flex flex-row items-center">
{#if open}
<ChevronDown class="w-6 h-6" />
{:else}
<ChevronRight class="w-6 h-6" />
{/if}
<Folder class="mr-2 w-4 h-4" />
{page.name}/
</div>
<div class="flex-row items-center hidden group-hover:flex">
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
<FilePlus class="w-3 h-3" />
</Button>
</div>
</button>
{#if open}
<div transition:slide={{ duration: 200, axis: "y" }}>
<div>
{#if newPage}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
{#if newPageName.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
<form onsubmit={createNewPage}>
<input
type="text"
bind:value={newPageName}
bind:this={newPageInput}
onblur={() => (newPage = false)}
placeholder="New page name"
class="flex-grow bg-transparent border-none outline-none text-white"
/>
</form>
</button>
{/if}
{#each Object.values(page.dirs) as subPage (subPage.name)}
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
{/each}
{#each Object.values(page.files) as file (file.id)}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
{#if file.name.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
{file.name}
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -1,226 +0,0 @@
import { base64ToBytes } from "@components/admin/util";
import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store";
import yaml from "js-yaml";
export class OpenEditPage {
public content: string = "";
public frontmatter: { [key: string]: string | string[] | Date } = $state({});
public dirty: boolean = $state(false);
public readonly fileType: string;
public constructor(
private manager: PageManager,
public readonly pageId: number,
public readonly pageTitle: string,
public readonly sha: string,
public readonly originalContent: string,
public readonly path: string
) {
this.fileType = this.path.split(".").pop() || "md";
this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent);
}
public async save(): Promise<void> {
if (!this.dirty) {
return;
}
let contentToSave = "";
if (this.frontmatter) {
contentToSave += "---\n";
contentToSave += yaml.dump(this.frontmatter);
contentToSave += "---\n\n";
}
contentToSave += this.content;
await get(pageRepo).updatePage(this.pageId, contentToSave, this.sha, prompt("Was hast du geändert?", `Updated ${this.pageTitle}`) ?? `Updated ${this.pageTitle}`, this.manager.branch);
this.dirty = false;
this.manager.reloadImages();
}
public focus(): boolean {
let index = this.manager.pages.indexOf(this);
if (index === this.manager.openPageIndex) {
return true;
}
this.manager.openPageIndex = this.manager.pages.indexOf(this);
return false;
}
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
const lines = content.split("\n");
let inFrontmatter = false;
const frontmatterLines: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
if (inFrontmatter) {
break; // End of frontmatter
}
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
frontmatterLines.push(line);
}
}
if (frontmatterLines.length === 0) {
return {};
}
try {
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
} catch (error) {
console.error("Failed to parse YAML frontmatter:", error);
return {};
}
}
private removeFrontmatter(content: string): string {
const lines = content.split("\n");
let inFrontmatter = false;
const result: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (!inFrontmatter) {
result.push(line);
}
}
return result.join("\n").trim();
}
}
export interface DirTree {
name: string;
dirs: { [key: string]: DirTree };
files: { [key: string]: ListPage };
}
export class PageManager {
public reloadImages() {
this.updater = this.updater + 1;
}
public branch: string = $state("master");
public pages: OpenEditPage[] = $state([]);
public branches: string[] = $state([]);
constructor() {
this.reloadBranches();
}
public reloadBranches() {
get(pageRepo)
.getBranches()
.then((branches) => {
this.branches = branches;
});
}
private updater = $state(0);
public openPageIndex: number = $state(-1);
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater)));
public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
private _t<T>(n: number): (v: T) => T {
return (v: T) => v;
}
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
private convertToTree(pages: PageList): DirTree {
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
pages.forEach((page) => {
const pathParts = page.path.split("/").filter((part) => part !== "");
let current = tree;
// Navigate/create directory structure
for (let i = 0; i < pathParts.length - 1; i++) {
const dir = pathParts[i];
if (!current.dirs[dir]) {
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
}
current = current.dirs[dir];
}
// Add file to the final directory
const fileName = pathParts[pathParts.length - 1];
current.files[fileName] = page;
});
return tree;
}
public async openPage(pageId: number) {
const existingPage = this.existingPage(pageId);
if (existingPage) {
existingPage.focus();
return;
}
let r = await get(pageRepo).getPage(pageId, this.branch);
if (!r) {
return;
}
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
this.pages.push(newPage);
newPage.focus();
}
public existingPage(pageId: number): OpenEditPage | undefined {
return this.pages.find((page) => page.pageId === pageId);
}
public closePage(index: number) {
if (index < 0 || index >= this.pages.length) {
return;
}
const page = this.pages[index];
if (page.dirty) {
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
return;
}
}
this.pages.splice(index, 1);
if (this.openPageIndex >= index) {
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
}
if (this.openPageIndex < 0 && this.pages.length > 0) {
this.openPageIndex = 0;
}
if (this.pages.length === 0) {
this.openPageIndex = -1;
}
}
public async createPage(path: string, newPageName: string): Promise<void> {
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
this.branch = this.branch;
}
public anyUnsavedChanges() {
return this.pages.some((page) => page.dirty);
}
}
export const manager = $state(new PageManager());

View File

@@ -1,56 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {permissions, players} from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts";
const {
perms, uuid
}: { perms: string[], uuid: string } = $props();
let value = $state(perms);
let prevValue = $state(perms);
function onChange(change: string[]) {
$permissions.perms.forEach(perm => {
if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm)
} else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm)
}
});
prevValue = change;
value = change;
}
</script>
<Select type="multiple" bind:value onValueChange={onChange}>
<SelectTrigger>
{value.length} Permissions
</SelectTrigger>
<SelectContent>
{#each $permissions.perms as permission (permission)}
<SelectItem value={permission}>{permission}</SelectItem>
{/each}
</SelectContent>
</Select>

View File

@@ -1,32 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script>
import Table from "@components/moderator/pages/players/Table.svelte";
import {dataRepo} from "@repo/data";
let playersFuture = $state($dataRepo.getPlayers())
</script>
{#await playersFuture}
<p>Loading...</p>
{:then players}
<Table data={players} />
{/await}

View File

@@ -1,47 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
import {permissions} from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts";
const {
prefix, uuid
}: { prefix: string, uuid: string } = $props();
let value = $state(prefix);
function onChange(change: string) {
$permsRepo.setPrefix(uuid, change);
value = $permissions.prefixes[change].chatPrefix;
}
</script>
<Select type="single" bind:value onValueChange={onChange}>
<SelectTrigger>
{value === "" ? "None" : value}
</SelectTrigger>
<SelectContent>
{#each Object.entries($permissions.prefixes) as prefix (prefix[1].name)}
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
{/each}
</SelectContent>
</Select>

View File

@@ -1,174 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
type ColumnFiltersState,
getCoreRowModel, getFilteredRowModel,
getPaginationRowModel, getSortedRowModel,
type PaginationState,
type SortingState,
} from "@tanstack/table-core";
import {
createSvelteTable,
FlexRender,
} from "@components/ui/data-table/index";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
import {Button} from "@components/ui/button";
import {Input} from "@components/ui/input";
import {Select} from "@components/ui/select";
import {SelectContent, SelectItem, SelectTrigger} from "@components/ui/select/index.js";
import type {Player} from "@type/data";
import { columns } from "./columns";
let { data }: { data: Player[] } = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 25 });
let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]);
const table = createSvelteTable({
get data() {
return data;
},
state: {
get pagination() {
return pagination;
},
get sorting() {
return sorting;
},
get columnFilters() {
return columnFilters;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onSortingChange: (updater) => {
if (typeof updater === "function") {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
</script>
<div class="rounded-md border m-4">
<div class="flex items-center p-4 border-b">
<Input
placeholder="Filter Players..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onchange={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
oninput={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
class="max-w-sm"
/>
<div class="flex items-center px-4">
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => pagination = { pageSize: +e, pageIndex: 0 }}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">
No results.
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="flex items-center justify-end space-x-2 p-4 border-t">
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<span>{pagination.pageIndex + 1}/{table.getPageCount()}</span>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>

View File

@@ -1,60 +0,0 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2025 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ColumnDef} from "@tanstack/table-core";
import type {Player} from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
export const columns: ColumnDef<Player[]> = [
{
accessorKey: "uuid",
header: "UUID",
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "prefix",
header: "Prefix",
cell: ({ row }) => {
return renderComponent(
PrefixDropdown, {
prefix: row.getValue("prefix"),
uuid: row.getValue("uuid"),
},
);
},
},
{
accessorKey: "perms",
header: "Permissions",
cell: ({ row }) => {
return renderComponent(
PermissionsDropdown,
{
perms: row.getValue("perms"),
uuid: row.getValue("uuid"),
},
);
},
},
];

View File

@@ -22,7 +22,7 @@
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import {onDestroy, onMount} from "svelte"; import {onDestroy, onMount} from "svelte";
import type { CollectionEntry } from "astro:content"; import { CollectionEntry } from "astro:content";
interface Props { interface Props {
pub: CollectionEntry<"publics">; pub: CollectionEntry<"publics">;

View File

@@ -0,0 +1,44 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2023 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {fetchWithToken, tokenStore} from "./repo.ts";
import {derived} from "svelte/store";
export class AuthRepo {
constructor(private token: string) {
}
public async login(username: string, password: string): Promise<string> {
return await fetchWithToken(this.token, "/auth/login", {
body: JSON.stringify({
username,
password,
}),
method: "POST",
}).then(value => value.json()).then(value => value.token);
}
public async logout(): Promise<void> {
await fetchWithToken(this.token, "/auth/tokens/logout", {
method: "POST",
});
}
}
export const authRepo = derived(tokenStore, ($token) => new AuthRepo($token));

View File

@@ -1,89 +0,0 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2025 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { readable, writable } from "svelte/store";
import { ResponseUserSchema } from "@components/types/data";
export class AuthV2Repo {
constructor() {
this.request("/data/me").then((value) => {
if (value.ok) {
loggedIn.set(true);
}
});
}
async login(name: string, password: string) {
try {
await this.request("/auth", {
method: "POST",
body: JSON.stringify({
name,
password,
keepLoggedIn: true,
}),
})
.then((value) => value.json())
.then((value) => ResponseUserSchema.parse(value));
loggedIn.set(true);
return true;
} catch (e) {
return false;
}
}
async loginDiscord(token: string) {
try {
await this.request("/auth/discord", {
method: "POST",
body: token,
})
.then((value) => value.json())
.then((value) => ResponseUserSchema.parse(value));
loggedIn.set(true);
return true;
} catch (e) {
return false;
}
}
async logout() {
await this.request("/auth", {
method: "DELETE",
});
loggedIn.set(false);
}
async request(url: string, params: RequestInit = {}) {
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {
...params,
credentials: "include",
headers: {
"Content-Type": "application/json",
...params.headers,
},
});
}
}
export const loggedIn = writable(false);
export const authV2Repo = readable(new AuthV2Repo());

View File

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

View File

@@ -17,26 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event"; import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
import { fetchWithToken, tokenStore } from "./repo"; import {fetchWithToken, tokenStore} from "./repo";
import { import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
ExtendedEventSchema, import {z} from "zod";
ShortEventSchema, import type {Dayjs} from "dayjs";
SWEventSchema, import {derived} from "svelte/store";
EventFightSchema,
ResponseGroupsSchema,
ResponseRelationSchema,
ResponseTeamSchema,
CreateEventGroupSchema,
UpdateEventGroupSchema,
CreateEventRelationSchema,
UpdateEventRelationSchema,
} from "@type/event.js";
import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEventRelation } from "@type/event.js";
import { z } from "zod";
import type { Dayjs } from "dayjs";
import { derived } from "svelte/store";
import { ResponseUserSchema } from "@components/types/data";
export interface CreateEvent { export interface CreateEvent {
name: string; name: string;
@@ -45,36 +31,30 @@ export interface CreateEvent {
} }
export interface UpdateEvent { export interface UpdateEvent {
name?: string | null; name: string | null;
start?: Dayjs | number | null; start: Dayjs | null;
end?: Dayjs | number | null; end: Dayjs | null;
deadline?: Dayjs | number | null; deadline: Dayjs | null;
maxTeamMembers?: number | null; maxTeamMembers: number | null;
schemType?: string | null; schemType: string | null;
publicSchemsOnly?: boolean | null; publicSchemsOnly: boolean | null;
addReferee?: string[] | null; addReferee: string[] | null;
removeReferee?: string[] | null; removeReferee: string[] | null;
}
export interface ResponseUser {
name: string;
uuid: string;
prefix: string;
perms: string[];
} }
export class EventRepo { export class EventRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async listEvents(): Promise<ShortEvent[]> { public async listEvents(): Promise<ShortEvent[]> {
return await fetchWithToken(this.token, "/events") return await fetchWithToken(this.token, "/events")
.then((value) => value.json()) .then(value => value.json())
.then((value) => z.array(ShortEventSchema).parse(value)); .then(value => z.array(ShortEventSchema).parse(value));
} }
public async getEvent(id: string): Promise<ExtendedEvent> { public async getEvent(id: string): Promise<ExtendedEvent> {
return await fetchWithToken(this.token, `/events/${id}`) return await fetchWithToken(this.token, `/events/${id}`)
.then((value) => value.json()) .then(value => value.json())
.then(ExtendedEventSchema.parse); .then(ExtendedEventSchema.parse);
} }
@@ -86,8 +66,7 @@ export class EventRepo {
start: +event.start, start: +event.start,
end: +event.end, end: +event.end,
}), }),
}) }).then(value => value.json())
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@@ -108,8 +87,7 @@ export class EventRepo {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) }).then(value => value.json())
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@@ -120,154 +98,6 @@ export class EventRepo {
return res.ok; return res.ok;
} }
// Fights
public async listFights(eventId: string): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then((value) => value.json())
.then((value) => z.array(EventFightSchema).parse(value));
}
public async createFight(eventId: string, fight: any): Promise<EventFight> {
delete fight.ergebnis;
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST",
body: JSON.stringify(fight),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(EventFightSchema.parse);
}
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "DELETE",
});
return res.ok;
}
// Groups
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
.then((value) => value.json())
.then((value) => z.array(ResponseGroupsSchema).parse(value));
}
public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
CreateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
method: "POST",
body: JSON.stringify({
name: group.name,
type: group.type,
}),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async getGroup(eventId: string, groupId: string): Promise<ResponseGroups> {
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`)
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async updateGroup(eventId: string, groupId: string, group: UpdateEventGroup): Promise<ResponseGroups> {
UpdateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "PUT",
body: JSON.stringify(group),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async deleteGroup(eventId: string, groupId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "DELETE",
});
return res.ok;
}
// Relations
public async listRelations(eventId: number): Promise<ResponseRelation[]> {
return await fetchWithToken(this.token, `/events/${eventId}/relations`)
.then((value) => value.json())
.then((value) => z.array(ResponseRelationSchema).parse(value));
}
public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
CreateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
method: "POST",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async getRelation(eventId: string, relationId: string): Promise<ResponseRelation> {
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`)
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
UpdateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "PUT",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "DELETE",
});
return res.ok;
}
// Teams
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
.then((value) => value.json())
.then((value) => z.array(ResponseTeamSchema).parse(value));
}
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "PUT",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "DELETE",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
// Referees
public async listReferees(eventId: string): Promise<ResponseUser[]> {
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
.then((value) => value.json())
.then((value) => z.array(ResponseUserSchema).parse(value));
}
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "PUT",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "DELETE",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
} }
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token)); export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));

View File

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

View File

@@ -17,26 +17,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Page, PageList } from "@type/page.ts"; import type {Page, PageList} from "@type/page.ts";
import { fetchWithToken, tokenStore } from "./repo.ts"; import {fetchWithToken, tokenStore} from "./repo.ts";
import { PageListSchema, PageSchema } from "@type/page.ts"; import {PageListSchema, PageSchema} from "@type/page.ts";
import { bytesToBase64 } from "../admin/util.ts"; import {bytesToBase64} from "../admin/util.ts";
import { z } from "zod"; import {z} from "zod";
import { derived } from "svelte/store"; import {derived} from "svelte/store";
export class PageRepo { export class PageRepo {
constructor(private token: string) {} constructor(private token: string) {
}
public async listPages(branch: string = "master"): Promise<PageList> { public async listPages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page?branch=${branch}`) return await fetchWithToken(this.token, `/page?branch=${branch}`)
.then((value) => value.json()) .then(value => value.json())
.then(PageListSchema.parse) .then(PageListSchema.parse)
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") }))); .then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")})));
} }
public async getPage(id: number, branch: string = "master"): Promise<Page> { public async getPage(id: number, branch: string = "master"): Promise<Page> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`) return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then((value) => value.json()) .then(value => value.json())
.then(PageSchema.parse); .then(PageSchema.parse);
} }
@@ -45,57 +46,42 @@ export class PageRepo {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)), content: bytesToBase64(new TextEncoder().encode(content)),
sha, sha, message,
message,
}), }),
}); });
} }
public async getBranches(): Promise<string[]> { public async getBranches(): Promise<string[]> {
return await fetchWithToken(this.token, "/page/branch") return await fetchWithToken(this.token, "/page/branch")
.then((value) => value.json()) .then(value => value.json())
.then((value) => z.array(z.string()).parse(value)); .then(value => z.array(z.string()).parse(value));
} }
public async createBranch(branch: string): Promise<void> { public async createBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) }); await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})});
} }
public async deleteBranch(branch: string): Promise<void> { public async deleteBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) }); await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})});
} }
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> { public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) }); await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})});
} }
public async merge(branch: string, message: string): Promise<void> { public async merge(branch: string, message: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch/merge", { await fetchWithToken(this.token, "/page/branch/merge", {
method: "POST", method: "POST",
body: JSON.stringify({ branch, message }), body: JSON.stringify({branch, message}),
}); });
} }
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> { public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, { await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify({ message, sha }), body: JSON.stringify({message, sha}),
});
}
public async listImages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
.then((value) => value.json())
.then(PageListSchema.parse)
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
}
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
method: "POST",
body: JSON.stringify({ name, data }),
}); });
} }
} }
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token)); export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));

View File

@@ -17,9 +17,31 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {get, writable} from "svelte/store"; import {writable} from "svelte/store";
import {authV2Repo} from "@repo/authv2.ts";
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params); 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 tokenStore = writable(""); 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));

View File

@@ -20,7 +20,6 @@
import {fetchWithToken, tokenStore} from "./repo.ts"; import {fetchWithToken, tokenStore} from "./repo.ts";
import {type Schematic, SchematicSchema} from "@type/schem.ts"; import {type Schematic, SchematicSchema} from "@type/schem.ts";
import {derived} from "svelte/store"; import {derived} from "svelte/store";
import {ResponseErrorSchema} from "@type/data.ts";
export class SchematicRepo { export class SchematicRepo {
constructor(private token: string) { constructor(private token: string) {
@@ -41,7 +40,7 @@ export class SchematicRepo {
name, name,
content, content,
}), }),
}).then(value => value.json()).then(SchematicSchema.or(ResponseErrorSchema).parse); });
} }
} }

View File

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

View File

@@ -17,31 +17,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { readonly, writable } from "svelte/store"; import {readonly, writable} from "svelte/store";
import type { Readable, Subscriber, Unsubscriber } from "svelte/store"; import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
export interface Cached<T> extends Readable<T> { export interface Cached<T> extends Readable<T> {
reload: () => void; reload: () => void;
future: Promise<T>;
} }
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> { export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
const store = writable<T>(normal); const store = writable<T>(normal);
const future = new Promise<T>((resolve) => {
let f = false;
store.subscribe((value) => {
if (f) {
resolve(value);
} else {
f = true;
}
});
});
let first = true; let first = true;
const reload = () => { const reload = () => {
init().then((data) => { init().then(data => {
store.set(data); store.set(data);
}); });
}; };
@@ -56,7 +45,6 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
return store.subscribe(run, invalidate); return store.subscribe(run, invalidate);
}, },
reload, reload,
future,
}; };
} }
@@ -70,7 +58,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
let first = true; let first = true;
const reload = () => { const reload = () => {
init(arg).then((data) => { init(arg).then(data => {
store.set(data); store.set(data);
}); });
}; };

View File

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

View File

@@ -19,7 +19,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"; import {onMount} from "svelte";
import {stopPropagation} from "@components/utils.ts"; import {stopPropagation} from "@components/util.ts";
interface Props { interface Props {
title: string; title: string;
@@ -68,18 +68,16 @@
}) })
</script> </script>
<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"> <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" class="w-fit"> <div onclick={stopPropagation(onclick)} aria-hidden="true">
<div class="p-6 border-b border-neutral-200 dark:border-neutral-700"> <div class="p-6 border-b border-neutral-200 dark:border-neutral-700">
<h1 class="text-4xl font-bold">{title}</h1> <h1 class="text-4xl font-bold">{title}</h1>
</div> </div>
<div class="p-6 main border-b border-neutral-200 dark:border-neutral-700"> <div class="p-6 main border-b border-neutral-200 dark:border-neutral-700">
{@render children?.()} {@render children?.()}
</div> </div>
<div class="mx-4 my-2 p-6"> <div class="flex mx-4 my-2 p-6" onclick={() => dialog.close()} aria-hidden="true">
<div class="ml-auto flex justify-end" onclick={() => dialog.close()} aria-hidden="true"> {@render footer?.()}
{@render footer?.()}
</div>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const SchematicTypeSchema = z.object({ export const SchematicTypeSchema = z.object({
name: z.string(), name: z.string(),
@@ -50,19 +50,3 @@ export const ServerSchema = z.object({
}); });
export type Server = z.infer<typeof ServerSchema>; export type Server = z.infer<typeof ServerSchema>;
export const ResponseErrorSchema = z.object({
error: z.string(),
code: z.string(),
});
export type ResponseError = z.infer<typeof ResponseErrorSchema>;
export const ResponseUserSchema = z.object({
name: z.string(),
uuid: z.string(),
prefix: z.string(),
perms: z.array(z.string()),
});
export type ResponseUser = z.infer<typeof ResponseUserSchema>;

View File

@@ -17,58 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
import { TeamSchema } from "./team.js"; import {TeamSchema} from "./team.js";
import { PlayerSchema, ResponseUserSchema } from "./data.js"; import {PlayerSchema} from "./data.js";
export const ResponseGroupsSchema = z.object({
id: z.number(),
name: z.string(),
pointsPerWin: z.number(),
pointsPerLoss: z.number(),
pointsPerDraw: z.number(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
points: z.record(z.string(), z.number()).nullable(),
});
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: ResponseGroupsSchema.nullable(),
hasFinished: z.boolean(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const EventFightEditSchema = EventFightSchema.omit({
id: true,
group: true,
hasFinished: true,
}).extend({
group: z.number().nullable(),
});
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
export const ResponseRelationSchema = z.object({
id: z.number(),
fight: z.number(),
team: z.enum(["RED", "BLUE"]),
type: z.enum(["FIGHT", "GROUP"]),
fromFight: EventFightSchema.optional(),
fromGroup: ResponseGroupsSchema.optional(),
fromPlace: z.number(),
});
export type ResponseRelation = z.infer<typeof ResponseRelationSchema>;
export const ShortEventSchema = z.object({ export const ShortEventSchema = z.object({
id: z.number(), id: z.number(),
@@ -84,69 +35,29 @@ export const SWEventSchema = ShortEventSchema.extend({
maxTeamMembers: z.number(), maxTeamMembers: z.number(),
schemType: z.string().nullable(), schemType: z.string().nullable(),
publicSchemsOnly: z.boolean(), publicSchemsOnly: z.boolean(),
referees: z.array(PlayerSchema),
}); });
export type SWEvent = z.infer<typeof SWEventSchema>; export type SWEvent = z.infer<typeof SWEventSchema>;
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: z.string().nullable(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const ExtendedEventSchema = z.object({ export const ExtendedEventSchema = z.object({
event: SWEventSchema, event: SWEventSchema,
teams: z.array(TeamSchema), teams: z.array(TeamSchema),
groups: z.array(ResponseGroupsSchema),
fights: z.array(EventFightSchema), fights: z.array(EventFightSchema),
referees: z.array(ResponseUserSchema),
relations: z.array(ResponseRelationSchema),
}); });
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>; export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
export const ResponseTeamSchema = z.object({
id: z.number(),
name: z.string(),
kuerzel: z.string(),
color: z.string(),
});
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
export const CreateEventGroupSchema = z.object({
name: z.string(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
});
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
export const UpdateEventGroupSchema = z.object({
name: z.string().nullable().optional(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
pointsPerWin: z.number().nullable().optional(),
pointsPerLoss: z.number().nullable().optional(),
pointsPerDraw: z.number().nullable().optional(),
});
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
export const GroupEditSchema = ResponseGroupsSchema.omit({
id: true,
points: true,
});
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
export const CreateEventRelationSchema = z.object({
fightId: z.number(),
team: z.enum(["RED", "BLUE"]),
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
export const UpdateFromRelationSchema = z.object({
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
export const UpdateEventRelationSchema = z.object({
team: z.enum(["RED", "BLUE"]).nullable().optional(),
from: UpdateFromRelationSchema.nullable().optional(),
});
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;

View File

@@ -17,11 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const PrefixSchema = z.object({ export const PrefixSchema = z.object({
name: z.string().startsWith("PREFIX_"), name: z.string().startsWith("PREFIX_"),
colorCode: z.string().startsWith("§"), colorCode: z.string().length(2).startsWith("§"),
chatPrefix: z.string(), chatPrefix: z.string(),
}); });

View File

@@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod"; import {z} from "zod";
export const TeamSchema = z.object({ export const TeamSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
kuerzel: z.string().min(1).max(16), kuerzel: z.string().min(1).max(4),
color: z.string().max(1), color: z.string().max(1),
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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